diff --git a/api/src/profile/scripts/sixth-grade-attestation-reward.js b/api/src/profile/scripts/sixth-grade-attestation-reward.js new file mode 100644 index 00000000000..4a7a3a04694 --- /dev/null +++ b/api/src/profile/scripts/sixth-grade-attestation-reward.js @@ -0,0 +1,110 @@ +import { CampaignParticipationStatuses } from '../../prescription/shared/domain/constants.js'; +import { usecases } from '../../quest/domain/usecases/index.js'; +import { isoDateParser } from '../../shared/application/scripts/parsers.js'; +import { Script } from '../../shared/application/scripts/script.js'; +import { ScriptRunner } from '../../shared/application/scripts/script-runner.js'; +import { DomainTransaction } from '../../shared/domain/DomainTransaction.js'; + +export const PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS = [1000000, 1000001, 1000002]; + +const options = { + start: { + type: 'string', + describe: 'Date de début de la période à traiter, jour inclus, format "YYYY-MM-DD", (ex: "2024-01-20")', + demandOption: false, + requiresArg: true, + coerce: isoDateParser(), + }, + end: { + type: 'string', + describe: 'Date de fin de la période à traiter, jour inclus, format "YYYY-MM-DD", (ex: "2024-02-27")', + demandOption: true, + requiresArg: true, + coerce: isoDateParser(), + }, +}; + +/** + * Script to reward sixth-grade students who have already completed a campaign linked to a specific target profile. + */ +export class SixthGradeAttestationRewardScript extends Script { + constructor() { + super({ + description: + 'This script process attestations rewards for users who have already completed a campaign linked to specific target profiles.', + permanent: false, + options, + }); + } + + /** + * Handles the core logic of the script. + * + * @param {{start: Date, end: Date, limit: number}} options + * @param {{info: function}} logger + * @param {function} rewardUser + * + * @returns {Promise} + */ + async handle({ options, logger, rewardUser = usecases.rewardUser }) { + this.checkEndDateBeforeStartDate(options.start, options.end); + + const users = await this.fetchUserIds(options.start, options.end); + + if (users.length === 0) { + logger.info('No user found'); + return; + } + + logger.info(`${users.length} users found`); + + for (const userId of users) { + logger.info(`Processing user ${userId}`); + await rewardUser({ + userId, + }); + } + } + + /** + * Fetch the userIDs of the users who have already completed a campaign linked to the specified target profiles. + * + * @param {Date} startDate + * @param {Date} endDate + * + * @returns {Promise<[number]>} + */ + async fetchUserIds(startDate, endDate) { + const knexConnection = DomainTransaction.getConnection(); + + const formatedStartDate = startDate.toISOString().split('T')[0]; + + endDate.setDate(endDate.getDate() + 1); + const formatedEndDate = endDate.toISOString().split('T')[0]; + + const users = await knexConnection('campaign-participations') + .distinct('campaign-participations.userId') + .join('campaigns', 'campaign-participations.campaignId', 'campaigns.id') + .join('target-profiles', 'campaigns.targetProfileId', 'target-profiles.id') + .where('campaign-participations.createdAt', '>=', formatedStartDate) + .where('campaign-participations.createdAt', '<=', formatedEndDate) + .where('campaign-participations.status', '<>', CampaignParticipationStatuses.STARTED) + .whereIn('campaigns.targetProfileId', PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS); + + return users.map(({ userId }) => userId); + } + + /** + * Check if the end date is before the start date. + * + * @param {Date} startDate + * @param {Date} endDate + */ + checkEndDateBeforeStartDate(startDate, endDate) { + if (endDate < startDate) { + throw new Error('The end date must be greater than the start date'); + } + } +} + +await ScriptRunner.execute(import.meta.url, SixthGradeAttestationRewardScript); diff --git a/api/tests/profile/integration/scripts/sixth-grade-attestation-reward_test.js b/api/tests/profile/integration/scripts/sixth-grade-attestation-reward_test.js new file mode 100644 index 00000000000..60f82c9fa2f --- /dev/null +++ b/api/tests/profile/integration/scripts/sixth-grade-attestation-reward_test.js @@ -0,0 +1,297 @@ +import sinon from 'sinon'; + +import { CampaignParticipationStatuses } from '../../../../src/prescription/shared/domain/constants.js'; +import { + PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS, + SixthGradeAttestationRewardScript, +} from '../../../../src/profile/scripts/sixth-grade-attestation-reward.js'; +import { catchErr, databaseBuilder, expect } from '../../../test-helper.js'; + +describe('Integration | Profile | Scripts | sixth-grade-attestation-reward', function () { + describe('options', function () { + it('parses dates correctly', function () { + const startDate = '2024-01-01'; + const endDate = { whut: 'idontknow' }; + const script = new SixthGradeAttestationRewardScript(); + const { options } = script.metaInfo; + const parsedDate = options.start.coerce(startDate); + expect(parsedDate).to.be.a.instanceOf(Date); + expect(() => options.end.coerce(endDate)).to.throw(); + }); + }); + + describe('#fetchUsers', function () { + let script; + + beforeEach(async function () { + script = new SixthGradeAttestationRewardScript(); + }); + + it('should not return the user if the participation date is not included between the start date and the end date ', async function () { + const { id: targetProfileId } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[0], + }); + const campaign = databaseBuilder.factory.buildCampaign({ targetProfileId }); + const { userId } = databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.SHARED, + createdAt: '2024-12-02', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.SHARED, + createdAt: '2024-12-07', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.SHARED, + createdAt: '2024-11-22', + }); + + await databaseBuilder.commit(); + const userIds = await script.fetchUserIds(new Date('2024-12-01'), new Date('2024-12-03')); + expect(userIds).to.have.lengthOf(1); + expect(userIds).to.contains(userId); + }); + + it('should only return the user if participation status is different than started', async function () { + const { id: targetProfileId } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[0], + }); + const campaign = databaseBuilder.factory.buildCampaign({ targetProfileId }); + const { userId } = databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.STARTED, + createdAt: '2024-12-02', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.SHARED, + createdAt: '2024-12-02', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-02', + }); + + await databaseBuilder.commit(); + const userIds = await script.fetchUserIds(new Date('2024-12-01'), new Date('2024-12-06')); + expect(userIds).to.have.lengthOf(2); + expect(userIds).to.not.contains(userId); + }); + + it('should not return the user if the campaign target profile is not included in targeted target profiles', async function () { + const { id: targetProfileId1 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[0], + }); + const { id: targetProfileId2 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[1], + }); + const { id: targetProfileId3 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[2], + }); + const { id: targetProfileId4 } = databaseBuilder.factory.buildTargetProfile(); + const campaign1 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId1 }); + const campaign2 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId2 }); + const campaign3 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId3 }); + const campaign4 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId4 }); + const otherParameters = { status: CampaignParticipationStatuses.SHARED, createdAt: '2024-12-02T15:07:57.376Z' }; + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign1.id, + ...otherParameters, + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign2.id, + ...otherParameters, + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + ...otherParameters, + }); + const { userId: otherTargetProfileCampaignParticipationUserId } = + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign4.id, + ...otherParameters, + }); + + await databaseBuilder.commit(); + const userIds = await script.fetchUserIds(new Date('2024-12-01'), new Date('2024-12-06')); + expect(userIds).to.have.lengthOf(3); + expect(userIds).to.not.contains(otherTargetProfileCampaignParticipationUserId); + }); + + it('should return a user only once if the user has several participations for several campaigns including the targeted target profiles', async function () { + const { id: targetProfileId1 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[0], + }); + const { id: targetProfileId2 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[1], + }); + const { id: targetProfileId3 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[2], + }); + + const user = databaseBuilder.factory.buildUser(); + const campaign1 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId1 }); + const campaign2 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId2 }); + const campaign3 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId3 }); + + const otherParameters = { + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-02T15:07:57.376Z', + userId: user.id, + }; + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign1.id, + ...otherParameters, + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign2.id, + ...otherParameters, + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + ...otherParameters, + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-02T15:07:57.376Z', + }); + + await databaseBuilder.commit(); + const userIds = await script.fetchUserIds(new Date('2024-12-01'), new Date('2024-12-06')); + expect(userIds).to.have.lengthOf(2); + expect(userIds).to.contains(user.id); + }); + + it('should return expected users', async function () { + const { id: targetProfileId1 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[0], + }); + const { id: targetProfileId2 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[1], + }); + const { id: targetProfileId3 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[2], + }); + const campaign1 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId1 }); + const campaign2 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId2 }); + const campaign3 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId3 }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign1.id, + status: CampaignParticipationStatuses.SHARED, + createdAt: '2024-12-01T00:00:01.376Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign2.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-07T15:07:57.376Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-07T23:59:59.376Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-31T21:22:00.001Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2025-01-01T21:22:00.001Z', + }); + + await databaseBuilder.commit(); + const userIds = await script.fetchUserIds(new Date('2024-12-01'), new Date('2024-12-31')); + expect(userIds).to.have.lengthOf(4); + }); + }); + + describe('#handle', function () { + it('should log information for each userId', async function () { + const { id: targetProfileId1 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[2], + }); + const { id: targetProfileId2 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[0], + }); + const { id: targetProfileId3 } = databaseBuilder.factory.buildTargetProfile({ + id: PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS[1], + }); + const campaign1 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId1 }); + const campaign2 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId2 }); + const campaign3 = databaseBuilder.factory.buildCampaign({ targetProfileId: targetProfileId3 }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign1.id, + status: CampaignParticipationStatuses.SHARED, + createdAt: '2024-12-01', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign2.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-02', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-03', + }); + + await databaseBuilder.commit(); + + const script = new SixthGradeAttestationRewardScript(); + const logger = { info: sinon.spy(), error: sinon.spy() }; + const usecases = { rewardUser: sinon.stub() }; + + await script.handle({ + options: { + start: new Date('2024-12-01'), + end: new Date('2024-12-07'), + }, + logger, + rewardUser: usecases.rewardUser, + }); + + expect(logger.info.callCount).to.equal(4); + }); + + it('should throw an error if end comes before start.', async function () { + const script = new SixthGradeAttestationRewardScript(); + const logger = { info: sinon.spy(), error: sinon.spy() }; + const usecases = { rewardUser: sinon.stub() }; + + const error = await catchErr(script.handle)({ + options: { + start: new Date('2024-11-10'), + end: new Date('2024-11-09'), + }, + logger, + rewardUser: usecases.rewardUser, + }); + + expect(error).to.be.an.instanceOf(Error); + }); + + it('should stop execution if there are no users', async function () { + const script = new SixthGradeAttestationRewardScript(); + const logger = { info: sinon.spy(), error: sinon.spy() }; + const usecases = { rewardUser: sinon.stub() }; + + await script.handle({ + options: { + start: new Date('2024-12-01'), + end: new Date('2024-12-09'), + }, + logger, + rewardUser: usecases.rewardUser, + }); + + expect(usecases.rewardUser).to.not.have.been.called; + expect(logger.info).to.have.been.calledOnceWithExactly('No user found'); + }); + }); +});