From 3da994af9a72316411046638d8650e60cde92468 Mon Sep 17 00:00:00 2001 From: Simone Radtke <94017602+SimoneRadtke-Cap@users.noreply.github.com> Date: Fri, 15 Nov 2024 10:27:02 +0100 Subject: [PATCH] EW-1011 Add migration to TspSyncStrategy (#5280) Co-authored-by: Maximilian Kreuzkam --- .../api-tsp-sync-cronjob-configmap.yml.j2 | 4 +- .../api-tsp-sync-init-configmap.yml.j2 | 4 +- apps/server/src/infra/sync/sync.module.ts | 13 +- .../tsp/loggable/tsp-data-fetched.loggable.ts | 2 +- .../tsp-missing-external-id.loggable.ts | 2 +- .../loggable/tsp-schools-fetched.loggable.ts | 2 +- .../loggable/tsp-schools-synced.loggable.ts | 2 +- .../tsp-schulnummer-missing.loggable.ts | 2 +- .../tsp-students-fetched.loggable.spec.ts | 26 ++ .../loggable/tsp-students-fetched.loggable.ts | 16 ++ .../tsp-students-migrated.loggable.spec.ts | 26 ++ .../tsp-students-migrated.loggable.ts | 16 ++ .../tsp/loggable/tsp-synced-users.loggable.ts | 2 +- .../loggable/tsp-syncing-users.loggable.ts | 2 +- ...tsp-system-not-found.loggable-exception.ts | 2 +- .../tsp-teachers-fetched.loggable.spec.ts | 26 ++ .../loggable/tsp-teachers-fetched.loggable.ts | 16 ++ .../tsp-teachers-migrated.loggable.spec.ts | 26 ++ .../tsp-teachers-migrated.loggable.ts | 16 ++ .../tsp-users-migrated.loggable.spec.ts | 26 ++ .../loggable/tsp-users-migrated.loggable.ts | 16 ++ .../sync/tsp/tsp-oauth-data.mapper.spec.ts | 4 +- .../infra/sync/tsp/tsp-oauth-data.mapper.ts | 4 +- .../src/infra/sync/tsp/tsp-sync.config.ts | 2 + .../infra/sync/tsp/tsp-sync.service.spec.ts | 266 +++++++++++++++++- .../src/infra/sync/tsp/tsp-sync.service.ts | 78 ++++- .../infra/sync/tsp/tsp-sync.strategy.spec.ts | 178 +++++++++++- .../src/infra/sync/tsp/tsp-sync.strategy.ts | 93 ++++++ .../src/modules/server/server.config.ts | 2 + .../modules/user/service/user-query.type.ts | 1 + .../src/shared/repo/user/user-do.repo.ts | 1 + .../src/shared/repo/user/user.scope.spec.ts | 19 ++ .../server/src/shared/repo/user/user.scope.ts | 7 + config/default.schema.json | 10 + 34 files changed, 890 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-students-fetched.loggable.spec.ts create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-students-fetched.loggable.ts create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-students-migrated.loggable.spec.ts create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-students-migrated.loggable.ts create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-teachers-fetched.loggable.spec.ts create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-teachers-fetched.loggable.ts create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-teachers-migrated.loggable.spec.ts create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-teachers-migrated.loggable.ts create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-users-migrated.loggable.spec.ts create mode 100644 apps/server/src/infra/sync/tsp/loggable/tsp-users-migrated.loggable.ts diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-cronjob-configmap.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-cronjob-configmap.yml.j2 index e59817acd58..5892f70c54b 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-cronjob-configmap.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-cronjob-configmap.yml.j2 @@ -22,4 +22,6 @@ data: TSP_SYNC_SCHOOL_LIMIT: "{{ TSP_SYNC_SCHOOL_LIMIT }}" TSP_SYNC_DATA_LIMIT: "{{ TSP_SYNC_DATA_LIMIT }}" TSP_SYNC_SCHOOL_DAYS_TO_FETCH: "{{ TSP_SYNC_SCHOOL_DAYS_TO_FETCH }}" - TSP_SYNC_DATA_DAYS_TO_FETCH: "{{ TSP_SYNC_DATA_DAYS_TO_FETCH }}" \ No newline at end of file + TSP_SYNC_DATA_DAYS_TO_FETCH: "{{ TSP_SYNC_DATA_DAYS_TO_FETCH }}" + FEATURE_TSP_MIGRATION_ENABLED: "{{ FEATURE_TSP_MIGRATION_ENABLED }}" + TSP_SYNC_MIGRATION_LIMIT: "{{ TSP_SYNC_MIGRATION_LIMIT }}" diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init-configmap.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init-configmap.yml.j2 index a79860325fa..22cefdacda2 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init-configmap.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init-configmap.yml.j2 @@ -22,4 +22,6 @@ data: TSP_SYNC_SCHOOL_LIMIT: "{{ TSP_SYNC_SCHOOL_LIMIT }}" TSP_SYNC_DATA_LIMIT: "{{ TSP_SYNC_DATA_LIMIT }}" TSP_SYNC_SCHOOL_DAYS_TO_FETCH: "{{ ((ansible_date_time.date | to_datetime('%Y-%m-%d')) - ("1970-01-01" | to_datetime('%Y-%m-%d'))).days }}" - TSP_SYNC_DATA_DAYS_TO_FETCH: "{{ ((ansible_date_time.date | to_datetime('%Y-%m-%d')) - ("1970-01-01" | to_datetime('%Y-%m-%d'))).days }}" \ No newline at end of file + TSP_SYNC_DATA_DAYS_TO_FETCH: "{{ ((ansible_date_time.date | to_datetime('%Y-%m-%d')) - ("1970-01-01" | to_datetime('%Y-%m-%d'))).days }}" + FEATURE_TSP_MIGRATION_ENABLED: "{{ FEATURE_TSP_MIGRATION_ENABLED }}" + TSP_SYNC_MIGRATION_LIMIT: "{{ TSP_SYNC_MIGRATION_LIMIT }}" diff --git a/apps/server/src/infra/sync/sync.module.ts b/apps/server/src/infra/sync/sync.module.ts index 40e02c83966..04e3fbca432 100644 --- a/apps/server/src/infra/sync/sync.module.ts +++ b/apps/server/src/infra/sync/sync.module.ts @@ -2,9 +2,11 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ConsoleWriterModule } from '@infra/console'; import { RabbitMQWrapperModule } from '@infra/rabbitmq'; import { TspClientModule } from '@infra/tsp-client/tsp-client.module'; +import { AccountModule } from '@modules/account'; import { LegacySchoolModule } from '@modules/legacy-school'; import { SchoolModule } from '@modules/school'; import { SystemModule } from '@modules/system'; +import { UserModule } from '@modules/user'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { ProvisioningModule } from '@src/modules/provisioning'; @@ -20,7 +22,16 @@ import { SyncUc } from './uc/sync.uc'; LoggerModule, ConsoleWriterModule, ...((Configuration.get('FEATURE_TSP_SYNC_ENABLED') as boolean) - ? [TspClientModule, SystemModule, SchoolModule, LegacySchoolModule, RabbitMQWrapperModule, ProvisioningModule] + ? [ + TspClientModule, + SystemModule, + SchoolModule, + LegacySchoolModule, + RabbitMQWrapperModule, + ProvisioningModule, + UserModule, + AccountModule, + ] : []), ], providers: [ diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-data-fetched.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-data-fetched.loggable.ts index ae9e62d96cc..e096993c328 100644 --- a/apps/server/src/infra/sync/tsp/loggable/tsp-data-fetched.loggable.ts +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-data-fetched.loggable.ts @@ -8,7 +8,7 @@ export class TspDataFetchedLoggable implements Loggable { private readonly daysFetched: number ) {} - getLogMessage(): LogMessage { + public getLogMessage(): LogMessage { const message: LogMessage = { message: `Fetched ${this.tspTeacherCount} teachers, ${this.tspStudentCount} students and ${this.tspClassesCount} classes for the last ${this.daysFetched} days from TSP`, data: { diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-missing-external-id.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-missing-external-id.loggable.ts index d8362100247..1ef7d8114b2 100644 --- a/apps/server/src/infra/sync/tsp/loggable/tsp-missing-external-id.loggable.ts +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-missing-external-id.loggable.ts @@ -3,7 +3,7 @@ import { Loggable, LogMessage } from '@src/core/logger'; export class TspMissingExternalIdLoggable implements Loggable { constructor(private readonly objectType: string) {} - getLogMessage(): LogMessage { + public getLogMessage(): LogMessage { const message: LogMessage = { message: `A ${this.objectType} is missing an id. It is skipped.`, data: { diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-schools-fetched.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-schools-fetched.loggable.ts index 62843fee0c3..f2f5bf512a7 100644 --- a/apps/server/src/infra/sync/tsp/loggable/tsp-schools-fetched.loggable.ts +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-schools-fetched.loggable.ts @@ -3,7 +3,7 @@ import { Loggable, LogMessage } from '@src/core/logger'; export class TspSchoolsFetchedLoggable implements Loggable { constructor(private readonly tspSchoolCount: number, private readonly daysFetched: number) {} - getLogMessage(): LogMessage { + public getLogMessage(): LogMessage { const message: LogMessage = { message: `Fetched ${this.tspSchoolCount} schools for the last ${this.daysFetched} days from TSP`, data: { diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-schools-synced.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-schools-synced.loggable.ts index e10ef69d194..1068270a151 100644 --- a/apps/server/src/infra/sync/tsp/loggable/tsp-schools-synced.loggable.ts +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-schools-synced.loggable.ts @@ -8,7 +8,7 @@ export class TspSchoolsSyncedLoggable implements Loggable { private readonly updatedSchools: number ) {} - getLogMessage(): LogMessage { + public getLogMessage(): LogMessage { const message: LogMessage = { message: `Synced schools: Of ${this.tspSchoolCount} schools ${this.processedSchools} were processed. ${this.createdSchools} were created and ${this.updatedSchools} were updated`, data: { diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-schulnummer-missing.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-schulnummer-missing.loggable.ts index ddd8e68f9df..3be67a149ee 100644 --- a/apps/server/src/infra/sync/tsp/loggable/tsp-schulnummer-missing.loggable.ts +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-schulnummer-missing.loggable.ts @@ -3,7 +3,7 @@ import { Loggable, LogMessage } from '@src/core/logger'; export class TspSchulnummerMissingLoggable implements Loggable { constructor(private readonly schulName?: string) {} - getLogMessage(): LogMessage { + public getLogMessage(): LogMessage { const message: LogMessage = { message: `The TSP school '${this.schulName ?? ''}' is missing a Schulnummer. This school is skipped.`, data: { diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-students-fetched.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-students-fetched.loggable.spec.ts new file mode 100644 index 00000000000..eeef45f5d8a --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-students-fetched.loggable.spec.ts @@ -0,0 +1,26 @@ +import { TspStudentsFetchedLoggable } from './tsp-students-fetched.loggable'; + +describe(TspStudentsFetchedLoggable.name, () => { + let loggable: TspStudentsFetchedLoggable; + + beforeAll(() => { + loggable = new TspStudentsFetchedLoggable(10); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message', () => { + expect(loggable.getLogMessage()).toEqual({ + message: `Fetched 10 students for migration from TSP`, + data: { + tspStudentCount: 10, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-students-fetched.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-students-fetched.loggable.ts new file mode 100644 index 00000000000..5a8af5758b3 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-students-fetched.loggable.ts @@ -0,0 +1,16 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspStudentsFetchedLoggable implements Loggable { + constructor(private readonly tspStudentCount: number) {} + + public getLogMessage(): LogMessage { + const message: LogMessage = { + message: `Fetched ${this.tspStudentCount} students for migration from TSP`, + data: { + tspStudentCount: this.tspStudentCount, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-students-migrated.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-students-migrated.loggable.spec.ts new file mode 100644 index 00000000000..725a1b845a8 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-students-migrated.loggable.spec.ts @@ -0,0 +1,26 @@ +import { TspStudentsMigratedLoggable } from './tsp-students-migrated.loggable'; + +describe(TspStudentsMigratedLoggable.name, () => { + let loggable: TspStudentsMigratedLoggable; + + beforeAll(() => { + loggable = new TspStudentsMigratedLoggable(10); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message', () => { + expect(loggable.getLogMessage()).toEqual({ + message: `Migrated students: 10 students migrated`, + data: { + migratedStudents: 10, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-students-migrated.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-students-migrated.loggable.ts new file mode 100644 index 00000000000..5937433ea6e --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-students-migrated.loggable.ts @@ -0,0 +1,16 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspStudentsMigratedLoggable implements Loggable { + constructor(private readonly migratedStudents: number) {} + + public getLogMessage(): LogMessage { + const message: LogMessage = { + message: `Migrated students: ${this.migratedStudents} students migrated`, + data: { + migratedStudents: this.migratedStudents, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-synced-users.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-synced-users.loggable.ts index 535a9ce0cb7..58a15f94cfa 100644 --- a/apps/server/src/infra/sync/tsp/loggable/tsp-synced-users.loggable.ts +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-synced-users.loggable.ts @@ -3,7 +3,7 @@ import { Loggable, LogMessage } from '@src/core/logger'; export class TspSyncedUsersLoggable implements Loggable { constructor(private readonly syncedUsers: number) {} - getLogMessage(): LogMessage { + public getLogMessage(): LogMessage { const message: LogMessage = { message: `Synced ${this.syncedUsers} users from TSP.`, data: { diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-syncing-users.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-syncing-users.loggable.ts index a6ecfe0aeff..58efd8e41c8 100644 --- a/apps/server/src/infra/sync/tsp/loggable/tsp-syncing-users.loggable.ts +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-syncing-users.loggable.ts @@ -3,7 +3,7 @@ import { Loggable, LogMessage } from '@src/core/logger'; export class TspSyncingUsersLoggable implements Loggable { constructor(private readonly syncingUsers: number) {} - getLogMessage(): LogMessage { + public getLogMessage(): LogMessage { const message: LogMessage = { message: `Syncing ${this.syncingUsers} users from TSP.`, data: { diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-system-not-found.loggable-exception.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-system-not-found.loggable-exception.ts index d7225c1cafa..c65fd7ad992 100644 --- a/apps/server/src/infra/sync/tsp/loggable/tsp-system-not-found.loggable-exception.ts +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-system-not-found.loggable-exception.ts @@ -14,7 +14,7 @@ export class TspSystemNotFoundLoggableException extends BusinessError implements ); } - getLogMessage(): LogMessage | ErrorLogMessage { + public getLogMessage(): LogMessage | ErrorLogMessage { const message: LogMessage | ErrorLogMessage = { message: this.message, type: this.type, diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-fetched.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-fetched.loggable.spec.ts new file mode 100644 index 00000000000..cf272bbf0f0 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-fetched.loggable.spec.ts @@ -0,0 +1,26 @@ +import { TspTeachersFetchedLoggable } from './tsp-teachers-fetched.loggable'; + +describe(TspTeachersFetchedLoggable.name, () => { + let loggable: TspTeachersFetchedLoggable; + + beforeAll(() => { + loggable = new TspTeachersFetchedLoggable(10); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message', () => { + expect(loggable.getLogMessage()).toEqual({ + message: `Fetched 10 teachers for migration from TSP`, + data: { + tspTeacherCount: 10, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-fetched.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-fetched.loggable.ts new file mode 100644 index 00000000000..476327462bc --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-fetched.loggable.ts @@ -0,0 +1,16 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspTeachersFetchedLoggable implements Loggable { + constructor(private readonly tspTeacherCount: number) {} + + public getLogMessage(): LogMessage { + const message: LogMessage = { + message: `Fetched ${this.tspTeacherCount} teachers for migration from TSP`, + data: { + tspTeacherCount: this.tspTeacherCount, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-migrated.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-migrated.loggable.spec.ts new file mode 100644 index 00000000000..df525470f2a --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-migrated.loggable.spec.ts @@ -0,0 +1,26 @@ +import { TspTeachersMigratedLoggable } from './tsp-teachers-migrated.loggable'; + +describe(TspTeachersMigratedLoggable.name, () => { + let loggable: TspTeachersMigratedLoggable; + + beforeAll(() => { + loggable = new TspTeachersMigratedLoggable(10); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message', () => { + expect(loggable.getLogMessage()).toEqual({ + message: `Migrated teachers: 10 teachers migrated`, + data: { + migratedTeachers: 10, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-migrated.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-migrated.loggable.ts new file mode 100644 index 00000000000..ebbe515d06c --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-teachers-migrated.loggable.ts @@ -0,0 +1,16 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspTeachersMigratedLoggable implements Loggable { + constructor(private readonly migratedTeachers: number) {} + + public getLogMessage(): LogMessage { + const message: LogMessage = { + message: `Migrated teachers: ${this.migratedTeachers} teachers migrated`, + data: { + migratedTeachers: this.migratedTeachers, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-users-migrated.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-users-migrated.loggable.spec.ts new file mode 100644 index 00000000000..22eb67a8ad9 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-users-migrated.loggable.spec.ts @@ -0,0 +1,26 @@ +import { TspUsersMigratedLoggable } from './tsp-users-migrated.loggable'; + +describe(TspUsersMigratedLoggable.name, () => { + let loggable: TspUsersMigratedLoggable; + + beforeAll(() => { + loggable = new TspUsersMigratedLoggable(10); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message', () => { + expect(loggable.getLogMessage()).toEqual({ + message: `Migrated users: 10 users migrated`, + data: { + migratedUsers: 10, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-users-migrated.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-users-migrated.loggable.ts new file mode 100644 index 00000000000..9000de6b9bc --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-users-migrated.loggable.ts @@ -0,0 +1,16 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspUsersMigratedLoggable implements Loggable { + constructor(private readonly migratedUsers: number) {} + + public getLogMessage(): LogMessage { + const message: LogMessage = { + message: `Migrated users: ${this.migratedUsers} users migrated`, + data: { + migratedUsers: this.migratedUsers, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.spec.ts b/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.spec.ts index 821a88d432a..fda468415cb 100644 --- a/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.spec.ts +++ b/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.spec.ts @@ -112,7 +112,7 @@ describe(TspOauthDataMapper.name, () => { system: provisioningSystemDto, externalUser: new ExternalUserDto({ externalId: tspTeachers[0].lehrerUid ?? '', - firstName: tspTeachers[0].lehrerNachname, + firstName: tspTeachers[0].lehrerVorname, lastName: tspTeachers[0].lehrerNachname, roles: [RoleName.TEACHER], }), @@ -123,7 +123,7 @@ describe(TspOauthDataMapper.name, () => { system: provisioningSystemDto, externalUser: new ExternalUserDto({ externalId: tspStudents[0].schuelerUid ?? '', - firstName: tspStudents[0].schuelerNachname, + firstName: tspStudents[0].schuelerVorname, lastName: tspStudents[0].schuelerNachname, roles: [RoleName.STUDENT], }), diff --git a/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.ts b/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.ts index 3237388d07f..31904871123 100644 --- a/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.ts +++ b/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.ts @@ -79,7 +79,7 @@ export class TspOauthDataMapper { const externalUser = new ExternalUserDto({ externalId: tspTeacher.lehrerUid, - firstName: tspTeacher.lehrerNachname, + firstName: tspTeacher.lehrerVorname, lastName: tspTeacher.lehrerNachname, roles: [RoleName.TEACHER], }); @@ -109,7 +109,7 @@ export class TspOauthDataMapper { const externalUser = new ExternalUserDto({ externalId: tspStudent.schuelerUid, - firstName: tspStudent.schuelerNachname, + firstName: tspStudent.schuelerVorname, lastName: tspStudent.schuelerNachname, roles: [RoleName.STUDENT], }); diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.config.ts b/apps/server/src/infra/sync/tsp/tsp-sync.config.ts index d6ac4204c9c..e26d0cb1369 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.config.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.config.ts @@ -3,4 +3,6 @@ export interface TspSyncConfig { TSP_SYNC_SCHOOL_DAYS_TO_FETCH: number; TSP_SYNC_DATA_LIMIT: number; TSP_SYNC_DATA_DAYS_TO_FETCH: number; + TSP_SYNC_MIGRATION_LIMIT: number; + FEATURE_TSP_MIGRATION_ENABLED: boolean; } diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.service.spec.ts b/apps/server/src/infra/sync/tsp/tsp-sync.service.spec.ts index fe8bcc4c4a3..4647822ab95 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.service.spec.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.service.spec.ts @@ -4,7 +4,9 @@ import { ExportApiInterface, RobjExportKlasse, RobjExportLehrer, + RobjExportLehrerMigration, RobjExportSchueler, + RobjExportSchuelerMigration, RobjExportSchule, TspClientFactory, } from '@infra/tsp-client'; @@ -12,12 +14,16 @@ import { School, SchoolService } from '@modules/school'; import { SystemService, SystemType } from '@modules/system'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { federalStateFactory, schoolYearFactory } from '@shared/testing'; +import { federalStateFactory, schoolYearFactory, userDoFactory } from '@shared/testing'; +import { AccountService } from '@src/modules/account'; +import { accountDoFactory } from '@src/modules/account/testing'; import { FederalStateService, SchoolYearService } from '@src/modules/legacy-school'; +import { OauthConfigMissingLoggableException } from '@src/modules/oauth/loggable'; import { SchoolProps } from '@src/modules/school/domain'; import { FederalStateEntityMapper, SchoolYearEntityMapper } from '@src/modules/school/repo/mikro-orm/mapper'; import { schoolFactory } from '@src/modules/school/testing'; import { systemFactory } from '@src/modules/system/testing'; +import { UserService } from '@src/modules/user'; import { AxiosResponse } from 'axios'; import { TspSyncService } from './tsp-sync.service'; @@ -29,6 +35,8 @@ describe(TspSyncService.name, () => { let schoolService: DeepMocked; let federalStateService: DeepMocked; let schoolYearService: DeepMocked; + let userService: DeepMocked; + let accountService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -54,6 +62,14 @@ describe(TspSyncService.name, () => { provide: SchoolYearService, useValue: createMock(), }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AccountService, + useValue: createMock(), + }, ], }).compile(); @@ -63,6 +79,8 @@ describe(TspSyncService.name, () => { schoolService = module.get(SchoolService); federalStateService = module.get(FederalStateService); schoolYearService = module.get(SchoolYearService); + userService = module.get(UserService); + accountService = module.get(AccountService); }); afterEach(() => { @@ -167,15 +185,47 @@ describe(TspSyncService.name, () => { data: classes, }); + const tspTeacherMigration: RobjExportLehrerMigration = { + lehrerUidAlt: faker.string.alpha(), + lehrerUidNeu: faker.string.alpha(), + }; + const teacherMigrations = [tspTeacherMigration]; + const responseTeacherMigrations = createMock>>({ + data: teacherMigrations, + }); + + const tspStudentMigration: RobjExportSchuelerMigration = { + schuelerUidAlt: faker.string.alpha(), + schuelerUidNeu: faker.string.alpha(), + }; + const studentMigrations = [tspStudentMigration]; + const responseStudentMigrations = createMock>>({ + data: studentMigrations, + }); + const exportApiMock = createMock(); exportApiMock.exportSchuleList.mockResolvedValueOnce(responseSchools); exportApiMock.exportLehrerList.mockResolvedValueOnce(responseTeachers); exportApiMock.exportSchuelerList.mockResolvedValueOnce(responseStudents); exportApiMock.exportKlasseList.mockResolvedValueOnce(responseClasses); + exportApiMock.exportLehrerListMigration.mockResolvedValueOnce(responseTeacherMigrations); + exportApiMock.exportSchuelerListMigration.mockResolvedValueOnce(responseStudentMigrations); tspClientFactory.createExportClient.mockReturnValueOnce(exportApiMock); - return { clientId, clientSecret, tokenEndpoint, system, exportApiMock, schools, teachers, students, classes }; + return { + clientId, + clientSecret, + tokenEndpoint, + system, + exportApiMock, + schools, + teachers, + students, + classes, + teacherMigrations, + studentMigrations, + }; }; describe('fetchTspSchools', () => { @@ -494,4 +544,216 @@ describe(TspSyncService.name, () => { }); }); }); + + describe('fetchTspTeacherMigrations', () => { + describe('when tsp teacher migrations are fetched', () => { + it('should create the client', async () => { + const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); + + await sut.fetchTspTeacherMigrations(system); + + expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ + clientId, + clientSecret, + tokenEndpoint, + }); + }); + + it('should call exportLehrerListMigration', async () => { + const { system, exportApiMock } = setupTspClient(); + + await sut.fetchTspTeacherMigrations(system); + + expect(exportApiMock.exportLehrerListMigration).toHaveBeenCalledTimes(1); + }); + + it('should return an array of teacher migrations', async () => { + const { system } = setupTspClient(); + + const result = await sut.fetchTspTeacherMigrations(system); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(Array); + }); + }); + }); + + describe('fetchTspStudentMigrations', () => { + describe('when tsp student migrations are fetched', () => { + it('should create the client', async () => { + const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); + + await sut.fetchTspStudentMigrations(system); + + expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ + clientId, + clientSecret, + tokenEndpoint, + }); + }); + + it('should call exportSchuelerListMigration', async () => { + const { system, exportApiMock } = setupTspClient(); + + await sut.fetchTspStudentMigrations(system); + + expect(exportApiMock.exportSchuelerListMigration).toHaveBeenCalledTimes(1); + }); + + it('should return an array of student migrations', async () => { + const { system } = setupTspClient(); + + const result = await sut.fetchTspStudentMigrations(system); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(Array); + }); + }); + }); + + describe('findUserByTspUid', () => { + describe('when user is found', () => { + const setup = () => { + const tspUid = faker.string.alpha(); + const user = userDoFactory.build(); + + userService.findUsers.mockResolvedValueOnce({ data: [user], total: 1 }); + + return { tspUid, user }; + }; + + it('should return the user', async () => { + const { tspUid, user } = setup(); + + const result = await sut.findUserByTspUid(tspUid); + + expect(result).toBe(user); + }); + }); + + describe('when user is not found', () => { + const setup = () => { + const tspUid = faker.string.alpha(); + + userService.findUsers.mockResolvedValueOnce({ data: [], total: 0 }); + + return { tspUid }; + }; + + it('should return null', async () => { + const { tspUid } = setup(); + + const result = await sut.findUserByTspUid(tspUid); + + expect(result).toBeNull(); + }); + }); + }); + + describe('findAccountByTspUid', () => { + describe('when account is found', () => { + const setup = () => { + const tspUid = faker.string.alpha(); + const user = userDoFactory.build(); + const account = accountDoFactory.build(); + + user.id = tspUid; + account.userId = user.id; + + userService.findUsers.mockResolvedValueOnce({ data: [user], total: 1 }); + accountService.findByUserId.mockResolvedValueOnce(account); + + return { tspUid, account }; + }; + + it('should return the account', async () => { + const { tspUid, account } = setup(); + + const result = await sut.findAccountByTspUid(tspUid); + + expect(result).toBe(account); + }); + }); + + describe('when account is not found', () => { + const setup = () => { + const tspUid = faker.string.alpha(); + const user = userDoFactory.build(); + + userService.findUsers.mockResolvedValueOnce({ data: [user], total: 0 }); + accountService.findByUserId.mockResolvedValueOnce(null); + + return { tspUid }; + }; + + it('should return null', async () => { + const { tspUid } = setup(); + + const result = await sut.findAccountByTspUid(tspUid); + + expect(result).toBeNull(); + }); + }); + }); + + describe('updateUser', () => { + describe('when user is updated', () => { + const setup = () => { + const oldUid = faker.string.alpha(); + const newUid = faker.string.alpha(); + const email = faker.internet.email(); + const user = userDoFactory.build(); + + userService.save.mockResolvedValueOnce(user); + + return { oldUid, newUid, email, user }; + }; + + it('should return the updated user', async () => { + const { oldUid, newUid, email, user } = setup(); + + const result = await sut.updateUser(user, email, newUid, oldUid); + + expect(result).toBe(user); + }); + }); + }); + + describe('updateAccount', () => { + describe('when account is updated', () => { + const setup = () => { + const username = faker.internet.userName(); + const systemId = faker.string.alpha(); + const account = accountDoFactory.build(); + + accountService.save.mockResolvedValueOnce(account); + + return { username, systemId, account }; + }; + + it('should return the updated account', async () => { + const { username, systemId, account } = setup(); + + const result = await sut.updateAccount(account, username, systemId); + + expect(result).toBe(account); + }); + }); + }); + + describe('createClient', () => { + describe('when oauthConfig is missing', () => { + const setup = () => { + const system = systemFactory.build({ oauthConfig: undefined }); + + return { system }; + }; + + it('should throw an OauthConfigMissingLoggableException', async () => { + const { system } = setup(); + + await expect(async () => sut.fetchTspSchools(system, 1)).rejects.toThrow(OauthConfigMissingLoggableException); + }); + }); + }); }); diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.service.ts b/apps/server/src/infra/sync/tsp/tsp-sync.service.ts index 084990d8f8e..2d48792c356 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.service.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.service.ts @@ -3,12 +3,16 @@ import { FederalStateService, SchoolYearService } from '@modules/legacy-school'; import { School, SchoolService } from '@modules/school'; import { System, SystemService, SystemType } from '@modules/system'; import { Injectable } from '@nestjs/common'; +import { UserDO } from '@shared/domain/domainobject'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { SchoolFeature } from '@shared/domain/types'; +import { Account, AccountService } from '@src/modules/account'; import { FederalStateNames } from '@src/modules/legacy-school/types'; +import { OauthConfigMissingLoggableException } from '@src/modules/oauth/loggable'; import { FederalState } from '@src/modules/school/domain'; import { SchoolFactory } from '@src/modules/school/domain/factory'; import { FederalStateEntityMapper, SchoolYearEntityMapper } from '@src/modules/school/repo/mikro-orm/mapper'; +import { UserService } from '@src/modules/user'; import { ObjectId } from 'bson'; import moment from 'moment/moment'; import { TspSystemNotFoundLoggableException } from './loggable/tsp-system-not-found.loggable-exception'; @@ -22,7 +26,9 @@ export class TspSyncService { private readonly systemService: SystemService, private readonly schoolService: SchoolService, private readonly federalStateService: FederalStateService, - private readonly schoolYearService: SchoolYearService + private readonly schoolYearService: SchoolYearService, + private readonly userService: UserService, + private readonly accountService: AccountService ) {} public async findTspSystemOrFail(): Promise { @@ -143,15 +149,79 @@ export class TspSyncService { return this.federalState; } + public async fetchTspTeacherMigrations(system: System) { + const client = this.createClient(system); + + const teacherMigrationsResponse = await client.exportLehrerListMigration(); + const teacherMigrations = teacherMigrationsResponse.data; + + return teacherMigrations; + } + + public async fetchTspStudentMigrations(system: System) { + const client = this.createClient(system); + + const studentMigrationsResponse = await client.exportSchuelerListMigration(); + const studentMigrations = studentMigrationsResponse.data; + + return studentMigrations; + } + + public async findUserByTspUid(tspUid: string): Promise { + const tspUser = await this.userService.findUsers({ tspUid }); + + if (tspUser.data.length === 0) { + return null; + } + + return tspUser.data[0]; + } + + public async findAccountByTspUid(tspUid: string): Promise { + const user = await this.findUserByTspUid(tspUid); + + if (!user || !user.id) { + return null; + } + + const account = await this.accountService.findByUserId(user.id); + + return account; + } + + public async updateUser( + user: UserDO, + email: string, + externalId: string, + previousExternalId: string + ): Promise { + user.email = email; + user.externalId = externalId; + user.previousExternalId = previousExternalId; + + return this.userService.save(user); + } + + public async updateAccount(account: Account, username: string, systemId: string): Promise { + account.username = username; + account.systemId = systemId; + + return this.accountService.save(account); + } + private formatChangeDate(daysToFetch: number): string { return moment(new Date()).subtract(daysToFetch, 'days').subtract(1, 'hours').format('YYYY-MM-DD HH:mm:ss.SSS'); } private createClient(system: System) { + if (!system.oauthConfig) { + throw new OauthConfigMissingLoggableException(system.id); + } + const client = this.tspClientFactory.createExportClient({ - clientId: system.oauthConfig?.clientId ?? '', - clientSecret: system.oauthConfig?.clientSecret ?? '', - tokenEndpoint: system.oauthConfig?.tokenEndpoint ?? '', + clientId: system.oauthConfig.clientId, + clientSecret: system.oauthConfig.clientSecret, + tokenEndpoint: system.oauthConfig.tokenEndpoint, }); return client; diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts index c7877e9977c..0b483133e90 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts @@ -1,12 +1,15 @@ import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { RobjExportSchule } from '@infra/tsp-client'; +import { RobjExportLehrerMigration, RobjExportSchuelerMigration, RobjExportSchule } from '@infra/tsp-client'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { userDoFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { accountDoFactory } from '@src/modules/account/testing'; import { ExternalUserDto, OauthDataDto, ProvisioningService, ProvisioningSystemDto } from '@src/modules/provisioning'; import { schoolFactory } from '@src/modules/school/testing'; +import { systemFactory } from '@src/modules/system/testing'; import { SyncStrategyTarget } from '../sync-strategy.types'; import { TspOauthDataMapper } from './tsp-oauth-data.mapper'; import { TspSyncConfig } from './tsp-sync.config'; @@ -45,6 +48,10 @@ describe(TspSyncStrategy.name, () => { return 10; case 'TSP_SYNC_DATA_DAYS_TO_FETCH': return 1; + case 'TSP_SYNC_MIGRATION_LIMIT': + return 10; + case 'FEATURE_TSP_MIGRATION_ENABLED': + return true; default: throw new Error(`Unknown key: ${key}`); } @@ -112,6 +119,25 @@ describe(TspSyncStrategy.name, () => { tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([oauthDataDto]); + const tspTeacher: RobjExportLehrerMigration = { + lehrerUidAlt: faker.string.alpha(), + lehrerUidNeu: faker.string.alpha(), + }; + + const tspStudent: RobjExportSchuelerMigration = { + schuelerUidAlt: faker.string.alpha(), + schuelerUidNeu: faker.string.alpha(), + }; + + tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([tspTeacher]); + tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([tspStudent]); + tspSyncService.findUserByTspUid.mockResolvedValueOnce(userDoFactory.build()); + tspSyncService.updateUser.mockResolvedValueOnce(userDoFactory.build()); + tspSyncService.findAccountByTspUid.mockResolvedValueOnce(accountDoFactory.build()); + tspSyncService.updateAccount.mockResolvedValueOnce(accountDoFactory.build()); + + tspSyncService.findTspSystemOrFail.mockResolvedValueOnce(systemFactory.build()); + return { oauthDataDto }; }; @@ -162,6 +188,56 @@ describe(TspSyncStrategy.name, () => { expect(provisioningService.provisionData).toHaveBeenCalledWith(oauthDataDto); }); + + describe('when feature tsp migration is enabled', () => { + it('should fetch teacher migrations', async () => { + setup(); + + await sut.sync(); + + expect(tspSyncService.fetchTspTeacherMigrations).toHaveBeenCalled(); + }); + + it('should fetch student migrations', async () => { + setup(); + + await sut.sync(); + + expect(tspSyncService.fetchTspStudentMigrations).toHaveBeenCalled(); + }); + + it('find user by tsp Uid', async () => { + setup(); + + await sut.sync(); + + expect(tspSyncService.findUserByTspUid).toHaveBeenCalled(); + }); + + it('should update user', async () => { + setup(); + + await sut.sync(); + + expect(tspSyncService.updateUser).toHaveBeenCalled(); + }); + + it('should find account by tsp Uid', async () => { + setup(); + + await sut.sync(); + + expect(tspSyncService.findAccountByTspUid).toHaveBeenCalled(); + }); + + it('should update account', async () => { + setup(); + + await sut.sync(); + + expect(tspSyncService.updateAccount).toHaveBeenCalled(); + }); + }); }); describe('when school does not exist', () => { @@ -179,6 +255,8 @@ describe(TspSyncStrategy.name, () => { tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); tspSyncService.findSchoolsForSystem.mockResolvedValueOnce([]); + tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([]); + tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); }; @@ -207,6 +285,8 @@ describe(TspSyncStrategy.name, () => { tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); tspSyncService.findSchoolsForSystem.mockResolvedValueOnce([]); + tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([]); + tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); }; @@ -232,6 +312,8 @@ describe(TspSyncStrategy.name, () => { tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); tspSyncService.findSchoolsForSystem.mockResolvedValueOnce([]); + tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([]); + tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); }; @@ -245,5 +327,99 @@ describe(TspSyncStrategy.name, () => { expect(tspSyncService.createSchool).not.toHaveBeenCalled(); }); }); + + describe('when UidAlt or UidNeu is missing during migration', () => { + const setup = () => { + const tspTeacher: RobjExportLehrerMigration = { + lehrerUidAlt: undefined, + lehrerUidNeu: faker.string.alpha(), + }; + + tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([tspTeacher]); + + const tspStudent: RobjExportSchuelerMigration = { + schuelerUidAlt: faker.string.alpha(), + schuelerUidNeu: undefined, + }; + + tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([tspStudent]); + + tspSyncService.fetchTspSchools.mockResolvedValueOnce([]); + tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); + tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); + tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); + tspSyncService.findTspSystemOrFail.mockResolvedValueOnce(systemFactory.build()); + tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); + }; + + it('should return false and not call findUserByTspUid', async () => { + setup(); + + await sut.sync(); + + expect(tspSyncService.findUserByTspUid).not.toHaveBeenCalled(); + }); + }); + + describe('when no user is found during migration', () => { + const setup = () => { + const tspTeacher: RobjExportLehrerMigration = { + lehrerUidAlt: faker.string.alpha(), + lehrerUidNeu: faker.string.alpha(), + }; + + tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([tspTeacher]); + tspSyncService.findUserByTspUid.mockResolvedValueOnce(null); + + tspSyncService.fetchTspSchools.mockResolvedValueOnce([]); + tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); + tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); + tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); + tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); + tspSyncService.findTspSystemOrFail.mockResolvedValueOnce(systemFactory.build()); + tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); + + return { tspTeacher }; + }; + + it('should throw and not call updateUser', async () => { + setup(); + + await sut.sync(); + + expect(tspSyncService.updateUser).not.toHaveBeenCalled(); + }); + }); + + describe('when no account is found during migration', () => { + const setup = () => { + const tspTeacher: RobjExportLehrerMigration = { + lehrerUidAlt: faker.string.alpha(), + lehrerUidNeu: faker.string.alpha(), + }; + + tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([tspTeacher]); + tspSyncService.findAccountByTspUid.mockResolvedValueOnce(null); + + tspSyncService.fetchTspSchools.mockResolvedValueOnce([]); + tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); + tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); + tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); + + tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); + tspSyncService.findUserByTspUid.mockResolvedValueOnce(userDoFactory.build()); + tspSyncService.updateUser.mockResolvedValueOnce(userDoFactory.build()); + tspSyncService.findTspSystemOrFail.mockResolvedValueOnce(systemFactory.build()); + tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); + }; + + it('should throw and not call updateAccount', async () => { + setup(); + + await sut.sync(); + + expect(tspSyncService.updateAccount).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts index b7273d7cfdc..e8b99104cc0 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts @@ -1,7 +1,10 @@ import { School } from '@modules/school'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { UserDO } from '@shared/domain/domainobject'; import { Logger } from '@src/core/logger'; +import { Account } from '@src/modules/account'; import { ProvisioningService } from '@src/modules/provisioning'; import { System } from '@src/modules/system'; import pLimit from 'p-limit'; @@ -11,8 +14,13 @@ import { TspDataFetchedLoggable } from './loggable/tsp-data-fetched.loggable'; import { TspSchoolsFetchedLoggable } from './loggable/tsp-schools-fetched.loggable'; import { TspSchoolsSyncedLoggable } from './loggable/tsp-schools-synced.loggable'; import { TspSchulnummerMissingLoggable } from './loggable/tsp-schulnummer-missing.loggable'; +import { TspStudentsFetchedLoggable } from './loggable/tsp-students-fetched.loggable'; +import { TspStudentsMigratedLoggable } from './loggable/tsp-students-migrated.loggable'; import { TspSyncedUsersLoggable } from './loggable/tsp-synced-users.loggable'; import { TspSyncingUsersLoggable } from './loggable/tsp-syncing-users.loggable'; +import { TspTeachersFetchedLoggable } from './loggable/tsp-teachers-fetched.loggable'; +import { TspTeachersMigratedLoggable } from './loggable/tsp-teachers-migrated.loggable'; +import { TspUsersMigratedLoggable } from './loggable/tsp-users-migrated.loggable'; import { TspOauthDataMapper } from './tsp-oauth-data.mapper'; import { TspSyncConfig } from './tsp-sync.config'; import { TspSyncService } from './tsp-sync.service'; @@ -23,10 +31,14 @@ export class TspSyncStrategy extends SyncStrategy { private readonly dataLimit: pLimit.Limit; + private readonly migrationLimit: pLimit.Limit; + private readonly schoolDaysToFetch: number; private readonly schoolDataDaysToFetch: number; + private readonly migrationEnabled: boolean; + constructor( private readonly logger: Logger, private readonly tspSyncService: TspSyncService, @@ -42,6 +54,9 @@ export class TspSyncStrategy extends SyncStrategy { this.dataLimit = pLimit(configService.getOrThrow('TSP_SYNC_DATA_LIMIT')); this.schoolDataDaysToFetch = configService.get('TSP_SYNC_DATA_DAYS_TO_FETCH', 1); + + this.migrationLimit = pLimit(configService.getOrThrow('TSP_SYNC_MIGRATION_LIMIT')); + this.migrationEnabled = configService.get('FEATURE_TSP_MIGRATION_ENABLED', false); } public override getType(): SyncStrategyTarget { @@ -55,6 +70,13 @@ export class TspSyncStrategy extends SyncStrategy { const schools = await this.tspSyncService.findSchoolsForSystem(system); + if (this.migrationEnabled) { + const teacherMigrationResult = await this.migrateTspTeachers(system); + const studentMigrationResult = await this.migrateTspStudents(system); + const totalMigrations = teacherMigrationResult.total + studentMigrationResult.total; + this.logger.info(new TspUsersMigratedLoggable(totalMigrations)); + } + await this.syncData(system, schools); } @@ -122,4 +144,75 @@ export class TspSyncStrategy extends SyncStrategy { this.logger.info(new TspSyncedUsersLoggable(results.length)); } + + private async migrateTspTeachers(system: System): Promise<{ total: number }> { + const tspTeacherIds = await this.tspSyncService.fetchTspTeacherMigrations(system); + this.logger.info(new TspTeachersFetchedLoggable(tspTeacherIds.length)); + + const teacherMigrationPromises = tspTeacherIds.map(({ lehrerUidAlt, lehrerUidNeu }) => + this.migrationLimit(async () => { + if (lehrerUidAlt && lehrerUidNeu) { + await this.migrateTspUser(lehrerUidAlt, lehrerUidNeu, system.id); + return true; + } + return false; + }) + ); + + const migratedTspTeachers = await Promise.allSettled(teacherMigrationPromises); + + const total = migratedTspTeachers.filter((result) => result.status === 'fulfilled' && result.value === true).length; + this.logger.info(new TspTeachersMigratedLoggable(total)); + + return { total }; + } + + private async migrateTspStudents(system: System): Promise<{ total: number }> { + const tspStudentIds = await this.tspSyncService.fetchTspStudentMigrations(system); + this.logger.info(new TspStudentsFetchedLoggable(tspStudentIds.length)); + + const studentMigrationPromises = tspStudentIds.map(({ schuelerUidAlt, schuelerUidNeu }) => + this.migrationLimit(async () => { + if (schuelerUidAlt && schuelerUidNeu) { + await this.migrateTspUser(schuelerUidAlt, schuelerUidNeu, system.id); + return true; + } + return false; + }) + ); + + const migratedStudents = await Promise.allSettled(studentMigrationPromises); + + const total = migratedStudents.filter((result) => result.status === 'fulfilled' && result.value === true).length; + this.logger.info(new TspStudentsMigratedLoggable(total)); + + return { total }; + } + + private async migrateTspUser( + oldUid: string, + newUid: string, + systemId: string + ): Promise<{ updatedUser: UserDO; updatedAccount: Account }> { + const newEmailAndUsername = `${newUid}@schul-cloud.org`; + const user = await this.tspSyncService.findUserByTspUid(oldUid); + + if (!user) { + throw new NotFoundLoggableException(UserDO.name, { oldUid }); + } + + const newEmail = newEmailAndUsername; + const updatedUser = await this.tspSyncService.updateUser(user, newEmail, newUid, oldUid); + + const account = await this.tspSyncService.findAccountByTspUid(oldUid); + + if (!account) { + throw new NotFoundLoggableException(Account.name, { oldUid }); + } + + const newUsername = newEmailAndUsername; + const updatedAccount = await this.tspSyncService.updateAccount(account, newUsername, systemId); + + return { updatedUser, updatedAccount }; + } } diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 2e6da8d4dcd..81bc4e337a0 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -316,6 +316,8 @@ const config: ServerConfig = { TSP_SYNC_SCHOOL_DAYS_TO_FETCH: Configuration.get('TSP_SYNC_SCHOOL_DAYS_TO_FETCH') as number, TSP_SYNC_DATA_LIMIT: Configuration.get('TSP_SYNC_DATA_LIMIT') as number, TSP_SYNC_DATA_DAYS_TO_FETCH: Configuration.get('TSP_SYNC_DATA_DAYS_TO_FETCH') as number, + TSP_SYNC_MIGRATION_LIMIT: Configuration.get('TSP_SYNC_MIGRATION_LIMIT') as number, + FEATURE_TSP_MIGRATION_ENABLED: Configuration.get('FEATURE_TSP_MIGRATION_ENABLED') as boolean, ROCKET_CHAT_URI: Configuration.get('ROCKET_CHAT_URI') as string, ROCKET_CHAT_ADMIN_ID: Configuration.get('ROCKET_CHAT_ADMIN_ID') as string, ROCKET_CHAT_ADMIN_TOKEN: Configuration.get('ROCKET_CHAT_ADMIN_TOKEN') as string, diff --git a/apps/server/src/modules/user/service/user-query.type.ts b/apps/server/src/modules/user/service/user-query.type.ts index c97c236ff41..cd1a4d05a42 100644 --- a/apps/server/src/modules/user/service/user-query.type.ts +++ b/apps/server/src/modules/user/service/user-query.type.ts @@ -13,4 +13,5 @@ export type UserQuery = Partial> & { lastLoginSystemChangeSmallerThan?: Date; lastLoginSystemChangeBetweenStart?: Date; lastLoginSystemChangeBetweenEnd?: Date; + tspUid?: string; }; diff --git a/apps/server/src/shared/repo/user/user-do.repo.ts b/apps/server/src/shared/repo/user/user-do.repo.ts index 19cd2206d8e..265463f4335 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.ts @@ -22,6 +22,7 @@ export class UserDORepo extends BaseDORepo { const order: QueryOrderMap = this.createQueryOrderMap(options?.order || {}); const scope: Scope = new UserScope() .bySchoolId(query.schoolId) + .byTspUid(query.tspUid) .byRoleId(query.roleId) .withDiscoverableTrue(query.discoverable) .isOutdated(query.isOutdated) diff --git a/apps/server/src/shared/repo/user/user.scope.spec.ts b/apps/server/src/shared/repo/user/user.scope.spec.ts index aaeed2a863b..e26c1ee5fea 100644 --- a/apps/server/src/shared/repo/user/user.scope.spec.ts +++ b/apps/server/src/shared/repo/user/user.scope.spec.ts @@ -243,4 +243,23 @@ describe('UserScope', () => { }); }); }); + + describe('byTspUid is called', () => { + describe('when tsp parameter is defined', () => { + it('should return scope with added tspUid to query', () => { + const tspUid = 'tspUid'; + + scope.byTspUid(tspUid); + + expect(scope.query).toEqual({ sourceOptions: { tspUid } }); + }); + }); + + describe('when tsp parameter is undefined', () => { + it('should return scope without added tspUid to query', () => { + scope.byTspUid(undefined); + expect(scope.query).toEqual({}); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/user/user.scope.ts b/apps/server/src/shared/repo/user/user.scope.ts index 408142f4edc..a13b2ec97f6 100644 --- a/apps/server/src/shared/repo/user/user.scope.ts +++ b/apps/server/src/shared/repo/user/user.scope.ts @@ -73,4 +73,11 @@ export class UserScope extends Scope { } return this; } + + byTspUid(tspUid?: string): UserScope { + if (tspUid !== undefined) { + this.addQuery({ sourceOptions: { tspUid } }); + } + return this; + } } diff --git a/config/default.schema.json b/config/default.schema.json index 0027fcaad35..d62209b5606 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -213,6 +213,16 @@ "default": "1", "description": "The amount of days for which the sync fetches school data from the TSP." }, + "TSP_SYNC_MIGRATION_LIMIT": { + "type": "number", + "default": "100", + "description": "The amount of users the sync migrates at once." + }, + "FEATURE_TSP_MIGRATION_ENABLED": { + "type": "boolean", + "default": false, + "description": "Feature toggle for TSP migration." + }, "FEATURE_TSP_ENABLED": { "type": "boolean", "default": false,