From ca2357062e67b0048771bfef47eb1a4aa0c1962e Mon Sep 17 00:00:00 2001 From: LionelB Date: Wed, 13 Nov 2024 15:19:03 +0100 Subject: [PATCH] feat(api): add script to remove an organizationLeaner Co-authored-by: mache --- ...ete-and-anonymise-organization-learners.js | 76 ++++++ ...nd-anonymise-organization-learners_test.js | 237 ++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 api/scripts/prod/delete-and-anonymise-organization-learners.js create mode 100644 api/tests/integration/scripts/prod/delete-and-anonymise-organization-learners_test.js diff --git a/api/scripts/prod/delete-and-anonymise-organization-learners.js b/api/scripts/prod/delete-and-anonymise-organization-learners.js new file mode 100644 index 00000000000..b8838aa5299 --- /dev/null +++ b/api/scripts/prod/delete-and-anonymise-organization-learners.js @@ -0,0 +1,76 @@ +import { DomainTransaction } from '../../lib/infrastructure/DomainTransaction.js'; +import { removeByOrganizationLearnerIds } from '../../src/prescription/learner-management/infrastructure/repositories/campaign-participation-repository.js'; +import { removeByIds } from '../../src/prescription/learner-management/infrastructure/repositories/organization-learner-repository.js'; +import { commaSeparatedNumberParser } from '../../src/shared/application/scripts/parsers.js'; +import { Script } from '../../src/shared/application/scripts/script.js'; +import { ScriptRunner } from '../../src/shared/application/scripts/script-runner.js'; +// Définition du script +export class DeleteAndAnonymiseOrgnizationLearnerScript extends Script { + constructor() { + super({ + description: 'Deletes organization-learners and anonymise their related data', + permanent: true, + options: { + organizationLearnerIds: { + type: 'number', + describe: 'a list of comma separated organization learner ids', + demandOption: true, + coerce: commaSeparatedNumberParser(), + }, + }, + }); + } + + async handle({ + options, + logger, + campaignParticipationRepository = { removeByOrganizationLearnerIds }, + organizationLearnerRepository = { removeByIds }, + }) { + const engineeringUserId = process.env.ENGINEERING_USER_ID; + + logger.info(`Anonymise ${options.organizationLearnerIds.length} learners`); + await DomainTransaction.execute(async () => { + await campaignParticipationRepository.removeByOrganizationLearnerIds({ + organizationLearnerIds: options.organizationLearnerIds, + userId: engineeringUserId, + }); + + await organizationLearnerRepository.removeByIds({ + organizationLearnerIds: options.organizationLearnerIds, + userId: engineeringUserId, + }); + + await anonymizeDeletedOrganizationLearners(options.organizationLearnerIds); + + const participations = await anonymizeDeletedOrganizationLearnersParticipations(options.organizationLearnerIds); + + await detachAssessments(participations.map((participation) => participation.id)); + }); + } +} + +async function anonymizeDeletedOrganizationLearners(organizationLearnerIds) { + const knexConnection = DomainTransaction.getConnection(); + await knexConnection('organization-learners') + .update({ firstName: '', lastName: '', userId: null, updatedAt: new Date() }) + .whereIn('id', organizationLearnerIds) + .whereNotNull('deletedAt'); +} + +async function anonymizeDeletedOrganizationLearnersParticipations(organizationLearnerIds) { + const knexConnection = DomainTransaction.getConnection(); + return knexConnection('campaign-participations') + .update({ participantExternalId: null, userId: null }) + .whereIn('organizationLearnerId', organizationLearnerIds) + .whereNotNull('deletedAt') + .returning('id'); +} +async function detachAssessments(participationIds) { + const knexConnection = DomainTransaction.getConnection(); + await knexConnection('assessments') + .update({ campaignParticipationId: null }) + .whereIn('campaignParticipationId', participationIds); +} +// Exécution du script +await ScriptRunner.execute(import.meta.url, DeleteAndAnonymiseOrgnizationLearnerScript); diff --git a/api/tests/integration/scripts/prod/delete-and-anonymise-organization-learners_test.js b/api/tests/integration/scripts/prod/delete-and-anonymise-organization-learners_test.js new file mode 100644 index 00000000000..fa9ee6e6b78 --- /dev/null +++ b/api/tests/integration/scripts/prod/delete-and-anonymise-organization-learners_test.js @@ -0,0 +1,237 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { DeleteAndAnonymiseOrgnizationLearnerScript } from '../../../../scripts/prod/delete-and-anonymise-organization-learners.js'; +import { databaseBuilder, knex } from '../../../test-helper.js'; + +describe('DeleteAndAnonymiseOrgnizationLearnerScript', function () { + describe('Options', function () { + it('has the correct options', function () { + const script = new DeleteAndAnonymiseOrgnizationLearnerScript(); + const { options } = script.metaInfo; + + expect(options.organizationLearnerIds).to.deep.include({ + type: 'number', + describe: 'a list of comma separated organization learner ids', + demandOption: true, + }); + expect(options.organizationLearnerIds.coerce).to.be.a('function'); + }); + + it('parses list of organizationLearnerIds', async function () { + const ids = '1,2,3'; + const script = new DeleteAndAnonymiseOrgnizationLearnerScript(); + const { options } = script.metaInfo; + const parsedData = await options.organizationLearnerIds.coerce(ids); + + expect(parsedData).to.deep.equals([1, 2, 3]); + }); + }); + + describe('Handle', function () { + let script; + let logger; + const ENGINEERING_USER_ID = 99999; + + beforeEach(async function () { + script = new DeleteAndAnonymiseOrgnizationLearnerScript(); + logger = { info: sinon.spy(), error: sinon.spy() }; + sinon.stub(process, 'env').value({ ENGINEERING_USER_ID }); + }); + + describe('anonymise organization learners', function () { + let learner, otherLearner, campaign, organization, clock, now; + + beforeEach(async function () { + now = new Date('2024-01-17'); + clock = sinon.useFakeTimers({ now, toFake: ['Date'] }); + + databaseBuilder.factory.buildUser({ id: ENGINEERING_USER_ID }); + organization = databaseBuilder.factory.buildOrganization(); + campaign = databaseBuilder.factory.buildCampaign({ organizationId: organization.id }); + learner = databaseBuilder.factory.prescription.organizationLearners.buildOrganizationLearner({ + id: 123, + firstName: 'johnny', + lastName: 'five', + organizationId: organization.id, + }); + otherLearner = databaseBuilder.factory.prescription.organizationLearners.buildOrganizationLearner({ + id: 456, + organizationId: organization.id, + }); + + await databaseBuilder.commit(); + }); + + afterEach(function () { + clock.restore(); + }); + + it('delete organization learners', async function () { + // given + const organizationLearnerIds = [learner.id]; + + // when + await script.handle({ options: { organizationLearnerIds }, logger }); + + // then + const organizationLearnerResult = await knex('organization-learners').whereNotNull('deletedAt'); + expect(organizationLearnerResult).lengthOf(1); + expect(organizationLearnerResult[0].id).to.equal(learner.id); + expect(organizationLearnerResult[0].updatedAt).to.deep.equal(now); + expect(organizationLearnerResult[0].deletedAt).to.deep.equal(now); + expect(organizationLearnerResult[0].deletedBy).to.equal(ENGINEERING_USER_ID); + }); + + it('anonymise given deleted organization learners id', async function () { + // when + await script.handle({ + options: { organizationLearnerIds: [learner.id] }, + logger, + }); + // then + const organizationLearnerResult = await knex('organization-learners').whereNull('userId').first(); + expect(organizationLearnerResult.id).to.equal(learner.id); + expect(organizationLearnerResult.firstName).to.equal(''); + expect(organizationLearnerResult.lastName).to.equal(''); + }); + + it('anonymise and delete participations', async function () { + // given + databaseBuilder.factory.buildCampaignParticipation({ + userId: otherLearner.userId, + organizationLearnerId: otherLearner.id, + participantExternalId: 'another-learner', + }); + + databaseBuilder.factory.buildCampaignParticipation({ + userId: learner.userId, + organizationLearnerId: learner.id, + participantExternalId: 'first', + campaignId: campaign.id, + isImproved: true, + }); + databaseBuilder.factory.buildCampaignParticipation({ + userId: learner.userId, + organizationLearnerId: learner.id, + participantExternalId: 'second', + campaignId: campaign.id, + }); + + await databaseBuilder.commit(); + + // when + await script.handle({ + options: { organizationLearnerIds: [learner.id] }, + logger, + }); + + // then + const participationResult = await knex('campaign-participations').whereNotNull('deletedAt'); + expect(participationResult).lengthOf(2); + expect(participationResult[0].deletedAt).to.deep.equal(now); + expect(participationResult[0].deletedBy).to.equal(ENGINEERING_USER_ID); + expect(participationResult[0].participantExternalId).to.be.null; + expect(participationResult[0].userId).to.be.null; + expect(participationResult[1].deletedAt).to.deep.equal(now); + expect(participationResult[1].deletedBy).to.equal(ENGINEERING_USER_ID); + expect(participationResult[1].participantExternalId).to.be.null; + expect(participationResult[1].userId).to.be.null; + }); + + it('detach its assessments', async function () { + // given + const otherParticipation = databaseBuilder.factory.buildCampaignParticipation({ + userId: otherLearner.userId, + organizationLearnerId: otherLearner.id, + participantExternalId: 'other-learner', + }); + databaseBuilder.factory.buildAssessment({ + userId: otherLearner.userId, + campaignParticipationId: otherParticipation.id, + }); + + const campaignParticipation = databaseBuilder.factory.buildCampaignParticipation({ + userId: learner.userId, + organizationLearnerId: learner.id, + participantExternalId: 'coucou', + }); + + databaseBuilder.factory.buildAssessment({ + userId: learner.userId, + campaignParticipationId: campaignParticipation.id, + }); + databaseBuilder.factory.buildAssessment({ + userId: learner.userId, + campaignParticipationId: campaignParticipation.id, + }); + + await databaseBuilder.commit(); + + // when + await script.handle({ + options: { organizationLearnerIds: [learner.id] }, + logger, + }); + + // then + const assessmentResults = await knex('assessments').whereNull('campaignParticipationId'); + expect(assessmentResults).lengthOf(2); + }); + + // We skip this test since there is a database + // non-nullable constraint on campaignParticipationId + // Until this constraint is removed we skip the tests + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('detach its user recommended trainings', async function () { + // given + const training = databaseBuilder.factory.buildTraining(); + const training2 = databaseBuilder.factory.buildTraining(); + + const otherCampaignParticipation = databaseBuilder.factory.buildCampaignParticipation({ + userId: otherLearner.userId, + organizationLearnerId: otherLearner.id, + participantExternalId: 'coucou', + }); + databaseBuilder.factory.buildUserRecommendedTraining({ + campaignParticipationId: otherCampaignParticipation.id, + trainingId: training.id, + userId: otherLearner.userId, + }); + + const campaignParticipation = databaseBuilder.factory.buildCampaignParticipation({ + userId: learner.userId, + organizationLearnerId: learner.id, + participantExternalId: 'coucou', + }); + + databaseBuilder.factory.buildUserRecommendedTraining({ + campaignParticipationId: campaignParticipation.id, + trainingId: training.id, + userId: learner.userId, + }); + databaseBuilder.factory.buildUserRecommendedTraining({ + campaignParticipationId: campaignParticipation.id, + trainingId: training2.id, + userId: learner.userId, + }); + + await databaseBuilder.commit(); + + // when + await script.handle({ + options: { organizationLearnerIds: [learner.id] }, + logger, + }); + + // then + const recommendedTrainingResults = await knex('user-recommended-trainings').whereNull('userId'); + expect(recommendedTrainingResults).lengthOf(2); + expect(recommendedTrainingResults[0].campaignParticipationId).to.be.null; + expect(recommendedTrainingResults[0].campaignParticipationId).to.be.null; + expect(recommendedTrainingResults[1].campaignParticipationId).to.be.null; + expect(recommendedTrainingResults[1].campaignParticipationId).to.be.null; + }); + }); + }); +});