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..fe2abeb3fc5 --- /dev/null +++ b/api/src/profile/scripts/sixth-grade-attestation-reward.js @@ -0,0 +1,111 @@ +import dayjs from 'dayjs'; +import Joi from 'joi'; + +import { CampaignParticipationStatuses } from '../../prescription/shared/domain/constants.js'; +import { usecases } from '../../quest/domain/usecases/index.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 = [107068, 107074, 107078]; +const isoDateSchema = Joi.date().iso(); + +const startDateDefault = '2024-12-01T00:00:00Z'; +const endDateDefault = '2024-12-03T23:59:59Z'; +const numberOfDaysLimitDefault = 7; + +/** + * + * @param {string} startDate + * @param {string} endDate + * @returns {Promise<[number]>} + */ +export const fetchUserIds = async (startDate = startDateDefault, endDate = endDateDefault) => { + const knexConnection = DomainTransaction.getConnection(); + const users = await knexConnection('campaign-participations') + .select('campaign-participations.userId') + .join('campaigns', 'campaign-participations.campaignId', 'campaigns.id') + .join('target-profiles', 'campaigns.targetProfileId', 'target-profiles.id') + .where('campaign-participations.createdAt', '>=', startDate) + .where('campaign-participations.createdAt', '<=', endDate) + .where('campaign-participations.status', '<>', CampaignParticipationStatuses.STARTED) + .whereIn('campaigns.targetProfileId', PRODUCTION_SIXTH_GRADE_TARGET_PROFILE_IDS); + + return users.map(({ userId }) => userId); +}; + +/** + * @param {Date} date + * + * @throws {Error} + */ +const checkDateFormat = (date) => { + const dateValidationResult = isoDateSchema.validate(date); + + if (dateValidationResult.error) { + throw new Error(dateValidationResult.error.message); + } +}; + +const checkEndDateBeforeStartDate = (startDate, endDate) => { + if (endDate < startDate) { + throw new Error('The end date must be greater than the start date'); + } +}; + +const checkDifferenceBetweenDates = (startDate, endDate) => { + if (dayjs(endDate).diff(startDate, 'day') > numberOfDaysLimitDefault) { + throw new Error('The difference between the two dates must be less than 7 days'); + } +}; + +export class SixthGradeAttestationRewardScript extends Script { + constructor() { + super({ + description: + 'This script awards attestations to sixth-grade students who have already completed a campaign linked to a target profile linked to them', + permanent: true, + options: { + firstDateLimitDefault: startDateDefault, + secondDateLimitDefault: endDateDefault, + }, + }); + } + + /** + * + * @param {{startDate:Date, endDate:Date}} options + * @param {{info:function}}logger + * @param {function} rewardUser + * @returns {Promise} + */ + async handle({ options, logger, rewardUser = usecases.rewardUser }) { + const startDate = new Date(options.startDate || startDateDefault); + const endDate = new Date(options.endDate || endDateDefault); + + checkDateFormat(startDate); + checkDateFormat(endDate); + checkEndDateBeforeStartDate(startDate, endDate); + checkDifferenceBetweenDates(startDate, endDate); + + logger.info(`Fetching users between ${startDate} and ${endDate}`); + + const users = await fetchUserIds(startDate.toISOString(), endDate.toISOString()); + + if (users.length === 0) { + logger.info('No user found'); + return; + } + + logger.info(`${users.length} users found`); + + for (const userId of users) { + await rewardUser({ + userId, + }); + logger.info(`Traitement de l'utilisateur ${userId}`); + } + } +} + +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..9ede26876d5 --- /dev/null +++ b/api/tests/profile/integration/scripts/sixth-grade-attestation-reward_test.js @@ -0,0 +1,293 @@ +import sinon from 'sinon'; + +import { CampaignParticipationStatuses } from '../../../../src/prescription/shared/domain/constants.js'; +import { + fetchUserIds, + 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('#fetchUsers', function () { + 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-02T15:07:57.376Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.SHARED, + createdAt: '2024-12-07T15:07:57.376Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.SHARED, + createdAt: '2024-11-22T15:07:57.376Z', + }); + + await databaseBuilder.commit(); + const userIds = await fetchUserIds(); + expect(userIds).to.have.lengthOf(1); + expect(userIds).to.contains(userId); + }); + + it('should not return the user if the participation status is different from 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-02T15:07:57.376Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.SHARED, + createdAt: '2024-12-02T15:07:57.376Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-02T15:07:57.376Z', + }); + + await databaseBuilder.commit(); + const userIds = await fetchUserIds(); + 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 fetchUserIds(); + expect(userIds).to.have.lengthOf(3); + expect(userIds).to.not.contains(otherTargetProfileCampaignParticipationUserId); + }); + + 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.000Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign2.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-02T15:00:00.000Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-03T23:59:58.000Z', + }); + + await databaseBuilder.commit(); + const userIds = await fetchUserIds(); + expect(userIds).to.have.lengthOf(3); + }); + }); + + 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[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.000Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign2.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-02T15:00:00.000Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-03T23:59:58.000Z', + }); + + await databaseBuilder.commit(); + + const script = new SixthGradeAttestationRewardScript(); + const logger = { info: sinon.spy(), error: sinon.spy() }; + const usecases = { rewardUser: sinon.stub() }; + + await script.handle({ + options: {}, + logger, + rewardUser: usecases.rewardUser, + }); + + expect(logger.info.callCount).to.equal(5); + }); + + it('should use the provided limit dates to query user ids', 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-11-05T00:00:01.000Z', + }); + const { userId: userId2 } = databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign2.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-05T15:00:00.000Z', + }); + databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign3.id, + status: CampaignParticipationStatuses.TO_SHARE, + createdAt: '2024-12-11T23:59:58.000Z', + }); + + await databaseBuilder.commit(); + + const script = new SixthGradeAttestationRewardScript(); + const logger = { info: sinon.spy(), error: sinon.spy() }; + const usecases = { rewardUser: sinon.stub() }; + + await script.handle({ + options: { + startDate: '2024-12-01T00:00:00Z', + endDate: '2024-12-10T23:59:59Z', + }, + logger, + rewardUser: usecases.rewardUser, + }); + + expect(usecases.rewardUser).to.have.been.calledOnceWithExactly({ userId: userId2 }); + }); + + it('should throw an error if the limit date option is not in the correct format', 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: { + startDate: 'definitely not a date', + endDate: { date: 'still not a date' }, + }, + logger, + rewardUser: usecases.rewardUser, + }); + + expect(error).to.be.an.instanceOf(Error); + }); + + it('should throw an error if the time between the two time limits is greater than one week.', 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: { + startDate: '2024-11-01T00:00:00.000Z', + endDate: '2024-11-09T00:00:00.000Z', + }, + logger, + rewardUser: usecases.rewardUser, + }); + + expect(error).to.be.an.instanceOf(Error); + }); + + it('should throw an error if endDate comes before startDate.', 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: { + startDate: '2024-11-10T00:00:00.000Z', + endDate: '2024-11-09T00:00:00.000Z', + }, + logger, + rewardUser: usecases.rewardUser, + }); + + expect(error).to.be.an.instanceOf(Error); + }); + + it.skip('should throw an error if there are more than 50k users to handle', async function () { + return expect(true).to.be.false; + }); + + it.skip('should log error and stop the script if reward user throw an error', async function () { + return expect(true).to.be.false; + }); + }); +});