diff --git a/api/db/database-builder/factory/build-attestation.js b/api/db/database-builder/factory/build-attestation.js index d4f3ef366da..4191f376aae 100644 --- a/api/db/database-builder/factory/build-attestation.js +++ b/api/db/database-builder/factory/build-attestation.js @@ -1,3 +1,4 @@ +import { ATTESTATIONS } from '../../../src/profile/domain/constants.js'; import { ATTESTATIONS_TABLE_NAME } from '../../migrations/20240820101115_add-attestations-table.js'; import { databaseBuffer } from '../database-buffer.js'; @@ -5,11 +6,13 @@ const buildAttestation = function ({ id = databaseBuffer.getNextId(), createdAt = new Date(), templateName = '6eme-pdf', + key = ATTESTATIONS.SIXTH_GRADE, } = {}) { const values = { id, createdAt, templateName, + key, }; return databaseBuffer.pushInsertable({ diff --git a/api/db/migrations/20241002122830_add-key-column-to-attestations-table.js b/api/db/migrations/20241002122830_add-key-column-to-attestations-table.js new file mode 100644 index 00000000000..7f7edb7b8e2 --- /dev/null +++ b/api/db/migrations/20241002122830_add-key-column-to-attestations-table.js @@ -0,0 +1,16 @@ +const TABLE_NAME = 'attestations'; +const COLUMN_NAME = 'key'; + +const up = async function (knex) { + await knex.schema.table(TABLE_NAME, function (table) { + table.string(COLUMN_NAME).notNullable().unique(); + }); +}; + +const down = async function (knex) { + await knex.schema.table(TABLE_NAME, function (table) { + table.dropColumn(COLUMN_NAME); + }); +}; + +export { down, up }; diff --git a/api/db/seeds/data/team-prescription/build-quests.js b/api/db/seeds/data/team-prescription/build-quests.js index a0856da001b..bae9d7eba8a 100644 --- a/api/db/seeds/data/team-prescription/build-quests.js +++ b/api/db/seeds/data/team-prescription/build-quests.js @@ -1,3 +1,4 @@ +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 { TARGET_PROFILE_BADGES_STAGES_ID } from './constants.js'; @@ -8,7 +9,8 @@ async function createAttestationQuest(databasebuilder) { const successfulUsers = await retrieveSuccessfulUsers(databasebuilder, campaigns); const { id: rewardId } = await databasebuilder.factory.buildAttestation({ - templateName: '6eme-pdf', + templateName: 'sixth-grade-attestation-template', + key: ATTESTATIONS.SIXTH_GRADE, }); const questEligibilityRequirements = [ diff --git a/api/src/profile/application/api/attestations-api.js b/api/src/profile/application/api/attestations-api.js new file mode 100644 index 00000000000..8acc603b4fa --- /dev/null +++ b/api/src/profile/application/api/attestations-api.js @@ -0,0 +1,19 @@ +import * as path from 'node:path'; +import * as url from 'node:url'; + +import { usecases } from '../../domain/usecases/index.js'; +import * as pdfWithFormSerializer from '../../infrastructure/serializers/pdf/pdf-with-form-serializer.js'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); + +export const generateAttestations = async function ({ + attestationKey, + userIds, + dependencies = { pdfWithFormSerializer }, +}) { + const { templateName, data } = await usecases.getAttestationDataForUsers({ attestationKey, userIds }); + + const templatePath = path.join(__dirname, `../../infrastructure/serializers/pdf/templates/${templateName}.pdf`); + + return dependencies.pdfWithFormSerializer.generate(templatePath, data); +}; diff --git a/api/src/profile/domain/constants.js b/api/src/profile/domain/constants.js new file mode 100644 index 00000000000..06c4b94a663 --- /dev/null +++ b/api/src/profile/domain/constants.js @@ -0,0 +1,3 @@ +export const ATTESTATIONS = { + SIXTH_GRADE: 'SIXTH_GRADE', +}; diff --git a/api/src/profile/domain/models/ProfileReward.js b/api/src/profile/domain/models/ProfileReward.js index 32712abea4f..1bbd6755728 100644 --- a/api/src/profile/domain/models/ProfileReward.js +++ b/api/src/profile/domain/models/ProfileReward.js @@ -1,7 +1,8 @@ export class ProfileReward { - constructor({ id, rewardId, rewardType } = {}) { + constructor({ id, rewardId, rewardType, createdAt } = {}) { this.id = id; this.rewardId = rewardId; this.rewardType = rewardType; + this.createdAt = createdAt; } } diff --git a/api/src/profile/domain/models/User.js b/api/src/profile/domain/models/User.js new file mode 100644 index 00000000000..85f4d992a12 --- /dev/null +++ b/api/src/profile/domain/models/User.js @@ -0,0 +1,27 @@ +import capitalize from 'lodash/capitalize.js'; + +export class User { + id; + #firstName; + #lastName; + + constructor({ id, firstName, lastName }) { + this.id = id; + this.#firstName = firstName; + this.#lastName = lastName; + } + + get fullName() { + return capitalize(this.#firstName) + ' ' + this.#lastName.toUpperCase(); + } + + toForm(createdAt) { + const map = new Map(); + + map.set('fullName', this.fullName); + map.set('filename', this.fullName + Date.now()); + map.set('date', createdAt); + + return map; + } +} diff --git a/api/src/profile/domain/usecases/get-attestation-data-for-users.js b/api/src/profile/domain/usecases/get-attestation-data-for-users.js new file mode 100644 index 00000000000..60728fcdb2d --- /dev/null +++ b/api/src/profile/domain/usecases/get-attestation-data-for-users.js @@ -0,0 +1,9 @@ +export async function getAttestationDataForUsers({ attestationKey, userIds, userRepository, profileRewardRepository }) { + const users = await userRepository.getByIds({ userIds }); + const profileRewards = await profileRewardRepository.getByAttestationKeyAndUserIds({ attestationKey, userIds }); + + return profileRewards.map(({ userId, createdAt }) => { + const user = users.find((user) => user.id === userId); + return user.toForm(createdAt); + }); +} diff --git a/api/src/profile/domain/usecases/index.js b/api/src/profile/domain/usecases/index.js index c3e1f28a02b..ac14337cb45 100644 --- a/api/src/profile/domain/usecases/index.js +++ b/api/src/profile/domain/usecases/index.js @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import * as knowledgeElementRepository from '../../../../lib/infrastructure/repositories/knowledge-element-repository.js'; import * as competenceEvaluationRepository from '../../../evaluation/infrastructure/repositories/competence-evaluation-repository.js'; +import { repositories } from '../../../profile/infrastructure/repositories/index.js'; import * as profileRewardRepository from '../../../profile/infrastructure/repositories/profile-reward-repository.js'; import * as areaRepository from '../../../shared/infrastructure/repositories/area-repository.js'; import * as competenceRepository from '../../../shared/infrastructure/repositories/competence-repository.js'; @@ -21,6 +22,7 @@ const dependencies = { competenceEvaluationRepository, knowledgeElementRepository, profileRewardRepository, + userRepository: repositories.userRepository, }; const usecases = injectDependencies(usecasesWithoutInjectedDependencies, dependencies); diff --git a/api/src/profile/infrastructure/repositories/index.js b/api/src/profile/infrastructure/repositories/index.js new file mode 100644 index 00000000000..b5dee85e48d --- /dev/null +++ b/api/src/profile/infrastructure/repositories/index.js @@ -0,0 +1,15 @@ +import * as usersApi from '../../../../src/identity-access-management/application/api/users-api.js'; +import { injectDependencies } from '../../../../src/shared/infrastructure/utils/dependency-injection.js'; +import * as userRepository from './user-repository.js'; + +const repositoriesWithoutInjectedDependencies = { + userRepository, +}; + +const dependencies = { + usersApi, +}; + +const repositories = injectDependencies(repositoriesWithoutInjectedDependencies, dependencies); + +export { repositories }; diff --git a/api/src/profile/infrastructure/repositories/profile-reward-repository.js b/api/src/profile/infrastructure/repositories/profile-reward-repository.js index 9698b52c7be..eb55a1fa658 100644 --- a/api/src/profile/infrastructure/repositories/profile-reward-repository.js +++ b/api/src/profile/infrastructure/repositories/profile-reward-repository.js @@ -3,11 +3,14 @@ import { REWARD_TYPES } from '../../../quest/domain/constants.js'; import { DomainTransaction } from '../../../shared/domain/DomainTransaction.js'; import { ProfileReward } from '../../domain/models/ProfileReward.js'; +const ATTESTATIONS_TABLE_NAME = 'attestations'; + /** - * @param {number} userId - * @param {number} rewardId - * @param {('ATTESTATION')} rewardType - * @returns {Promise<*>} + * @param {Object} args + * @param {number} args.userId + * @param {number} args.rewardId + * @param {('ATTESTATION')} args.rewardType + * @returns {Promise} */ export const save = async ({ userId, rewardId, rewardType = REWARD_TYPES.ATTESTATION }) => { const knexConnection = await DomainTransaction.getConnection(); @@ -19,8 +22,9 @@ export const save = async ({ userId, rewardId, rewardType = REWARD_TYPES.ATTESTA }; /** - * @param {number} userId - * @returns {Promise<*>} + * @param {Object} args + * @param {number} args.userId + * @returns {Promise>} */ export const getByUserId = async ({ userId }) => { const knexConnection = await DomainTransaction.getConnection(); @@ -28,6 +32,22 @@ export const getByUserId = async ({ userId }) => { return profileRewards.map(toDomain); }; +/** + * @param {Object} args + * @param {string} args.attestationKey + * @param {Array} args.userIds + * @returns {Promise>} + */ +export const getByAttestationKeyAndUserIds = async ({ attestationKey, userIds }) => { + const knexConnection = await DomainTransaction.getConnection(); + const profileRewards = await knexConnection(PROFILE_REWARDS_TABLE_NAME) + .select(PROFILE_REWARDS_TABLE_NAME + '.*') + .join(ATTESTATIONS_TABLE_NAME, ATTESTATIONS_TABLE_NAME + '.id', PROFILE_REWARDS_TABLE_NAME + '.rewardId') + .whereIn('userId', userIds) + .where(ATTESTATIONS_TABLE_NAME + '.key', attestationKey); + return profileRewards.map(toDomain); +}; + const toDomain = (profileReward) => { return new ProfileReward(profileReward); }; diff --git a/api/src/profile/infrastructure/repositories/user-repository.js b/api/src/profile/infrastructure/repositories/user-repository.js new file mode 100644 index 00000000000..3b8f3b1f18f --- /dev/null +++ b/api/src/profile/infrastructure/repositories/user-repository.js @@ -0,0 +1,7 @@ +import { User } from '../../domain/models/User.js'; + +export async function getByIds({ userIds, usersApi }) { + const userDTOs = await usersApi.getUserDetailsByUserIds({ userIds }); + + return userDTOs.map((userDTO) => new User(userDTO)); +} diff --git a/api/src/profile/infrastructure/serializers/pdf/templates/sixth-grade-attestation-template.pdf b/api/src/profile/infrastructure/serializers/pdf/templates/sixth-grade-attestation-template.pdf new file mode 100644 index 00000000000..03e38fa43bf Binary files /dev/null and b/api/src/profile/infrastructure/serializers/pdf/templates/sixth-grade-attestation-template.pdf differ diff --git a/api/tests/profile/integration/domain/usecases/get-attestation-data-for-users_test.js b/api/tests/profile/integration/domain/usecases/get-attestation-data-for-users_test.js new file mode 100644 index 00000000000..8411e4d3c77 --- /dev/null +++ b/api/tests/profile/integration/domain/usecases/get-attestation-data-for-users_test.js @@ -0,0 +1,44 @@ +import { User } from '../../../../../src/profile/domain/models/User.js'; +import { usecases } from '../../../../../src/profile/domain/usecases/index.js'; +import { databaseBuilder, expect, sinon } from '../../../../test-helper.js'; + +describe('Profile | Integration | Domain | get-attestation-data-for-users', function () { + let clock; + const now = new Date('2022-12-25'); + + beforeEach(function () { + clock = sinon.useFakeTimers({ + now, + toFake: ['Date'], + }); + }); + + afterEach(async function () { + clock.restore(); + }); + + describe('#getAttestationDataForUsers', function () { + it('should return profile rewards', async function () { + const attestation = databaseBuilder.factory.buildAttestation(); + const firstUser = new User(databaseBuilder.factory.buildUser()); + const secondUser = new User(databaseBuilder.factory.buildUser()); + const firstCreatedAt = databaseBuilder.factory.buildProfileReward({ + rewardId: attestation.id, + userId: firstUser.id, + }).createdAt; + const secondCreatedAt = databaseBuilder.factory.buildProfileReward({ + rewardId: attestation.id, + userId: secondUser.id, + }).createdAt; + + await databaseBuilder.commit(); + + const results = await usecases.getAttestationDataForUsers({ + attestationKey: attestation.key, + userIds: [firstUser.id, secondUser.id], + }); + + expect(results).to.deep.equal([firstUser.toForm(firstCreatedAt), secondUser.toForm(secondCreatedAt)]); + }); + }); +}); 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 03fe7d1dea9..a6d7bc99052 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 @@ -1,6 +1,11 @@ import { PROFILE_REWARDS_TABLE_NAME } from '../../../../../db/migrations/20240820101213_add-profile-rewards-table.js'; +import { ATTESTATIONS } from '../../../../../src/profile/domain/constants.js'; import { ProfileReward } from '../../../../../src/profile/domain/models/ProfileReward.js'; -import { getByUserId, save } from '../../../../../src/profile/infrastructure/repositories/profile-reward-repository.js'; +import { + getByAttestationKeyAndUserIds, + getByUserId, + save, +} from '../../../../../src/profile/infrastructure/repositories/profile-reward-repository.js'; import { REWARD_TYPES } from '../../../../../src/quest/domain/constants.js'; import { databaseBuilder, expect, knex } from '../../../../test-helper.js'; @@ -40,8 +45,13 @@ describe('Profile | Integration | Repository | profile-reward', function () { eligibilityRequirements: {}, successRequirements: {}, }); + const otherAttestation = databaseBuilder.factory.buildAttestation({ + templateName: 'otherTemplateName', + key: 'otherKey', + }); const { rewardId: secondRewardId } = databaseBuilder.factory.buildQuest({ rewardType: REWARD_TYPES.ATTESTATION, + rewardId: otherAttestation.id, eligibilityRequirements: {}, successRequirements: {}, }); @@ -83,4 +93,90 @@ describe('Profile | Integration | Repository | profile-reward', function () { expect(result).to.be.empty; }); }); + + describe('#getByAttestationKeyAndUserIds', function () { + it('should return an empty array if there are no attestations for these users', async function () { + // given + const attestation = databaseBuilder.factory.buildAttestation(); + const user = databaseBuilder.factory.buildUser(); + await databaseBuilder.commit(); + + // when + const result = await getByAttestationKeyAndUserIds({ attestationKey: attestation.key, userIds: [user.id] }); + + // then + expect(result.length).to.equal(0); + }); + + it('should return all attestations for users', async function () { + // given + const attestation = databaseBuilder.factory.buildAttestation(); + const firstUser = databaseBuilder.factory.buildUser(); + const secondUser = databaseBuilder.factory.buildUser(); + const expectedProfileRewards = []; + expectedProfileRewards.push( + new ProfileReward( + databaseBuilder.factory.buildProfileReward({ rewardId: attestation.id, userId: firstUser.id }), + ), + ); + expectedProfileRewards.push( + new ProfileReward( + databaseBuilder.factory.buildProfileReward({ rewardId: attestation.id, userId: secondUser.id }), + ), + ); + await databaseBuilder.commit(); + + // when + const result = await getByAttestationKeyAndUserIds({ + attestationKey: attestation.key, + userIds: [firstUser.id, secondUser.id], + }); + + // then + expect(result).to.be.deep.equal(expectedProfileRewards); + expect(result[0]).to.be.an.instanceof(ProfileReward); + expect(result[1]).to.be.an.instanceof(ProfileReward); + }); + + it('should not return attestations of other users', async function () { + // given + const attestation = databaseBuilder.factory.buildAttestation(); + const firstUser = databaseBuilder.factory.buildUser(); + const secondUser = databaseBuilder.factory.buildUser(); + const expectedFirstUserProfileReward = []; + expectedFirstUserProfileReward.push( + new ProfileReward( + databaseBuilder.factory.buildProfileReward({ rewardId: attestation.id, userId: firstUser.id }), + ), + ); + databaseBuilder.factory.buildProfileReward({ rewardId: attestation.id, userId: secondUser.id }); + await databaseBuilder.commit(); + + // when + const result = await getByAttestationKeyAndUserIds({ + attestationKey: attestation.key, + userIds: [firstUser.id], + }); + + // then + expect(result).to.be.deep.equal(expectedFirstUserProfileReward); + }); + + it('should not return other attestations', async function () { + // given + const attestation = databaseBuilder.factory.buildAttestation({ key: ATTESTATIONS.SIXTH_GRADE }); + const firstUser = databaseBuilder.factory.buildUser(); + databaseBuilder.factory.buildProfileReward({ rewardId: attestation.id, userId: firstUser.id }); + await databaseBuilder.commit(); + + // when + const result = await getByAttestationKeyAndUserIds({ + attestationKey: 'SOME_KEY', + userIds: [firstUser.id], + }); + + // then + expect(result.length).to.equal(0); + }); + }); }); diff --git a/api/tests/profile/unit/application/api/attestations-api_test.js b/api/tests/profile/unit/application/api/attestations-api_test.js new file mode 100644 index 00000000000..46ea9846bda --- /dev/null +++ b/api/tests/profile/unit/application/api/attestations-api_test.js @@ -0,0 +1,32 @@ +import { generateAttestations } from '../../../../../src/profile/application/api/attestations-api.js'; +import { usecases } from '../../../../../src/profile/domain/usecases/index.js'; +import { expect, sinon } from '../../../../test-helper.js'; + +describe('Profile | Unit | Application | Api | attestations', function () { + describe('#generateAttestations', function () { + it('should return a zip archive with users attestations', async function () { + const attestationKey = Symbol('attestationKey'); + const userIds = Symbol('userIds'); + const templateName = 'templateName'; + const data = Symbol('data'); + const expectedBuffer = Symbol('expectedBuffer'); + + const dependencies = { + pdfWithFormSerializer: { + generate: sinon.stub(), + }, + }; + + sinon.stub(usecases, 'getAttestationDataForUsers'); + + usecases.getAttestationDataForUsers.withArgs({ attestationKey, userIds }).resolves({ templateName, data }); + + dependencies.pdfWithFormSerializer.generate + .withArgs(sinon.match(/(\w*\/)*templateName.pdf/), data) + .resolves(expectedBuffer); + const result = await generateAttestations({ attestationKey, userIds, dependencies }); + + expect(result).to.equal(expectedBuffer); + }); + }); +}); diff --git a/api/tests/profile/unit/domain/models/User_test.js b/api/tests/profile/unit/domain/models/User_test.js new file mode 100644 index 00000000000..f0eef54becb --- /dev/null +++ b/api/tests/profile/unit/domain/models/User_test.js @@ -0,0 +1,40 @@ +import { User } from '../../../../../src/profile/domain/models/User.js'; +import { expect, sinon } from '../../../../test-helper.js'; + +describe('Unit | Profile | Domain | Models | User', function () { + let clock; + const now = new Date('2022-12-25'); + + beforeEach(function () { + clock = sinon.useFakeTimers({ + now, + toFake: ['Date'], + }); + }); + + afterEach(async function () { + clock.restore(); + }); + + it('should return fullname', function () { + // when + const user = new User({ firstName: 'Théo', lastName: 'Courant' }); + + // then + expect(user.fullName).to.equal('Théo COURANT'); + }); + + it('should transform to form', function () { + // given + const user = new User({ firstName: 'Théo', lastName: 'Courant' }); + const date = new Date('2024-10-02'); + + // when + const form = user.toForm(date); + + // then + expect(form.get('fullName')).to.deep.equal(user.fullName); + expect(form.get('filename')).to.deep.equal(user.fullName + Date.now()); + expect(form.get('date')).to.deep.equal(date); + }); +}); diff --git a/api/tests/profile/unit/infrastructure/repositories/user-repository_test.js b/api/tests/profile/unit/infrastructure/repositories/user-repository_test.js new file mode 100644 index 00000000000..e19d62bd0f4 --- /dev/null +++ b/api/tests/profile/unit/infrastructure/repositories/user-repository_test.js @@ -0,0 +1,25 @@ +import { User } from '../../../../../src/profile/domain/models/User.js'; +import * as userRepository from '../../../../../src/profile/infrastructure/repositories/user-repository.js'; +import { expect, sinon } from '../../../../test-helper.js'; + +describe('Unit | Profile | Infrastructure | Repositories | UserRepository', function () { + it('should return users', async function () { + // given + const userIds = [1, 2]; + const usersApi = { + getUserDetailsByUserIds: sinon.stub(), + }; + const usersFromApi = [ + { id: 1, firstName: 'Théo', lastName: 'Courant' }, + { id: 2, firstName: 'Alex', lastName: 'Térieur' }, + ]; + const expectedUsers = usersFromApi.map((user) => new User(user)); + usersApi.getUserDetailsByUserIds.withArgs({ userIds }).resolves(usersFromApi); + + // when + const users = await userRepository.getByIds({ userIds, usersApi }); + + // then + expect(users).to.deep.equal(expectedUsers); + }); +});