From 0c570339d0ad8ce96ef2c855311918522a060ecb Mon Sep 17 00:00:00 2001 From: Alexandre Monney Date: Tue, 19 Nov 2024 11:34:29 +0100 Subject: [PATCH 1/5] feat(api): create new table to link organization with profile reward --- ...0240820101213_add-profile-rewards-table.js | 9 ------- ...ate-organizations-profile-rewards-table.js | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 api/db/migrations/20241118134739_create-organizations-profile-rewards-table.js diff --git a/api/db/migrations/20240820101213_add-profile-rewards-table.js b/api/db/migrations/20240820101213_add-profile-rewards-table.js index 32b0126690b..458ea338587 100644 --- a/api/db/migrations/20240820101213_add-profile-rewards-table.js +++ b/api/db/migrations/20240820101213_add-profile-rewards-table.js @@ -1,12 +1,3 @@ -// Make sure you properly test your migration, especially DDL (Data Definition Language) -// ! If the target table is large, and the migration take more than 20 minutes, the deployment will fail ! - -// You can design and test your migration to avoid this by following this guide -// https://1024pix.atlassian.net/wiki/spaces/DEV/pages/2153512965/Cr+er+une+migration - -// If your migrations target `answers` or `knowledge-elements` -// contact @team-captains, because automatic migrations are not active on `pix-datawarehouse-production` -// this may prevent data replication to succeed the day after your migration is deployed on `pix-api-production` import { REWARD_TYPES } from '../../src/quest/domain/constants.js'; export const PROFILE_REWARDS_TABLE_NAME = 'profile-rewards'; diff --git a/api/db/migrations/20241118134739_create-organizations-profile-rewards-table.js b/api/db/migrations/20241118134739_create-organizations-profile-rewards-table.js new file mode 100644 index 00000000000..41d60451677 --- /dev/null +++ b/api/db/migrations/20241118134739_create-organizations-profile-rewards-table.js @@ -0,0 +1,26 @@ +import { PROFILE_REWARDS_TABLE_NAME } from './20240820101213_add-profile-rewards-table.js'; + +export const ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME = 'organizations-profile-rewards'; +const ORGANIZATION_ID_COLUMN = 'organizationId'; +const PROFILE_REWARD_ID_COLUMN = 'profileRewardId'; +const CONSTRAINT_NAME = 'organizationId_profileRewardId_unique'; + +const up = async function (knex) { + await knex.schema.createTable(ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME, function (table) { + table.increments().primary().notNullable(); + table.integer(ORGANIZATION_ID_COLUMN).notNullable().unsigned().references('organizations.id').index(); + table + .integer(PROFILE_REWARD_ID_COLUMN) + .notNullable() + .unsigned() + .references(`${PROFILE_REWARDS_TABLE_NAME}.id`) + .index(); + table.unique([ORGANIZATION_ID_COLUMN, PROFILE_REWARD_ID_COLUMN], { indexName: CONSTRAINT_NAME }); + }); +}; + +const down = async function (knex) { + return knex.schema.dropTable(ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME); +}; + +export { down, up }; From 2393af1c5dc23d77e7491b21d8b50e999e230c66 Mon Sep 17 00:00:00 2001 From: Alexandre Monney Date: Tue, 19 Nov 2024 11:54:37 +0100 Subject: [PATCH 2/5] feat(api): create seeds for shared profile reward --- .../build-organizations-profile-rewards.js | 26 ++++++++ .../data/team-prescription/build-quests.js | 64 +++++++++++++++---- 2 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 api/db/database-builder/factory/build-organizations-profile-rewards.js diff --git a/api/db/database-builder/factory/build-organizations-profile-rewards.js b/api/db/database-builder/factory/build-organizations-profile-rewards.js new file mode 100644 index 00000000000..c9210aa3d81 --- /dev/null +++ b/api/db/database-builder/factory/build-organizations-profile-rewards.js @@ -0,0 +1,26 @@ +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'; + +const buildOrganizationsProfileRewards = function ({ + id = databaseBuffer.getNextId(), + profileRewardId, + organizationId, +} = {}) { + profileRewardId ?? buildProfileReward().id; + organizationId ?? buildOrganization().id; + + const values = { + id, + profileRewardId, + organizationId, + }; + + return databaseBuffer.pushInsertable({ + tableName: ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME, + values, + }); +}; + +export { buildOrganizationsProfileRewards }; diff --git a/api/db/seeds/data/team-prescription/build-quests.js b/api/db/seeds/data/team-prescription/build-quests.js index bbb71ee8a99..2fdc2947289 100644 --- a/api/db/seeds/data/team-prescription/build-quests.js +++ b/api/db/seeds/data/team-prescription/build-quests.js @@ -1,8 +1,9 @@ import { ATTESTATIONS } from '../../../../src/profile/domain/constants.js'; import { REWARD_TYPES } from '../../../../src/quest/domain/constants.js'; import { COMPARISON } from '../../../../src/quest/domain/models/Quest.js'; -import { Assessment, Tag } from '../../../../src/shared/domain/models/index.js'; +import { Assessment, CampaignParticipationStatuses } from '../../../../src/shared/domain/models/index.js'; import { temporaryStorage } from '../../../../src/shared/infrastructure/temporary-storage/index.js'; +import { AEFE_TAG, FEATURE_ATTESTATIONS_MANAGEMENT_ID } from '../common/constants.js'; import { TARGET_PROFILE_BADGES_STAGES_ID } from './constants.js'; const profileRewardTemporaryStorage = temporaryStorage.withPrefix('profile-rewards:'); @@ -13,6 +14,11 @@ const USERS = [ lastName: 'attestation', email: 'attestation-success@example.net', }, + { + firstName: 'attestation-success-shared', + lastName: 'attestation', + email: 'attestation-success-shared@example.net', + }, { firstName: 'attestation-failed', lastName: 'attestation', @@ -30,7 +36,6 @@ const USERS = [ }, ]; const ORGANIZATION = { name: 'attestation', type: 'SCO', isManagingStudents: true }; -const ORGANIZATION_TAG = Tag.AEFE; const CAMPAIGN = { code: 'ATESTTEST', multipleSendings: true }; const TUBES = [ @@ -82,12 +87,14 @@ const buildOrganizationLearners = (databaseBuilder, organization, users) => ); const buildCampaignParticipations = (databaseBuilder, campaignId, users) => - users.map(({ user, organizationLearner }) => + users.map(({ user, organizationLearner, status, sharedAt }) => databaseBuilder.factory.buildCampaignParticipation({ userId: user.id, campaignId, masteryRate: 1, organizationLearnerId: organizationLearner.id, + status, + sharedAt, }), ); @@ -104,7 +111,7 @@ const buildQuest = (databaseBuilder, rewardId, targetProfileId) => { type: 'organization', data: { isManagingStudents: true, - tags: [ORGANIZATION_TAG], + tags: [AEFE_TAG.name], }, comparison: COMPARISON.ONE_OF, }, @@ -186,27 +193,37 @@ const buildTargetProfile = (databaseBuilder, organization) => { export const buildQuests = async (databaseBuilder) => { // Create USERS - const [successUser, failedUser, pendingUser, blankUser] = buildUsers(databaseBuilder); + const [successUser, successSharedUser, failedUser, pendingUser, blankUser] = buildUsers(databaseBuilder); // Create organization const organization = buildOrganization(databaseBuilder); - // Get organization tag id + // Associate attestation feature to organization - const { id: tagId } = await databaseBuilder.knex('tags').select('id').where({ name: ORGANIZATION_TAG }).first(); + databaseBuilder.factory.buildOrganizationFeature({ + organizationId: organization.id, + featureId: FEATURE_ATTESTATIONS_MANAGEMENT_ID, + }); // Associate tag to organization - databaseBuilder.factory.buildOrganizationTag({ organizationId: organization.id, tagId: tagId }); + databaseBuilder.factory.buildOrganizationTag({ organizationId: organization.id, tagId: AEFE_TAG.id }); // Create organizationLearners - const [successOrganizationLearner, failedOrganizationLearner, pendingOrganizationLearner] = buildOrganizationLearners( - databaseBuilder, - organization, - [successUser, failedUser, pendingUser, blankUser], - ); + const [ + successOrganizationLearner, + successSharedOrganizationLearner, + failedOrganizationLearner, + pendingOrganizationLearner, + ] = buildOrganizationLearners(databaseBuilder, organization, [ + successUser, + successSharedUser, + failedUser, + pendingUser, + blankUser, + ]); // Create target profile @@ -236,6 +253,12 @@ export const buildQuests = async (databaseBuilder) => { { user: successUser, organizationLearner: successOrganizationLearner, + sharedAt: null, + status: CampaignParticipationStatuses.TO_SHARE, + }, + { + user: successSharedUser, + organizationLearner: successSharedOrganizationLearner, }, { user: failedUser, @@ -297,7 +320,20 @@ export const buildQuests = async (databaseBuilder) => { rewardId, }); + const { id: sharedProfileRewardId } = databaseBuilder.factory.buildProfileReward({ + userId: successSharedUser.id, + rewardType: REWARD_TYPES.ATTESTATION, + rewardId, + }); + + // Create link between profile reward and organization + + databaseBuilder.factory.buildOrganizationsProfileRewards({ + organizationId: organization.id, + profileRewardId: sharedProfileRewardId, + }); + // Insert job count in temporary storage for pending user - profileRewardTemporaryStorage.increment(pendingUser.id); + await profileRewardTemporaryStorage.increment(pendingUser.id); }; From b5608ce6557676f0e721e04a9292a7de563254f4 Mon Sep 17 00:00:00 2001 From: Alexandre Monney Date: Tue, 19 Nov 2024 11:29:31 +0100 Subject: [PATCH 3/5] feat(api): create repositories needed in usecase --- api/src/profile/domain/models/Campaign.js | 6 ++ api/src/profile/domain/usecases/index.js | 4 + .../campaign-participation-repository.js | 13 ++++ .../organization-profile-reward-repository.js | 13 ++++ .../repositories/profile-reward-repository.js | 12 +++ .../campaign-participation-repository_test.js | 46 ++++++++++++ ...zations-profile-rewards-repository_test.js | 73 +++++++++++++++++++ .../profile-reward-repository_test.js | 32 ++++++++ 8 files changed, 199 insertions(+) create mode 100644 api/src/profile/domain/models/Campaign.js create mode 100644 api/src/profile/infrastructure/repositories/campaign-participation-repository.js create mode 100644 api/src/profile/infrastructure/repositories/organization-profile-reward-repository.js create mode 100644 api/tests/profile/integration/infrastructure/repositories/campaign-participation-repository_test.js create mode 100644 api/tests/profile/integration/infrastructure/repositories/organizations-profile-rewards-repository_test.js diff --git a/api/src/profile/domain/models/Campaign.js b/api/src/profile/domain/models/Campaign.js new file mode 100644 index 00000000000..77690c91436 --- /dev/null +++ b/api/src/profile/domain/models/Campaign.js @@ -0,0 +1,6 @@ +export class Campaign { + constructor({ id, organizationId }) { + this.id = id; + this.organizationId = organizationId; + } +} diff --git a/api/src/profile/domain/usecases/index.js b/api/src/profile/domain/usecases/index.js index f2e46bf948f..8b79e1f4212 100644 --- a/api/src/profile/domain/usecases/index.js +++ b/api/src/profile/domain/usecases/index.js @@ -10,6 +10,8 @@ import * as competenceRepository from '../../../shared/infrastructure/repositori import { injectDependencies } from '../../../shared/infrastructure/utils/dependency-injection.js'; import { importNamedExportsFromDirectory } from '../../../shared/infrastructure/utils/import-named-exports-from-directory.js'; import * as attestationRepository from '../../infrastructure/repositories/attestation-repository.js'; +import * as campaignParticipationRepository from '../../infrastructure/repositories/campaign-participation-repository.js'; +import * as organizationProfileRewardRepository from '../../infrastructure/repositories/organization-profile-reward-repository.js'; import * as rewardRepository from '../../infrastructure/repositories/reward-repository.js'; const path = dirname(fileURLToPath(import.meta.url)); @@ -25,8 +27,10 @@ const dependencies = { knowledgeElementRepository, profileRewardRepository, userRepository: repositories.userRepository, + organizationProfileRewardRepository, attestationRepository, rewardRepository, + campaignParticipationRepository, }; const usecases = injectDependencies(usecasesWithoutInjectedDependencies, dependencies); diff --git a/api/src/profile/infrastructure/repositories/campaign-participation-repository.js b/api/src/profile/infrastructure/repositories/campaign-participation-repository.js new file mode 100644 index 00000000000..0a4e0931c4c --- /dev/null +++ b/api/src/profile/infrastructure/repositories/campaign-participation-repository.js @@ -0,0 +1,13 @@ +import { DomainTransaction } from '../../../shared/domain/DomainTransaction.js'; +import { Campaign } from '../../domain/models/Campaign.js'; + +export async function getCampaignByParticipationId({ campaignParticipationId }) { + const knexConnection = DomainTransaction.getConnection(); + const campaign = await knexConnection('campaign-participations') + .select('campaigns.id', 'campaigns.organizationId') + .innerJoin('campaigns', 'campaigns.id', 'campaign-participations.campaignId') + .where({ 'campaign-participations.id': campaignParticipationId }) + .first(); + + return campaign ? new Campaign(campaign) : null; +} diff --git a/api/src/profile/infrastructure/repositories/organization-profile-reward-repository.js b/api/src/profile/infrastructure/repositories/organization-profile-reward-repository.js new file mode 100644 index 00000000000..b57dfb17115 --- /dev/null +++ b/api/src/profile/infrastructure/repositories/organization-profile-reward-repository.js @@ -0,0 +1,13 @@ +import { ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME } from '../../../../db/migrations/20241118134739_create-organizations-profile-rewards-table.js'; +import { DomainTransaction } from '../../../shared/domain/DomainTransaction.js'; + +export const save = async ({ organizationId, profileRewardId }) => { + const knexConn = DomainTransaction.getConnection(); + await knexConn(ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME) + .insert({ + organizationId, + profileRewardId, + }) + .onConflict() + .ignore(); +}; diff --git a/api/src/profile/infrastructure/repositories/profile-reward-repository.js b/api/src/profile/infrastructure/repositories/profile-reward-repository.js index c0b8677b145..4ecf5a20f46 100644 --- a/api/src/profile/infrastructure/repositories/profile-reward-repository.js +++ b/api/src/profile/infrastructure/repositories/profile-reward-repository.js @@ -32,6 +32,18 @@ export const getByUserId = async ({ userId }) => { return profileRewards.map(toDomain); }; +/** + * @param {Object} args + * @param {number} args.profileRewardId + * @returns {Promise} + */ +export const getById = async ({ profileRewardId }) => { + const knexConnection = await DomainTransaction.getConnection(); + const profileReward = await knexConnection(PROFILE_REWARDS_TABLE_NAME).where({ id: profileRewardId }).first(); + + return profileReward ? toDomain(profileReward) : null; +}; + /** * @param {Object} args * @param {string} args.attestationKey diff --git a/api/tests/profile/integration/infrastructure/repositories/campaign-participation-repository_test.js b/api/tests/profile/integration/infrastructure/repositories/campaign-participation-repository_test.js new file mode 100644 index 00000000000..ad97caee781 --- /dev/null +++ b/api/tests/profile/integration/infrastructure/repositories/campaign-participation-repository_test.js @@ -0,0 +1,46 @@ +import { Campaign } from '../../../../../src/profile/domain/models/Campaign.js'; +import { getCampaignByParticipationId } from '../../../../../src/profile/infrastructure/repositories/campaign-participation-repository.js'; +import { databaseBuilder, expect } from '../../../../test-helper.js'; + +describe('Profile | Integration | Infrastructure | Repository | campaign-participation-repository', function () { + describe('#getCampaignByParticipationId', function () { + it('return campaign informations for given campaignParticipationId', async function () { + // given + const campaign = databaseBuilder.factory.buildCampaign(); + const campaignParticipationId = databaseBuilder.factory.buildCampaignParticipation({ + campaignId: campaign.id, + }).id; + await databaseBuilder.commit(); + + // when + const result = await getCampaignByParticipationId({ campaignParticipationId }); + + // then + expect(result).to.be.an.instanceOf(Campaign); + expect(result.id).to.equal(campaign.id); + expect(result.organizationId).to.equal(campaign.organizationId); + }); + + it('return null if campaignParticipation does not exist', async function () { + // given + const notExistingCampaignParticipation = 123; + + // when + const result = await getCampaignByParticipationId({ campaignParticipationId: notExistingCampaignParticipation }); + + // then + expect(result).to.be.null; + }); + + it('return null if campaignParticipation does not have linked campaign', async function () { + // given + const campaignParticipationId = databaseBuilder.factory.buildCampaignParticipation({ campaignId: null }).id; + + // when + const result = await getCampaignByParticipationId({ campaignParticipationId }); + + // then + expect(result).to.be.null; + }); + }); +}); diff --git a/api/tests/profile/integration/infrastructure/repositories/organizations-profile-rewards-repository_test.js b/api/tests/profile/integration/infrastructure/repositories/organizations-profile-rewards-repository_test.js new file mode 100644 index 00000000000..ea538e22b7e --- /dev/null +++ b/api/tests/profile/integration/infrastructure/repositories/organizations-profile-rewards-repository_test.js @@ -0,0 +1,73 @@ +import { ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME } from '../../../../../db/migrations/20241118134739_create-organizations-profile-rewards-table.js'; +import { save } from '../../../../../src/profile/infrastructure/repositories/organization-profile-reward-repository.js'; +import { databaseBuilder, expect, knex } from '../../../../test-helper.js'; + +describe('Profile | Integration | Infrastructure | Repository | organizations-profile-rewards-repository', function () { + describe('#save', function () { + it('should save organization profile reward', async function () { + // given + const profileReward = databaseBuilder.factory.buildProfileReward(); + const organization = databaseBuilder.factory.buildOrganization(); + await databaseBuilder.commit(); + + // when + await save({ organizationId: organization.id, profileRewardId: profileReward.id }); + + // then + const organizationProfileReward = await knex(ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME).where({ + organizationId: organization.id, + profileRewardId: profileReward.id, + }); + expect(organizationProfileReward).to.have.lengthOf(1); + }); + + it('should save organization profile reward for other profile reward id but for the same organization', async function () { + // given + const profileReward = databaseBuilder.factory.buildProfileReward(); + const otherProfileReward = databaseBuilder.factory.buildProfileReward({ rewardId: 11 }); + const organization = databaseBuilder.factory.buildOrganization(); + databaseBuilder.factory.buildOrganizationsProfileRewards({ + organizationId: organization.id, + profileRewardId: profileReward.id, + }); + + await databaseBuilder.commit(); + + // when + await save({ organizationId: organization.id, profileRewardId: otherProfileReward.id }); + + // then + const organizationProfileReward = await knex(ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME) + .select('profileRewardId') + .where({ + organizationId: organization.id, + }); + expect(organizationProfileReward).to.have.lengthOf(2); + expect(organizationProfileReward).to.have.deep.members([ + { profileRewardId: profileReward.id }, + { profileRewardId: otherProfileReward.id }, + ]); + }); + + it('should do nothing if profile reward is already existing for same organization', async function () { + // given + const profileReward = databaseBuilder.factory.buildProfileReward(); + const organization = databaseBuilder.factory.buildOrganization(); + databaseBuilder.factory.buildOrganizationsProfileRewards({ + organizationId: organization.id, + profileRewardId: profileReward.id, + }); + await databaseBuilder.commit(); + + // when + await save({ organizationId: organization.id, profileRewardId: profileReward.id }); + + // then + const organizationProfileReward = await knex(ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME).where({ + organizationId: organization.id, + profileRewardId: profileReward.id, + }); + expect(organizationProfileReward).to.have.lengthOf(1); + }); + }); +}); diff --git a/api/tests/profile/integration/infrastructure/repositories/profile-reward-repository_test.js b/api/tests/profile/integration/infrastructure/repositories/profile-reward-repository_test.js index 8fd24295227..610eaa5ffad 100644 --- a/api/tests/profile/integration/infrastructure/repositories/profile-reward-repository_test.js +++ b/api/tests/profile/integration/infrastructure/repositories/profile-reward-repository_test.js @@ -3,6 +3,7 @@ import { ATTESTATIONS } from '../../../../../src/profile/domain/constants.js'; import { ProfileReward } from '../../../../../src/profile/domain/models/ProfileReward.js'; import { getByAttestationKeyAndUserIds, + getById, getByUserId, save, } from '../../../../../src/profile/infrastructure/repositories/profile-reward-repository.js'; @@ -34,6 +35,37 @@ describe('Profile | Integration | Repository | profile-reward', function () { }); }); + describe('#getById', function () { + it('should return null if the profile reward does not exist', async function () { + // given + const notExistingProfileRewardId = 12; + + // when + const result = await getById({ profileRewardId: notExistingProfileRewardId }); + + // then + expect(result).to.be.null; + }); + + it('should return the expected profile reward', async function () { + // given + const attestation = databaseBuilder.factory.buildAttestation({ key: 'key' }); + const expectedProfileReward = databaseBuilder.factory.buildProfileReward({ rewardId: attestation.id }); + + const otherAttestation = databaseBuilder.factory.buildAttestation({ key: 'otherkey' }); + databaseBuilder.factory.buildProfileReward({ rewardId: otherAttestation.id }); + + await databaseBuilder.commit(); + + // when + const result = await getById({ profileRewardId: expectedProfileReward.id }); + + // then + expect(result).to.be.an.instanceof(ProfileReward); + expect(result.id).to.equal(expectedProfileReward.id); + }); + }); + describe('#getByUserId', function () { it('should return all profile rewards for the user', async function () { // given From 9aaa251cfa18cf97cce156bc668991c1fdc51e10 Mon Sep 17 00:00:00 2001 From: Alexandre Monney Date: Tue, 19 Nov 2024 11:31:19 +0100 Subject: [PATCH 4/5] feat(api): create share profile usecase --- .../http-error-mapper-configuration.js | 18 +++- api/src/profile/domain/errors.js | 8 +- .../domain/usecases/share-profile-reward.js | 23 +++++ .../usecases/share-profile-reward_test.js | 84 +++++++++++++++++++ .../http-error-mapper-configuration_test.js | 32 ++++++- 5 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 api/src/profile/domain/usecases/share-profile-reward.js create mode 100644 api/tests/profile/integration/domain/usecases/share-profile-reward_test.js diff --git a/api/src/profile/application/http-error-mapper-configuration.js b/api/src/profile/application/http-error-mapper-configuration.js index 877492e959f..c228716b14f 100644 --- a/api/src/profile/application/http-error-mapper-configuration.js +++ b/api/src/profile/application/http-error-mapper-configuration.js @@ -1,5 +1,9 @@ import { HttpErrors } from '../../shared/application/http-errors.js'; -import { AttestationNotFoundError } from '../domain/errors.js'; +import { + AttestationNotFoundError, + ProfileRewardCantBeSharedError, + RewardTypeDoesNotExistError, +} from '../domain/errors.js'; const profileDomainErrorMappingConfiguration = [ { @@ -8,6 +12,18 @@ const profileDomainErrorMappingConfiguration = [ return new HttpErrors.NotFoundError(error.message, error.code, error.meta); }, }, + { + name: ProfileRewardCantBeSharedError.name, + httpErrorFn: (error) => { + return new HttpErrors.PreconditionFailedError(error.message, error.code, error.meta); + }, + }, + { + name: RewardTypeDoesNotExistError.name, + httpErrorFn: (error) => { + return new HttpErrors.BadRequestError(error.message, error.code, error.meta); + }, + }, ]; export { profileDomainErrorMappingConfiguration }; diff --git a/api/src/profile/domain/errors.js b/api/src/profile/domain/errors.js index 08864038e4a..09e5e472cf3 100644 --- a/api/src/profile/domain/errors.js +++ b/api/src/profile/domain/errors.js @@ -12,4 +12,10 @@ class RewardTypeDoesNotExistError extends DomainError { } } -export { AttestationNotFoundError, RewardTypeDoesNotExistError }; +class ProfileRewardCantBeSharedError extends DomainError { + constructor(message = 'Profile reward cannot be shared') { + super(message); + } +} + +export { AttestationNotFoundError, ProfileRewardCantBeSharedError, RewardTypeDoesNotExistError }; diff --git a/api/src/profile/domain/usecases/share-profile-reward.js b/api/src/profile/domain/usecases/share-profile-reward.js new file mode 100644 index 00000000000..c2b321b5669 --- /dev/null +++ b/api/src/profile/domain/usecases/share-profile-reward.js @@ -0,0 +1,23 @@ +import { ProfileRewardCantBeSharedError } from '../errors.js'; + +export const shareProfileReward = async function ({ + userId, + profileRewardId, + campaignParticipationId, + profileRewardRepository, + organizationProfileRewardRepository, + campaignParticipationRepository, +}) { + const profileReward = await profileRewardRepository.getById({ profileRewardId }); + + if (profileReward?.userId !== userId) { + throw new ProfileRewardCantBeSharedError(); + } + + const campaign = await campaignParticipationRepository.getCampaignByParticipationId({ campaignParticipationId }); + + await organizationProfileRewardRepository.save({ + organizationId: campaign.organizationId, + profileRewardId, + }); +}; diff --git a/api/tests/profile/integration/domain/usecases/share-profile-reward_test.js b/api/tests/profile/integration/domain/usecases/share-profile-reward_test.js new file mode 100644 index 00000000000..111b331d3a6 --- /dev/null +++ b/api/tests/profile/integration/domain/usecases/share-profile-reward_test.js @@ -0,0 +1,84 @@ +import { ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME } from '../../../../../db/migrations/20241118134739_create-organizations-profile-rewards-table.js'; +import { ProfileRewardCantBeSharedError } from '../../../../../src/profile/domain/errors.js'; +import { usecases } from '../../../../../src/profile/domain/usecases/index.js'; +import { catchErr, databaseBuilder, expect, knex } from '../../../../test-helper.js'; + +describe('Profile | Integration | Domain | Usecases | share-profile-reward', function () { + describe('#shareProfileReward', function () { + describe('if the reward does not exist', function () { + let user; + + before(async function () { + user = databaseBuilder.factory.buildUser(); + await databaseBuilder.commit(); + }); + + it('should throw an ProfileRewardCantBeSharedError', async function () { + const error = await catchErr(usecases.shareProfileReward)({ + userId: user.id, + profileRewardId: 1, + campaignParticipationId: 1, + }); + expect(error).to.be.instanceOf(ProfileRewardCantBeSharedError); + }); + }); + + describe('if the reward does not belong to the user', function () { + let profileReward; + let user; + + before(async function () { + const otherUserId = databaseBuilder.factory.buildUser().id; + user = databaseBuilder.factory.buildUser(); + profileReward = databaseBuilder.factory.buildProfileReward({ + rewardId: 1, + userId: otherUserId, + }); + + await databaseBuilder.commit(); + }); + + it('should throw an ProfileRewardCantBeSharedError', async function () { + expect( + await catchErr(usecases.shareProfileReward)({ + userId: user.id, + profileRewardId: profileReward.id, + campaignParticipationId: 1, + }), + ).to.be.instanceOf(ProfileRewardCantBeSharedError); + }); + }); + + describe('if the reward belongs to the user', function () { + let profileRewardId; + let campaignParticipationId; + let userId; + + before(async function () { + userId = databaseBuilder.factory.buildUser().id; + profileRewardId = databaseBuilder.factory.buildProfileReward({ + rewardId: 1, + userId: userId, + }).id; + campaignParticipationId = databaseBuilder.factory.buildCampaignParticipation({ + userId, + }).id; + + await databaseBuilder.commit(); + }); + + it('should insert a new line in organization-profile-rewards table', async function () { + await usecases.shareProfileReward({ + userId, + profileRewardId, + campaignParticipationId, + }); + + const organizationsProfileRewards = await knex(ORGANIZATIONS_PROFILE_REWARDS_TABLE_NAME); + + expect(organizationsProfileRewards).to.have.lengthOf(1); + expect(organizationsProfileRewards[0].profileRewardId).to.equal(profileRewardId); + }); + }); + }); +}); diff --git a/api/tests/profile/unit/application/http-error-mapper-configuration_test.js b/api/tests/profile/unit/application/http-error-mapper-configuration_test.js index cf4e0243a18..5f59b44c073 100644 --- a/api/tests/profile/unit/application/http-error-mapper-configuration_test.js +++ b/api/tests/profile/unit/application/http-error-mapper-configuration_test.js @@ -1,5 +1,9 @@ import { profileDomainErrorMappingConfiguration } from '../../../../src/profile/application/http-error-mapper-configuration.js'; -import { AttestationNotFoundError } from '../../../../src/profile/domain/errors.js'; +import { + AttestationNotFoundError, + ProfileRewardCantBeSharedError, + RewardTypeDoesNotExistError, +} from '../../../../src/profile/domain/errors.js'; import { HttpErrors } from '../../../../src/shared/application/http-errors.js'; import { expect } from '../../../test-helper.js'; @@ -16,4 +20,30 @@ describe('Profile | Unit | Application | HttpErrorMapperConfiguration', function //then expect(error).to.be.instanceOf(HttpErrors.NotFoundError); }); + + it('instantiates PreconditionFailedError when ProfileRewardCantBeShared', async function () { + //given + const httpErrorMapper = profileDomainErrorMappingConfiguration.find( + (httpErrorMapper) => httpErrorMapper.name === ProfileRewardCantBeSharedError.name, + ); + + //when + const error = httpErrorMapper.httpErrorFn(new ProfileRewardCantBeSharedError()); + + //then + expect(error).to.be.instanceOf(HttpErrors.PreconditionFailedError); + }); + + it('instantiates BadRequestError when RewardTypeDoesNotExistError', async function () { + //given + const httpErrorMapper = profileDomainErrorMappingConfiguration.find( + (httpErrorMapper) => httpErrorMapper.name === RewardTypeDoesNotExistError.name, + ); + + //when + const error = httpErrorMapper.httpErrorFn(new RewardTypeDoesNotExistError()); + + //then + expect(error).to.be.instanceOf(HttpErrors.BadRequestError); + }); }); From d2bad35fb6176e29481269913a37dfceff3985b3 Mon Sep 17 00:00:00 2001 From: Alexandre Monney Date: Tue, 19 Nov 2024 11:31:48 +0100 Subject: [PATCH 5/5] feat(api): create share profile route and controller --- api/src/profile/application/index.js | 65 ++++++++++++---- .../profile/application/profile-controller.js | 9 +++ .../shared/domain/types/identifiers-type.js | 1 + .../share-profile-reward-route_test.js | 76 +++++++++++++++++++ .../application/profile-controller_test.js | 42 ++++++++++ 5 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 api/tests/profile/acceptance/application/share-profile-reward-route_test.js diff --git a/api/src/profile/application/index.js b/api/src/profile/application/index.js index 23252b58463..5f429ab8881 100644 --- a/api/src/profile/application/index.js +++ b/api/src/profile/application/index.js @@ -6,7 +6,37 @@ import { attestationController } from './attestation-controller.js'; import { profileController } from './profile-controller.js'; const register = async function (server) { - server.route([ + const adminRoutes = [ + { + method: 'GET', + path: '/api/admin/users/{id}/profile', + config: { + pre: [ + { + method: (request, h) => + securityPreHandlers.hasAtLeastOneAccessOf([ + securityPreHandlers.checkAdminMemberHasRoleSuperAdmin, + securityPreHandlers.checkAdminMemberHasRoleCertif, + securityPreHandlers.checkAdminMemberHasRoleSupport, + securityPreHandlers.checkAdminMemberHasRoleMetier, + ])(request, h), + }, + ], + validate: { + params: Joi.object({ + id: identifiersType.userId, + }), + }, + handler: profileController.getProfileForAdmin, + notes: [ + "- Permet à un administrateur de récupérer le nombre total de Pix d'un utilisateur\n et de ses scorecards", + ], + tags: ['api', 'user', 'profile'], + }, + }, + ]; + + const userRoutes = [ { method: 'GET', path: '/api/users/{userId}/attestations/{attestationKey}', @@ -57,33 +87,38 @@ const register = async function (server) { }, }, { - method: 'GET', - path: '/api/admin/users/{id}/profile', + method: 'POST', + path: '/api/users/{userId}/profile/share-reward', config: { pre: [ { - method: (request, h) => - securityPreHandlers.hasAtLeastOneAccessOf([ - securityPreHandlers.checkAdminMemberHasRoleSuperAdmin, - securityPreHandlers.checkAdminMemberHasRoleCertif, - securityPreHandlers.checkAdminMemberHasRoleSupport, - securityPreHandlers.checkAdminMemberHasRoleMetier, - ])(request, h), + method: securityPreHandlers.checkRequestedUserIsAuthenticatedUser, + assign: 'requestedUserIsAuthenticatedUser', }, ], validate: { params: Joi.object({ - id: identifiersType.userId, + userId: identifiersType.userId, + }), + payload: Joi.object({ + data: { + attributes: { + campaignParticipationId: identifiersType.campaignParticipationId, + profileRewardId: identifiersType.profileRewardId, + }, + }, }), }, - handler: profileController.getProfileForAdmin, + handler: profileController.shareProfileReward, notes: [ - "- Permet à un administrateur de récupérer le nombre total de Pix d'un utilisateur\n et de ses scorecards", + "- Cette route permet à un utilisateur de partager l'obtention de son attestation avec une organisation\n", ], - tags: ['api', 'user', 'profile'], + tags: ['api', 'user', 'profile', 'reward'], }, }, - ]); + ]; + + server.route([...adminRoutes, ...userRoutes]); }; const name = 'profile-api'; diff --git a/api/src/profile/application/profile-controller.js b/api/src/profile/application/profile-controller.js index 14c26971a48..c675f0b40d1 100644 --- a/api/src/profile/application/profile-controller.js +++ b/api/src/profile/application/profile-controller.js @@ -18,9 +18,18 @@ const getProfileForAdmin = function (request, h, dependencies = { profileSeriali return usecases.getUserProfile({ userId, locale }).then(dependencies.profileSerializer.serialize); }; +const shareProfileReward = async function (request, h) { + const userId = request.params.userId; + const { profileRewardId, campaignParticipationId } = request.payload.data.attributes; + + await usecases.shareProfileReward({ userId, profileRewardId, campaignParticipationId }); + return h.response().code(201); +}; + const profileController = { getProfile, getProfileForAdmin, + shareProfileReward, }; export { profileController }; diff --git a/api/src/shared/domain/types/identifiers-type.js b/api/src/shared/domain/types/identifiers-type.js index 98a1339687d..273cd84f3dc 100644 --- a/api/src/shared/domain/types/identifiers-type.js +++ b/api/src/shared/domain/types/identifiers-type.js @@ -55,6 +55,7 @@ const typesPositiveInteger32bits = [ 'ownerId', 'passageId', 'placeId', + 'profileRewardId', 'schoolingRegistrationId', 'sessionId', 'stageCollectionId', diff --git a/api/tests/profile/acceptance/application/share-profile-reward-route_test.js b/api/tests/profile/acceptance/application/share-profile-reward-route_test.js new file mode 100644 index 00000000000..4f86897195a --- /dev/null +++ b/api/tests/profile/acceptance/application/share-profile-reward-route_test.js @@ -0,0 +1,76 @@ +import { + createServer, + databaseBuilder, + expect, + generateValidRequestAuthorizationHeader, +} from '../../../test-helper.js'; + +describe('Profile | Acceptance | Application | Share Profile Route ', function () { + let server; + + beforeEach(async function () { + server = await createServer(); + }); + + describe('POST /api/users/{userId}/profile/share-reward', function () { + describe('when profile reward exists and is linked to user', function () { + it('should return a success code', async function () { + // given + const userId = databaseBuilder.factory.buildUser().id; + const profileReward = databaseBuilder.factory.buildProfileReward({ userId }); + const campaignParticipation = databaseBuilder.factory.buildCampaignParticipation(); + + await databaseBuilder.commit(); + const options = { + method: 'POST', + headers: { authorization: generateValidRequestAuthorizationHeader(userId) }, + url: `/api/users/${userId}/profile/share-reward`, + payload: { + data: { + attributes: { + profileRewardId: profileReward.id, + campaignParticipationId: campaignParticipation.id, + }, + }, + }, + }; + + // when + const response = await server.inject(options); + + // then + expect(response.statusCode).to.equal(201); + }); + }); + }); + + describe('when profile reward is not linked to user', function () { + it('should return a 412 code', async function () { + // given + const userId = databaseBuilder.factory.buildUser().id; + const profileReward = databaseBuilder.factory.buildProfileReward(); + const campaignParticipation = databaseBuilder.factory.buildCampaignParticipation(); + + await databaseBuilder.commit(); + const options = { + method: 'POST', + headers: { authorization: generateValidRequestAuthorizationHeader(userId) }, + url: `/api/users/${userId}/profile/share-reward`, + payload: { + data: { + attributes: { + profileRewardId: profileReward.id, + campaignParticipationId: campaignParticipation.id, + }, + }, + }, + }; + + // when + const response = await server.inject(options); + + // then + expect(response.statusCode).to.equal(412); + }); + }); +}); diff --git a/api/tests/profile/unit/application/profile-controller_test.js b/api/tests/profile/unit/application/profile-controller_test.js index f4cdcb25b2b..7b214b001c6 100644 --- a/api/tests/profile/unit/application/profile-controller_test.js +++ b/api/tests/profile/unit/application/profile-controller_test.js @@ -38,4 +38,46 @@ describe('Profile | Unit | Controller | profile-controller', function () { expect(usecases.getUserProfile).to.have.been.calledWithExactly({ userId, locale }); }); }); + + describe('#shareProfileReward', function () { + beforeEach(function () { + sinon.stub(usecases, 'shareProfileReward').resolves(); + }); + + it('should call the expected usecase', async function () { + // given + const profileRewardId = '11'; + const campaignParticipationId = '22'; + const userId = '33'; + + const request = { + auth: { + credentials: { + userId, + }, + }, + params: { + userId, + }, + payload: { + data: { + attributes: { + profileRewardId, + campaignParticipationId, + }, + }, + }, + }; + + // when + await profileController.shareProfileReward(request, hFake); + + // then + expect(usecases.shareProfileReward).to.have.been.calledWithExactly({ + userId, + profileRewardId, + campaignParticipationId, + }); + }); + }); });