From e7188d306413bbfb566470ea934dc1c0b67007fc Mon Sep 17 00:00:00 2001 From: Theotime2005 Date: Fri, 31 Jan 2025 09:18:48 +0100 Subject: [PATCH] feat(api): Add a script to send invitations --- .../domain/models/OrganizationInvitation.js | 1 + .../send-organization-invitations-script.js | 88 ++++++++++++++ .../files/send-organization-invitations.csv | 2 + ...nd-organization-invitations-script_test.js | 110 ++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 api/src/team/scripts/send-organization-invitations-script.js create mode 100644 api/tests/team/unit/scripts/files/send-organization-invitations.csv create mode 100644 api/tests/team/unit/scripts/send-organization-invitations-script_test.js diff --git a/api/src/team/domain/models/OrganizationInvitation.js b/api/src/team/domain/models/OrganizationInvitation.js index cd8e56a8ceb..8e2afc8d2db 100644 --- a/api/src/team/domain/models/OrganizationInvitation.js +++ b/api/src/team/domain/models/OrganizationInvitation.js @@ -75,5 +75,6 @@ class OrganizationInvitation { } OrganizationInvitation.StatusType = statuses; +OrganizationInvitation.RoleType = roles; export { OrganizationInvitation, statuses }; diff --git a/api/src/team/scripts/send-organization-invitations-script.js b/api/src/team/scripts/send-organization-invitations-script.js new file mode 100644 index 00000000000..f3fc75eaca4 --- /dev/null +++ b/api/src/team/scripts/send-organization-invitations-script.js @@ -0,0 +1,88 @@ +import { setTimeout } from 'node:timers/promises'; + +import Joi from 'joi'; +import _ from 'lodash'; + +import { csvFileParser } from '../../shared/application/scripts/parsers.js'; +import { Script } from '../../shared/application/scripts/script.js'; +import { ScriptRunner } from '../../shared/application/scripts/script-runner.js'; +import { OrganizationInvitation } from '../domain/models/OrganizationInvitation.js'; +import { usecases } from '../domain/usecases/index.js'; + +const DEFAULT_BATCH_SIZE = 200; +const DEFAULT_THROTTE_DELAY = 1000; + +const columnsSchemas = [ + { name: 'Organization ID', schema: Joi.number() }, + { name: 'email', schema: Joi.string().trim().replace(' ', '').lowercase() }, + { name: 'locale', schema: Joi.string().trim().lowercase() }, + { + name: 'role', + schema: Joi.string() + .trim() + .optional() + .valid(...Object.values(OrganizationInvitation.RoleType)), + }, +]; + +export class SendOrganizationInvitationsScript extends Script { + constructor() { + super({ + description: 'this script used to send invitations for one organization with a csv file', + permanent: true, + options: { + dryRun: { + type: 'boolean', + describe: 'Just to check, not action', + default: false, + }, + file: { + type: 'string', + describe: 'The file path', + demandOption: true, + coerce: csvFileParser(columnsSchemas), + }, + batchSize: { + type: 'number', + describe: 'The batch size', + default: DEFAULT_BATCH_SIZE, + }, + throttleDelay: { + type: 'number', + describe: 'The throttle delay', + default: DEFAULT_THROTTE_DELAY, + }, + }, + }); + } + + async handle({ options, logger, sendOrganizationInvitation = usecases.createOrganizationInvitationByAdmin }) { + const { file, dryRun, batchSize, throttleDelay } = options; + const batches = _.chunk(file, batchSize); + let batchCount = 1; + for (const batch of batches) { + logger.info(`Batch #${batchCount++}`); + batch.map(async (invitation) => { + if (!dryRun) { + await sendOrganizationInvitation({ + organizationId: invitation['Organization ID'], + email: invitation.email, + locale: invitation.locale, + role: invitation.role || null, + }); + } + }); + await setTimeout(throttleDelay); + if (dryRun) { + logger.info('Dry run, no action'); + } + } + if (dryRun) { + logger.info(`${file.length} invitations will be processed`); + } else { + logger.info(`${file.length} invitations processed`); + } + } +} + +await ScriptRunner.execute(import.meta.url, SendOrganizationInvitationsScript); diff --git a/api/tests/team/unit/scripts/files/send-organization-invitations.csv b/api/tests/team/unit/scripts/files/send-organization-invitations.csv new file mode 100644 index 00000000000..fdca3d72782 --- /dev/null +++ b/api/tests/team/unit/scripts/files/send-organization-invitations.csv @@ -0,0 +1,2 @@ +"Organization ID","email","locale","role" +1,test@example.net,fr,MEMBER diff --git a/api/tests/team/unit/scripts/send-organization-invitations-script_test.js b/api/tests/team/unit/scripts/send-organization-invitations-script_test.js new file mode 100644 index 00000000000..5d44f707965 --- /dev/null +++ b/api/tests/team/unit/scripts/send-organization-invitations-script_test.js @@ -0,0 +1,110 @@ +import * as url from 'node:url'; + +import { SendOrganizationInvitationsScript } from '../../../../src/team/scripts/send-organization-invitations-script.js'; +import { expect, sinon } from '../../../test-helper.js'; + +const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url)); + +describe('unit | team | scripts | send-organization-invitations-script', function () { + describe('options', function () { + it('Check the csv parser', async function () { + // given + const testCsvFile = `${currentDirectory}files/send-organization-invitations.csv`; + const script = new SendOrganizationInvitationsScript(); + + // when + const { options } = script.metaInfo; + const fileData = await options.file.coerce(testCsvFile); + + // then + expect(fileData).to.be.deep.equal([ + { 'Organization ID': 1, email: 'test@example.net', locale: 'fr', role: 'MEMBER' }, + ]); + }); + }); + + describe('#handle', function () { + let file; + let script; + let logger; + let sendOrganizationInvitation; + + beforeEach(function () { + file = [ + { 'Organization Id': 1, email: 'test1@example.net', locale: 'fr', role: 'MEMBER' }, + { 'Organization Id': 2, email: 'test2@example.net', locale: 'en', role: 'ADMIN' }, + { 'Organization Id': 3, email: 'test3@example.net', locale: 'fr', role: 'MEMBER' }, + ]; + script = new SendOrganizationInvitationsScript(); + logger = { info: sinon.spy() }; + sendOrganizationInvitation = sinon.stub(); + }); + + it('Runs the script', async function () { + // when + await script.handle({ + options: { file }, + logger, + sendOrganizationInvitation, + }); + + // then + expect(sendOrganizationInvitation).to.have.callCount(3); + expect(sendOrganizationInvitation).to.have.calledWith({ + organizationId: file[0]['Organization ID'], + email: file[0].email, + locale: file[0].locale, + role: file[0].role, + }); + expect(logger.info).to.have.been.calledWith('3 invitations processed'); + }); + + it('runs the script and replace empty role by null', async function () { + // given + const file = [{ 'Organization ID': 1, email: 'test@example.net', locale: 'fr' }]; + + // when + await script.handle({ + options: { file }, + logger, + sendOrganizationInvitation, + }); + + // then + expect(sendOrganizationInvitation).to.have.been.calledWith({ + organizationId: 1, + email: 'test@example.net', + locale: 'fr', + role: null, + }); + }); + + it('Runs the script with batch', async function () { + // when + await script.handle({ + options: { file, batchSize: 1 }, + logger, + sendOrganizationInvitation, + }); + + // then + expect(logger.info).to.have.been.calledWith('Batch #1'); + expect(logger.info).to.have.been.calledWith('Batch #2'); + expect(logger.info).to.have.been.calledWith('Batch #3'); + }); + + it('runs the script with dryRun', async function () { + // when + await script.handle({ + options: { file, dryRun: true }, + logger, + sendOrganizationInvitation, + }); + + // then + expect(logger.info).to.have.been.calledWith('Dry run, no action'); + expect(sendOrganizationInvitation).to.not.have.been.called; + expect(logger.info).to.have.been.calledWith('3 invitations will be processed'); + }); + }); +});