diff --git a/api/db/database-builder/factory/build-organization-profile-reward.js b/api/db/database-builder/factory/build-organization-profile-reward.js new file mode 100644 index 00000000000..2cdc13fd58d --- /dev/null +++ b/api/db/database-builder/factory/build-organization-profile-reward.js @@ -0,0 +1,26 @@ +import _ from 'lodash'; + +import { ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME } from '../../migrations/20241118134739_create-organizations-profile-rewards-table.js'; +import { databaseBuffer } from '../database-buffer.js'; +import { buildOrganization } from './build-organization.js'; +import { buildProfileReward } from './build-profile-reward.js'; + +export const buildOrganizationProfileReward = ({ + id = databaseBuffer.getNextId(), + organizationId, + profileRewardId, +} = {}) => { + organizationId = _.isUndefined(organizationId) ? buildOrganization().id : organizationId; + profileRewardId = _.isUndefined(profileRewardId) ? buildProfileReward().id : profileRewardId; + + const values = { + id, + organizationId, + profileRewardId, + }; + + return databaseBuffer.pushInsertable({ + tableName: ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME, + values, + }); +}; diff --git a/api/src/profile/scripts/sixth-grade-organization-share.js b/api/src/profile/scripts/sixth-grade-organization-share.js new file mode 100644 index 00000000000..12d5b9146f7 --- /dev/null +++ b/api/src/profile/scripts/sixth-grade-organization-share.js @@ -0,0 +1,92 @@ +import { ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME } from '../../../db/migrations/20241118134739_create-organizations-profile-rewards-table.js'; +import { PGSQL_UNIQUE_CONSTRAINT_VIOLATION_ERROR } from '../../../db/pgsql-errors.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'; + +const options = { + limit: { + type: 'number', + describe: 'Id limit', + demandOption: true, + requiresArg: true, + coerce: Number, + }, + offset: { + type: 'number', + describe: 'Id offset', + demandOption: true, + requiresArg: true, + coerce: Number, + }, +}; + +export class SixthGradeOrganizationShare extends Script { + constructor() { + super({ + description: 'Insert share attestations with organizations for sixth graders', + permanent: false, + options, + }); + } + + async handle({ options, logger }) { + const profileRewards = await this.fetchProfileRewards(options.limit, options.offset); + + logger.info(`${profileRewards.length} users to handle`); + + let count = 1; + + for (const profileReward of profileRewards) { + logger.info(`Handling user ${profileReward.userId}: (${count}/${profileRewards.length})`); + + const userOrganizationIds = await this.fetchUserOrganizations(profileReward.userId); + + logger.info(`Organization ids for user ${profileReward.userId}: ${userOrganizationIds.join(',')}`); + + for (const organizationId of userOrganizationIds) { + const knexConnection = await DomainTransaction.getConnection(); + logger.info(`Table insertion for user ${profileReward.userId} and organization ${organizationId}`); + + try { + await knexConnection(ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME).insert({ + profileRewardId: profileReward.id, + organizationId, + }); + } catch (error) { + if (error.code === PGSQL_UNIQUE_CONSTRAINT_VIOLATION_ERROR) { + logger.warn( + `User ${profileReward.userId} already shared an attestation with organization ${organizationId}`, + ); + } else { + throw error; + } + } + } + + count++; + } + } + + /** + * @param {number} limit + * @param {number} offset + * + * @returns {Promise<[{id:number, userId:number}]>} + */ + async fetchProfileRewards(limit, offset) { + const knexConnection = DomainTransaction.getConnection(); + return await knexConnection('profile-rewards').select('userId', 'id').limit(limit).offset(offset); + } + + async fetchUserOrganizations(userId) { + const knexConnection = DomainTransaction.getConnection(); + const organizations = await knexConnection('view-active-organization-learners') + .select('organizationId') + .where({ userId }); + + return organizations.map(({ organizationId }) => organizationId); + } +} + +await ScriptRunner.execute(import.meta.url, SixthGradeOrganizationShare); diff --git a/api/tests/profile/integration/scripts/sixth-grade-organization-share_test.js b/api/tests/profile/integration/scripts/sixth-grade-organization-share_test.js new file mode 100644 index 00000000000..da9a9ab9075 --- /dev/null +++ b/api/tests/profile/integration/scripts/sixth-grade-organization-share_test.js @@ -0,0 +1,98 @@ +import sinon from 'sinon'; + +import { SixthGradeOrganizationShare } from '../../../../src/profile/scripts/sixth-grade-organization-share.js'; +import { databaseBuilder, expect, knex } from '../../../test-helper.js'; + +describe('Integration | Profile | Scripts | sixth-grade-organization-share ', function () { + describe('#handle', function () { + let organizationProfileRewards; + let logger; + let profileRewardIds; + let firstOrganizationId; + let secondOrganizationId; + + before(async function () { + // build attestation + const attestation = databaseBuilder.factory.buildAttestation(); + + // build users + const userIds = [...Array(8).keys()].map((id) => databaseBuilder.factory.buildUser({ id: id + 1 }).id); + + // build organizations + firstOrganizationId = databaseBuilder.factory.buildOrganization().id; + secondOrganizationId = databaseBuilder.factory.buildOrganization().id; + + // build organization learners + userIds.forEach((userId) => + databaseBuilder.factory.buildOrganizationLearner({ organizationId: firstOrganizationId, userId }), + ); + + // build an other organization learner for userId 3 + databaseBuilder.factory.buildOrganizationLearner({ organizationId: secondOrganizationId, userId: 3 }); + + // build profile rewards + profileRewardIds = userIds.map( + (userId) => databaseBuilder.factory.buildProfileReward({ rewardId: attestation.id, userId }).id, + ); + + // build one organization profile reward to test unique constraint violation + databaseBuilder.factory.buildOrganizationProfileReward({ + profileRewardId: profileRewardIds[4], + organizationId: firstOrganizationId, + }); + + await databaseBuilder.commit(); + + const script = new SixthGradeOrganizationShare(); + logger = { info: sinon.spy(), warn: sinon.spy() }; + + await script.handle({ + options: { + limit: 5, + offset: 2, + }, + logger, + }); + + organizationProfileRewards = await knex('organizations-profile-rewards').select('*'); + }); + + it('should handle offset option', async function () { + const organizationProfilRewardIds = organizationProfileRewards.map( + (profileReward) => profileReward.profileRewardId, + ); + expect(organizationProfilRewardIds).to.not.contains(profileRewardIds[0]); + expect(organizationProfilRewardIds).to.not.contains(profileRewardIds[1]); + }); + + it('should handle limit option', async function () { + const organizationProfilRewardIds = organizationProfileRewards.map( + (profileReward) => profileReward.profileRewardId, + ); + expect(organizationProfilRewardIds).to.not.contains(profileRewardIds[7]); + expect(organizationProfilRewardIds).to.not.contains(profileRewardIds[8]); + }); + + it('should handle pgsql unique constraint violation error', async function () { + expect(logger.warn).to.have.been.calledOnceWithExactly( + `User 5 already shared an attestation with organization ${firstOrganizationId}`, + ); + }); + + it('should insert expected data', async function () { + const organizationProfileRewardsWithoutIds = organizationProfileRewards.map((organizationProfileReward) => { + delete organizationProfileReward.id; + return organizationProfileReward; + }); + + expect(organizationProfileRewardsWithoutIds).to.have.deep.members([ + { organizationId: firstOrganizationId, profileRewardId: profileRewardIds[2] }, + { organizationId: secondOrganizationId, profileRewardId: profileRewardIds[2] }, + { organizationId: firstOrganizationId, profileRewardId: profileRewardIds[3] }, + { organizationId: firstOrganizationId, profileRewardId: profileRewardIds[4] }, + { organizationId: firstOrganizationId, profileRewardId: profileRewardIds[5] }, + { organizationId: firstOrganizationId, profileRewardId: profileRewardIds[6] }, + ]); + }); + }); +});