diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init.yml.j2 index 3ec8144a7d8..6c126f8b981 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init.yml.j2 @@ -50,5 +50,5 @@ spec: requests: cpu: {{ API_CPU_REQUESTS|default("100m", true) }} memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} - restartPolicy: Never + restartPolicy: OnFailure backoffLimit: 5 diff --git a/apps/server/src/infra/sync/sync.module.ts b/apps/server/src/infra/sync/sync.module.ts index 75a9a0d45d4..0bf0c2dc0ec 100644 --- a/apps/server/src/infra/sync/sync.module.ts +++ b/apps/server/src/infra/sync/sync.module.ts @@ -12,6 +12,7 @@ import { LoggerModule } from '@src/core/logger'; import { ProvisioningModule } from '@src/modules/provisioning'; import { SyncConsole } from './console/sync.console'; import { SyncService } from './service/sync.service'; +import { TspLegacyMigrationService } from './tsp/tsp-legacy-migration.service'; import { TspOauthDataMapper } from './tsp/tsp-oauth-data.mapper'; import { TspSyncService } from './tsp/tsp-sync.service'; import { TspSyncStrategy } from './tsp/tsp-sync.strategy'; @@ -40,7 +41,7 @@ import { TspFetchService } from './tsp/tsp-fetch.service'; SyncUc, SyncService, ...((Configuration.get('FEATURE_TSP_SYNC_ENABLED') as boolean) - ? [TspSyncStrategy, TspSyncService, TspOauthDataMapper, TspFetchService] + ? [TspSyncStrategy, TspSyncService, TspOauthDataMapper, TspFetchService, TspLegacyMigrationService] : []), ], exports: [SyncConsole], diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.spec.ts new file mode 100644 index 00000000000..ccba74ee0f8 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.spec.ts @@ -0,0 +1,23 @@ +import { TspLegacyMigrationStartLoggable } from './tsp-legacy-migration-start.loggable'; + +describe(TspLegacyMigrationStartLoggable.name, () => { + let loggable: TspLegacyMigrationStartLoggable; + + beforeAll(() => { + loggable = new TspLegacyMigrationStartLoggable(); + }); + + 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: 'Running migration of legacy tsp data.', + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.ts new file mode 100644 index 00000000000..c3af20dc0c7 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.ts @@ -0,0 +1,11 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspLegacyMigrationStartLoggable implements Loggable { + getLogMessage(): LogMessage { + const message: LogMessage = { + message: 'Running migration of legacy tsp data.', + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.spec.ts new file mode 100644 index 00000000000..fe7dc8eed89 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.spec.ts @@ -0,0 +1,23 @@ +import { TspLegacyMigrationSystemMissingLoggable } from './tsp-legacy-migration-system-missing.loggable'; + +describe(TspLegacyMigrationSystemMissingLoggable.name, () => { + let loggable: TspLegacyMigrationSystemMissingLoggable; + + beforeAll(() => { + loggable = new TspLegacyMigrationSystemMissingLoggable(); + }); + + 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: 'No legacy system found', + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.ts new file mode 100644 index 00000000000..fcdf3b26d0a --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.ts @@ -0,0 +1,11 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspLegacyMigrationSystemMissingLoggable implements Loggable { + getLogMessage(): LogMessage { + const message: LogMessage = { + message: 'No legacy system found', + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.spec.ts new file mode 100644 index 00000000000..8d5bad881a2 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.spec.ts @@ -0,0 +1,26 @@ +import { TspLegacySchoolMigrationCountLoggable } from './tsp-legacy-school-migration-count.loggable'; + +describe(TspLegacySchoolMigrationCountLoggable.name, () => { + let loggable: TspLegacySchoolMigrationCountLoggable; + + beforeAll(() => { + loggable = new TspLegacySchoolMigrationCountLoggable(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: `Found 10 legacy tsp schools to migrate`, + data: { + total: 10, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.ts new file mode 100644 index 00000000000..c04fc6b5a53 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.ts @@ -0,0 +1,16 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspLegacySchoolMigrationCountLoggable implements Loggable { + constructor(private readonly total: number) {} + + getLogMessage(): LogMessage { + const message: LogMessage = { + message: `Found ${this.total} legacy tsp schools to migrate`, + data: { + total: this.total, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.spec.ts new file mode 100644 index 00000000000..d8bd6bee2ac --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.spec.ts @@ -0,0 +1,27 @@ +import { TspLegacySchoolMigrationSuccessLoggable } from './tsp-legacy-school-migration-success.loggable'; + +describe(TspLegacySchoolMigrationSuccessLoggable.name, () => { + let loggable: TspLegacySchoolMigrationSuccessLoggable; + + beforeAll(() => { + loggable = new TspLegacySchoolMigrationSuccessLoggable(10, 5); + }); + + 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: `Legacy tsp data migration finished. Total schools: 10, migrated schools: 5`, + data: { + total: 10, + migrated: 5, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.ts new file mode 100644 index 00000000000..b6ac7b247e2 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.ts @@ -0,0 +1,17 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspLegacySchoolMigrationSuccessLoggable implements Loggable { + constructor(private readonly total: number, private readonly migrated: number) {} + + getLogMessage(): LogMessage { + const message: LogMessage = { + message: `Legacy tsp data migration finished. Total schools: ${this.total}, migrated schools: ${this.migrated}`, + data: { + total: this.total, + migrated: this.migrated, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.integration.spec.ts b/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.integration.spec.ts new file mode 100644 index 00000000000..ee52feb75cf --- /dev/null +++ b/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.integration.spec.ts @@ -0,0 +1,106 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolEntity } from '@shared/domain/entity'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { SchoolFeature } from '@shared/domain/types'; +import { cleanupCollections, schoolEntityFactory, systemEntityFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { MongoMemoryDatabaseModule } from '@src/infra/database'; +import { SystemType } from '@src/modules/system'; +import { TspLegacyMigrationSystemMissingLoggable } from './loggable/tsp-legacy-migration-system-missing.loggable'; +import { TspLegacyMigrationService } from './tsp-legacy-migration.service'; + +describe('account repo', () => { + let module: TestingModule; + let em: EntityManager; + let sut: TspLegacyMigrationService; + let logger: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [ + TspLegacyMigrationService, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + sut = module.get(TspLegacyMigrationService); + em = module.get(EntityManager); + logger = module.get(Logger); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + jest.resetAllMocks(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + await cleanupCollections(em); + }); + + describe('migrateLegacyData', () => { + describe('when legacy system is not found', () => { + it('should log TspLegacyMigrationSystemMissingLoggable', async () => { + await sut.migrateLegacyData(''); + + expect(logger.info).toHaveBeenCalledWith(new TspLegacyMigrationSystemMissingLoggable()); + }); + }); + + describe('when migrating legacy data', () => { + const setup = async () => { + const legacySystem = systemEntityFactory.buildWithId({ + type: 'tsp-school', + }); + const newSystem = systemEntityFactory.buildWithId({ + type: SystemType.OAUTH, + provisioningStrategy: SystemProvisioningStrategy.TSP, + }); + + const schoolIdentifier = '123'; + const legacySchool = schoolEntityFactory.buildWithId({ + systems: [legacySystem], + features: [], + }); + + await em.persistAndFlush([legacySystem, newSystem, legacySchool]); + em.clear(); + + await em.getCollection('schools').findOneAndUpdate( + { + systems: [legacySystem._id], + }, + { + $set: { + sourceOptions: { + schoolIdentifier, + }, + source: 'tsp', + }, + } + ); + + return { legacySystem, newSystem, legacySchool, schoolId: schoolIdentifier }; + }; + + it('should update the school to the new format', async () => { + const { newSystem, legacySchool, schoolId: schoolIdentifier } = await setup(); + + await sut.migrateLegacyData(newSystem.id); + + const migratedSchool = await em.findOne(SchoolEntity.name, { + id: legacySchool.id, + }); + expect(migratedSchool?.externalId).toBe(schoolIdentifier); + expect(migratedSchool?.systems[0].id).toBe(newSystem.id); + expect(migratedSchool?.features).toContain(SchoolFeature.OAUTH_PROVISIONING_ENABLED); + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.ts b/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.ts new file mode 100644 index 00000000000..1bff95ef2b5 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.ts @@ -0,0 +1,93 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId, SchoolFeature } from '@shared/domain/types'; +import { Logger } from '@src/core/logger'; +import { TspLegacyMigrationStartLoggable } from './loggable/tsp-legacy-migration-start.loggable'; +import { TspLegacyMigrationSystemMissingLoggable } from './loggable/tsp-legacy-migration-system-missing.loggable'; +import { TspLegacySchoolMigrationCountLoggable } from './loggable/tsp-legacy-school-migration-count.loggable'; +import { TspLegacySchoolMigrationSuccessLoggable } from './loggable/tsp-legacy-school-migration-success.loggable'; + +type LegacyTspSchoolProperties = { + sourceOptions: { + schoolIdentifier: number; + }; +}; + +const TSP_LEGACY_SYSTEM_TYPE = 'tsp-school'; +const TSP_LEGACY_SOURCE_TYPE = 'tsp'; +const SCHOOLS_COLLECTION = 'schools'; +const SYSTEMS_COLLECTION = 'systems'; + +@Injectable() +export class TspLegacyMigrationService { + constructor(private readonly em: EntityManager, private readonly logger: Logger) { + logger.setContext(TspLegacyMigrationService.name); + } + + public async migrateLegacyData(newSystemId: EntityId): Promise { + this.logger.info(new TspLegacyMigrationStartLoggable()); + + const legacySystemId = await this.findLegacySystemId(); + + if (!legacySystemId) { + this.logger.info(new TspLegacyMigrationSystemMissingLoggable()); + return; + } + + const schoolIds = await this.findIdsOfLegacyTspSchools(legacySystemId); + + this.logger.info(new TspLegacySchoolMigrationCountLoggable(schoolIds.length)); + + const promises = schoolIds.map(async (oldId): Promise => { + const legacySchoolFilter = { + systems: [legacySystemId], + source: TSP_LEGACY_SOURCE_TYPE, + sourceOptions: { + schoolIdentifier: oldId, + }, + }; + + const featureUpdateCount = await this.em.nativeUpdate(SCHOOLS_COLLECTION, legacySchoolFilter, { + $addToSet: { + features: SchoolFeature.OAUTH_PROVISIONING_ENABLED, + }, + }); + const idUpdateCount = await this.em.nativeUpdate(SCHOOLS_COLLECTION, legacySchoolFilter, { + ldapSchoolIdentifier: oldId, + systems: [new ObjectId(newSystemId)], + }); + + return featureUpdateCount === 1 && idUpdateCount === 1 ? 1 : 0; + }); + + const results = await Promise.allSettled(promises); + const successfulMigrations = results + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value) + .reduce((previousValue, currentValue) => previousValue + currentValue, 0); + + this.logger.info(new TspLegacySchoolMigrationSuccessLoggable(schoolIds.length, successfulMigrations)); + } + + private async findLegacySystemId() { + const tspLegacySystem = await this.em.getCollection(SYSTEMS_COLLECTION).findOne({ + type: TSP_LEGACY_SYSTEM_TYPE, + }); + + return tspLegacySystem?._id; + } + + private async findIdsOfLegacyTspSchools(legacySystemId: ObjectId) { + const schools = await this.em + .getCollection(SCHOOLS_COLLECTION) + .find({ + systems: [legacySystemId], + source: TSP_LEGACY_SOURCE_TYPE, + }) + .toArray(); + + const schoolIds = schools.map((school) => school.sourceOptions.schoolIdentifier); + + return schoolIds; + } +} 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 77556ed6897..b86d44430f7 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 @@ -1,18 +1,18 @@ import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AccountService } from '@modules/account'; import { School, SchoolService } from '@modules/school'; import { SystemService, SystemType } from '@modules/system'; +import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { federalStateFactory, schoolYearFactory, userDoFactory } from '@shared/testing'; -import { AccountService } from '@modules/account'; import { accountDoFactory } from '@src/modules/account/testing'; -import { FederalStateService, SchoolYearService } from '@modules/legacy-school'; -import { SchoolProps } from '@src/modules/school/domain'; +import { FederalStateService, SchoolYearService } from '@src/modules/legacy-school'; +import { FileStorageType, 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 '@modules/user'; import { TspSyncService } from './tsp-sync.service'; describe(TspSyncService.name, () => { @@ -254,6 +254,7 @@ describe(TspSyncService.name, () => { systemIds: [system.id], federalState, currentYear: schoolYear, + fileStorageType: FileStorageType.AWS_S3, }) as Partial, }); }); @@ -291,6 +292,7 @@ describe(TspSyncService.name, () => { systemIds: [system.id], federalState, currentYear: schoolYear, + fileStorageType: FileStorageType.AWS_S3, }) as Partial, }); expect(federalStateService.findFederalStateByName).not.toHaveBeenCalled(); @@ -337,26 +339,29 @@ describe(TspSyncService.name, () => { }); }); - describe('findAccountByTspUid', () => { + describe('findAccountByExternalId', () => { describe('when account is found', () => { const setup = () => { - const tspUid = faker.string.alpha(); + const externalId = faker.string.alpha(); + const systemId = faker.string.alpha(); + const user = userDoFactory.build(); const account = accountDoFactory.build(); - user.id = tspUid; + user.id = faker.string.alpha(); + user.externalId = externalId; account.userId = user.id; - userService.findUsers.mockResolvedValueOnce({ data: [user], total: 1 }); + userService.findByExternalId.mockResolvedValueOnce(user); accountService.findByUserId.mockResolvedValueOnce(account); - return { tspUid, account }; + return { externalId, systemId, account }; }; it('should return the account', async () => { - const { tspUid, account } = setup(); + const { externalId, systemId, account } = setup(); - const result = await sut.findAccountByTspUid(tspUid); + const result = await sut.findAccountByExternalId(externalId, systemId); expect(result).toBe(account); }); @@ -364,19 +369,19 @@ describe(TspSyncService.name, () => { describe('when account is not found', () => { const setup = () => { - const tspUid = faker.string.alpha(); - const user = userDoFactory.build(); + const externalId = faker.string.alpha(); + const systemId = faker.string.alpha(); - userService.findUsers.mockResolvedValueOnce({ data: [user], total: 0 }); + userService.findByExternalId.mockResolvedValueOnce(null); accountService.findByUserId.mockResolvedValueOnce(null); - return { tspUid }; + return { externalId, systemId }; }; it('should return null', async () => { - const { tspUid } = setup(); + const { externalId, systemId } = setup(); - const result = await sut.findAccountByTspUid(tspUid); + const result = await sut.findAccountByExternalId(externalId, systemId); expect(result).toBeNull(); }); 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 7e9de63e6c2..c79e1165343 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,14 @@ 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 { UserSourceOptions } from '@shared/domain/domainobject/user-source-options.do'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { SchoolFeature } from '@shared/domain/types'; +import { EntityId, SchoolFeature } from '@shared/domain/types'; import { Account, AccountService } from '@src/modules/account'; import { FederalStateNames } from '@src/modules/legacy-school/types'; -import { FederalState } from '@src/modules/school/domain'; +import { FederalState, FileStorageType } from '@src/modules/school/domain'; import { SchoolFactory } from '@src/modules/school/domain/factory'; +import { SchoolPermissions } from '@src/modules/school/domain/type'; import { FederalStateEntityMapper, SchoolYearEntityMapper } from '@src/modules/school/repo/mikro-orm/mapper'; import { UserService } from '@src/modules/user'; import { ObjectId } from 'bson'; @@ -78,6 +80,12 @@ export class TspSyncService { const schoolYear = SchoolYearEntityMapper.mapToDo(schoolYearEntity); const federalState = await this.findFederalState(); + const permissions: SchoolPermissions = { + teacher: { + STUDENT_LIST: true, + }, + }; + const school = SchoolFactory.build({ externalId: identifier, name, @@ -88,6 +96,8 @@ export class TspSyncService { createdAt: new Date(), updatedAt: new Date(), id: new ObjectId().toHexString(), + fileStorageType: FileStorageType.AWS_S3, + permissions, }); const savedSchool = await this.schoolService.save(school); @@ -115,8 +125,8 @@ export class TspSyncService { return tspUser.data[0]; } - public async findAccountByTspUid(tspUid: string): Promise { - const user = await this.findUserByTspUid(tspUid); + public async findAccountByExternalId(externalId: string, systemId: EntityId): Promise { + const user = await this.userService.findByExternalId(externalId, systemId); if (!user || !user.id) { return null; @@ -136,6 +146,7 @@ export class TspSyncService { user.email = email; user.externalId = externalId; user.previousExternalId = previousExternalId; + user.sourceOptions = new UserSourceOptions({ tspUid: user.externalId }); return this.userService.save(user); } 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 f73cda9bcbd..b4d8a3b8a52 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 @@ -22,6 +22,7 @@ import { schoolFactory } from '@src/modules/school/testing'; import { System } from '@src/modules/system'; import { systemFactory } from '@src/modules/system/testing'; import { SyncStrategyTarget } from '../sync-strategy.types'; +import { TspLegacyMigrationService } from './tsp-legacy-migration.service'; import { TspFetchService } from './tsp-fetch.service'; import { TspOauthDataMapper } from './tsp-oauth-data.mapper'; import { TspSyncConfig } from './tsp-sync.config'; @@ -35,6 +36,7 @@ describe(TspSyncStrategy.name, () => { let tspFetchService: DeepMocked; let provisioningService: DeepMocked; let tspOauthDataMapper: DeepMocked; + let tspLegacyMigrationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -83,6 +85,10 @@ describe(TspSyncStrategy.name, () => { provide: TspOauthDataMapper, useValue: createMock(), }, + { + provide: TspLegacyMigrationService, + useValue: createMock(), + }, ], }).compile(); @@ -91,6 +97,7 @@ describe(TspSyncStrategy.name, () => { tspFetchService = module.get(TspFetchService); provisioningService = module.get(ProvisioningService); tspOauthDataMapper = module.get(TspOauthDataMapper); + tspLegacyMigrationService = module.get(TspLegacyMigrationService); }); afterEach(() => { @@ -145,7 +152,7 @@ describe(TspSyncStrategy.name, () => { params.foundTspUidUser !== undefined ? params.foundTspUidUser : userDoFactory.build() ); tspSyncService.updateUser.mockResolvedValueOnce(params.updatedUser ?? userDoFactory.build()); - tspSyncService.findAccountByTspUid.mockResolvedValueOnce( + tspSyncService.findAccountByExternalId.mockResolvedValueOnce( params.foundTspUidAccount !== undefined ? params.foundTspUidAccount : accountDoFactory.build() ); tspSyncService.updateAccount.mockResolvedValueOnce(params.updatedAccount ?? accountDoFactory.build()); @@ -191,6 +198,14 @@ describe(TspSyncStrategy.name, () => { expect(tspSyncService.findTspSystemOrFail).toHaveBeenCalled(); }); + it('should migrate the legacy data', async () => { + setup(); + + await sut.sync(); + + expect(tspLegacyMigrationService.migrateLegacyData).toHaveBeenCalled(); + }); + it('should fetch the schools', async () => { setup(); @@ -271,7 +286,7 @@ describe(TspSyncStrategy.name, () => { await sut.sync(); - expect(tspSyncService.findAccountByTspUid).toHaveBeenCalled(); + expect(tspSyncService.findAccountByExternalId).toHaveBeenCalled(); }); it('should update account', async () => { 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 c5b1fd6a93c..07469f5abd6 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts @@ -24,6 +24,7 @@ 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'; +import { TspLegacyMigrationService } from './tsp-legacy-migration.service'; import { TspFetchService } from './tsp-fetch.service'; @Injectable() @@ -45,6 +46,7 @@ export class TspSyncStrategy extends SyncStrategy { private readonly tspSyncService: TspSyncService, private readonly tspFetchService: TspFetchService, private readonly tspOauthDataMapper: TspOauthDataMapper, + private readonly tspLegacyMigrationService: TspLegacyMigrationService, configService: ConfigService, private readonly provisioningService: ProvisioningService ) { @@ -68,6 +70,8 @@ export class TspSyncStrategy extends SyncStrategy { public async sync(): Promise { const system = await this.tspSyncService.findTspSystemOrFail(); + await this.tspLegacyMigrationService.migrateLegacyData(system.id); + await this.syncSchools(system); const schools = await this.tspSyncService.findSchoolsForSystem(system); @@ -206,7 +210,7 @@ export class TspSyncStrategy extends SyncStrategy { const newEmail = newEmailAndUsername; const updatedUser = await this.tspSyncService.updateUser(user, newEmail, newUid, oldUid); - const account = await this.tspSyncService.findAccountByTspUid(oldUid); + const account = await this.tspSyncService.findAccountByExternalId(newUid, systemId); if (!account) { throw new NotFoundLoggableException(Account.name, { oldUid }); diff --git a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts index b5d4b8ba1bc..1174d732926 100644 --- a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts @@ -4,9 +4,13 @@ import { RoleService } from '@modules/role'; import { Injectable } from '@nestjs/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { RoleReference, UserDO } from '@shared/domain/domainobject'; +import { Consent } from '@shared/domain/domainobject/consent'; +import { ParentConsent } from '@shared/domain/domainobject/parent-consent'; +import { UserConsent } from '@shared/domain/domainobject/user-consent'; import { RoleName } from '@shared/domain/interface'; import { School, SchoolService } from '@src/modules/school'; import { UserService } from '@src/modules/user'; +import { ObjectId } from 'bson'; import { ExternalClassDto, ExternalSchoolDto, ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '../dto'; import { BadDataLoggableException } from '../loggable'; @@ -137,6 +141,9 @@ export class TspProvisioningService { birthday: externalUser.birthday, externalId: externalUser.externalId, }); + + this.createTspConsent(newUser); + const savedUser = await this.userService.save(newUser); return savedUser; @@ -179,4 +186,30 @@ export class TspProvisioningService { return email.toLowerCase(); } + + private createTspConsent(user: UserDO) { + const userConsent = new UserConsent({ + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date(), + dateOfTermsOfUseConsent: new Date(), + }); + + const parentConsent = new ParentConsent({ + id: new ObjectId().toString(), + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date(), + dateOfTermsOfUseConsent: new Date(), + }); + + const consent = new Consent({ + userConsent, + parentConsent: [parentConsent], + }); + + user.consent = consent; + } } diff --git a/apps/server/src/shared/domain/domainobject/consent.ts b/apps/server/src/shared/domain/domainobject/consent.ts new file mode 100644 index 00000000000..5094bb9befb --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/consent.ts @@ -0,0 +1,13 @@ +import { ParentConsent } from './parent-consent'; +import { UserConsent } from './user-consent'; + +export class Consent { + userConsent: UserConsent; + + parentConsent: ParentConsent[]; + + constructor(props: Consent) { + this.userConsent = props.userConsent; + this.parentConsent = props.parentConsent; + } +} diff --git a/apps/server/src/shared/domain/domainobject/parent-consent.ts b/apps/server/src/shared/domain/domainobject/parent-consent.ts new file mode 100644 index 00000000000..0c3a294dc36 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/parent-consent.ts @@ -0,0 +1,24 @@ +import { EntityId } from '../types'; + +export class ParentConsent { + id: EntityId; + + form: string; + + privacyConsent: boolean; + + termsOfUseConsent: boolean; + + dateOfPrivacyConsent: Date; + + dateOfTermsOfUseConsent: Date; + + constructor(props: ParentConsent) { + this.id = props.id; + this.form = props.form; + this.privacyConsent = props.privacyConsent; + this.termsOfUseConsent = props.termsOfUseConsent; + this.dateOfPrivacyConsent = props.dateOfPrivacyConsent; + this.dateOfTermsOfUseConsent = props.dateOfTermsOfUseConsent; + } +} diff --git a/apps/server/src/shared/domain/domainobject/user-consent.ts b/apps/server/src/shared/domain/domainobject/user-consent.ts new file mode 100644 index 00000000000..419e6d73327 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/user-consent.ts @@ -0,0 +1,19 @@ +export class UserConsent { + form: string; + + privacyConsent: boolean; + + termsOfUseConsent: boolean; + + dateOfPrivacyConsent: Date; + + dateOfTermsOfUseConsent: Date; + + constructor(props: UserConsent) { + this.form = props.form; + this.privacyConsent = props.privacyConsent; + this.termsOfUseConsent = props.termsOfUseConsent; + this.dateOfPrivacyConsent = props.dateOfPrivacyConsent; + this.dateOfTermsOfUseConsent = props.dateOfTermsOfUseConsent; + } +} diff --git a/apps/server/src/shared/domain/domainobject/user-source-options.do.spec.ts b/apps/server/src/shared/domain/domainobject/user-source-options.do.spec.ts new file mode 100644 index 00000000000..da26728ab83 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/user-source-options.do.spec.ts @@ -0,0 +1,43 @@ +import { UserSourceOptions } from './user-source-options.do'; + +describe(UserSourceOptions.name, () => { + describe('constructor', () => { + describe('When a constructor is called', () => { + const setup = () => { + const domainObject = new UserSourceOptions({ tspUid: '12345' }); + + return { domainObject }; + }; + + it('should create empty object', () => { + const domainObject = new UserSourceOptions({}); + + expect(domainObject).toEqual(expect.objectContaining({})); + }); + + it('should contain valid tspUid ', () => { + const { domainObject } = setup(); + + const userSourceOptionsDo: UserSourceOptions = new UserSourceOptions(domainObject); + + expect(userSourceOptionsDo.tspUid).toEqual(domainObject.tspUid); + }); + }); + }); + describe('getters', () => { + describe('When getters are used', () => { + it('getters should return proper value', () => { + const props = { + tspUid: '12345', + }; + + const userSourceOptionsDo = new UserSourceOptions(props); + const gettersValues = { + tspUid: userSourceOptionsDo.tspUid, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/user-source-options.do.ts b/apps/server/src/shared/domain/domainobject/user-source-options.do.ts new file mode 100644 index 00000000000..49ab42d5071 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/user-source-options.do.ts @@ -0,0 +1,15 @@ +export interface UserSourceOptionsProps { + tspUid?: string; +} + +export class UserSourceOptions { + protected props: UserSourceOptionsProps; + + constructor(props: UserSourceOptionsProps) { + this.props = props; + } + + get tspUid(): string | undefined { + return this.props.tspUid; + } +} diff --git a/apps/server/src/shared/domain/domainobject/user.do.ts b/apps/server/src/shared/domain/domainobject/user.do.ts index b98e9303210..a100fc41149 100644 --- a/apps/server/src/shared/domain/domainobject/user.do.ts +++ b/apps/server/src/shared/domain/domainobject/user.do.ts @@ -1,7 +1,9 @@ import { LanguageType } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BaseDO } from './base.do'; +import { Consent } from './consent'; import { RoleReference } from './role-reference'; +import { UserSourceOptions } from './user-source-options.do'; export class UserDO extends BaseDO { createdAt?: Date; @@ -50,6 +52,10 @@ export class UserDO extends BaseDO { birthday?: Date; + consent?: Consent; + + sourceOptions?: UserSourceOptions; + constructor(domainObject: UserDO) { super(domainObject.id); @@ -76,5 +82,7 @@ export class UserDO extends BaseDO { this.outdatedSince = domainObject.outdatedSince; this.previousExternalId = domainObject.previousExternalId; this.birthday = domainObject.birthday; + this.consent = domainObject.consent; + this.sourceOptions = domainObject.sourceOptions; } } 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 265463f4335..3251be09ff5 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.ts @@ -4,8 +4,10 @@ import { UserQuery } from '@modules/user/service/user-query.type'; import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; import { Page, RoleReference } from '@shared/domain/domainobject'; +import { UserSourceOptions } from '@shared/domain/domainobject/user-source-options.do'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { Role, SchoolEntity, User } from '@shared/domain/entity'; +import { UserSourceOptionsEntity } from '@shared/domain/entity/user-source-options-entity'; import { IFindOptions, Pagination, SortOrder, SortOrderMap } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BaseDORepo, Scope } from '@shared/repo'; @@ -151,6 +153,7 @@ export class UserDORepo extends BaseDORepo { outdatedSince: entity.outdatedSince, previousExternalId: entity.previousExternalId, birthday: entity.birthday, + sourceOptions: entity.sourceOptions ? new UserSourceOptions({ tspUid: entity.sourceOptions.tspUid }) : undefined, }); if (entity.roles.isInitialized()) { @@ -179,6 +182,9 @@ export class UserDORepo extends BaseDORepo { outdatedSince: entityDO.outdatedSince, previousExternalId: entityDO.previousExternalId, birthday: entityDO.birthday, + sourceOptions: entityDO.sourceOptions + ? new UserSourceOptionsEntity({ tspUid: entityDO.sourceOptions.tspUid }) + : undefined, }; }