Skip to content

Commit

Permalink
[FEATURE] Ajoute un endpoint pour récupérer les résultats de quêtes d…
Browse files Browse the repository at this point in the history
…ans le cadre d'une campagne (PIX-14837).

 #10332
  • Loading branch information
pix-service-auto-merge authored Oct 18, 2024
2 parents ef61d14 + 7cb510d commit 86ea1dc
Show file tree
Hide file tree
Showing 34 changed files with 791 additions and 16 deletions.
2 changes: 2 additions & 0 deletions api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { organizationLearnerRoutes } from './src/prescription/organization-learn
import { organizationPlaceRoutes } from './src/prescription/organization-place/routes.js';
import { targetProfileRoutes } from './src/prescription/target-profile/routes.js';
import { profileRoutes } from './src/profile/routes.js';
import { questRoutes } from './src/quest/routes.js';
import { schoolRoutes } from './src/school/routes.js';
import { config } from './src/shared/config.js';
import { monitoringTools } from './src/shared/infrastructure/monitoring-tools.js';
Expand Down Expand Up @@ -155,6 +156,7 @@ const setupRoutesAndPlugins = async function (server) {
organizationalEntitiesRoutes,
sharedRoutes,
profileRoutes,
questRoutes,
evaluationRoutes,
flashCertificationRoutes,
devcompRoutes,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HttpErrors } from '../../../shared/application/http-errors.js';
import {
CampaignCodeFormatError,
CampaignParticipationDoesNotBelongToUser,
CampaignUniqueCodeError,
IsForAbsoluteNoviceUpdateError,
MultipleSendingsUpdateError,
Expand Down Expand Up @@ -35,6 +36,10 @@ const campaignDomainErrorMappingConfiguration = [
name: CampaignCodeFormatError.name,
httpErrorFn: (error) => new HttpErrors.UnprocessableEntityError(error.message, error.code, error.meta),
},
{
name: CampaignParticipationDoesNotBelongToUser.name,
httpErrorFn: (error) => new HttpErrors.ForbiddenError(error.message, error.code, error.meta),
},
];

export { campaignDomainErrorMappingConfiguration };
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as campaignParticipationRepository from '../../../campaign-participation/infrastructure/repositories/campaign-participation-repository.js';
import { CampaignParticipationDoesNotBelongToUser } from '../../domain/errors.js';

const execute = async function ({
userId,
campaignParticipationId,
dependencies = { campaignParticipationRepository },
}) {
const campaignParticipation = await dependencies.campaignParticipationRepository.get(campaignParticipationId);
if (!campaignParticipation || campaignParticipation.userId !== userId) {
throw new CampaignParticipationDoesNotBelongToUser();
}
};

export { execute };
7 changes: 7 additions & 0 deletions api/src/prescription/campaign/domain/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,16 @@ class DeletedCampaignError extends DomainError {
}
}

class CampaignParticipationDoesNotBelongToUser extends DomainError {
constructor(message = "La participation n'est pas liée à l'utilisateur") {
super(message);
}
}

export {
ArchivedCampaignError,
CampaignCodeFormatError,
CampaignParticipationDoesNotBelongToUser,
CampaignUniqueCodeError,
DeletedCampaignError,
IsForAbsoluteNoviceUpdateError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ export class OrganizationLearnerWithParticipations {
MEFCode: organizationLearner.MEFCode,
};
this.organization = {
id: organization.id,
isManagingStudents: organization.isManagingStudents,
tags: tagNames,
type: organization.type,
};
this.campaignParticipations = campaignParticipations.map(({ targetProfileId }) => ({ targetProfileId }));
this.campaignParticipations = campaignParticipations.map(({ id, targetProfileId }) => ({ id, targetProfileId }));
}
}
5 changes: 5 additions & 0 deletions api/src/profile/application/api/reward-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { usecases } from '../../domain/usecases/index.js';

export const getByIdAndType = ({ rewardId, rewardType }) => {
return usecases.getRewardByIdAndType({ rewardId, rewardType });
};
8 changes: 7 additions & 1 deletion api/src/profile/domain/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ class AttestationNotFoundError extends DomainError {
}
}

export { AttestationNotFoundError };
class RewardTypeDoesNotExistError extends DomainError {
constructor(message = 'Reward Type does not exist') {
super(message);
}
}

export { AttestationNotFoundError, RewardTypeDoesNotExistError };
3 changes: 3 additions & 0 deletions api/src/profile/domain/usecases/get-reward-by-id-and-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const getRewardByIdAndType = ({ rewardId, rewardType, rewardRepository }) => {
return rewardRepository.getByIdAndType({ rewardId, rewardType });
};
2 changes: 2 additions & 0 deletions api/src/profile/domain/usecases/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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 rewardRepository from '../../infrastructure/repositories/reward-repository.js';

const path = dirname(fileURLToPath(import.meta.url));

Expand All @@ -25,6 +26,7 @@ const dependencies = {
profileRewardRepository,
userRepository: repositories.userRepository,
attestationRepository,
rewardRepository,
};

const usecases = injectDependencies(usecasesWithoutInjectedDependencies, dependencies);
Expand Down
16 changes: 16 additions & 0 deletions api/src/profile/infrastructure/repositories/reward-repository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { knex } from '../../../../db/knex-database-connection.js';
import { REWARD_TYPES } from '../../../quest/domain/constants.js';
import { RewardTypeDoesNotExistError } from '../../domain/errors.js';
import { Attestation } from '../../domain/models/Attestation.js';

export const getByIdAndType = async ({ rewardId, rewardType }) => {
try {
const result = await knex(rewardType).where({ id: rewardId }).first();
switch (rewardType) {
case REWARD_TYPES.ATTESTATION:
return new Attestation(result);
}
} catch (error) {
throw new RewardTypeDoesNotExistError(error);
}
};
34 changes: 34 additions & 0 deletions api/src/quest/application/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Joi from 'joi';

import { securityPreHandlers } from '../../shared/application/security-pre-handlers.js';
import { identifiersType } from '../../shared/domain/types/identifiers-type.js';
import { questController } from './quest-controller.js';

const register = async function (server) {
server.route([
{
method: 'GET',
path: '/api/campaign-participations/{campaignParticipationId}/quest-results',
config: {
pre: [
{
method: securityPreHandlers.checkCampaignParticipationBelongsToUser,
},
],
handler: questController.getQuestResults,
validate: {
params: Joi.object({
campaignParticipationId: identifiersType.campaignParticipationId,
}),
},
notes: [
'- **Route nécessitant une authentification**\n' +
"- Récupère le résultat d'une quête pour une participation et un user donné",
],
tags: ['api', 'quest'],
},
},
]);
};
const name = 'quest-api';
export { name, register };
20 changes: 20 additions & 0 deletions api/src/quest/application/quest-controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as requestResponseUtils from '../../shared/infrastructure/utils/request-response-utils.js';
import { usecases } from '../domain/usecases/index.js';
import * as questResultSerializer from '../infrastructure/serializers/quest-result-serializer.js';

const getQuestResults = async function (request, h, dependencies = { questResultSerializer, requestResponseUtils }) {
const { campaignParticipationId } = request.params;
const userId = dependencies.requestResponseUtils.extractUserIdFromRequest(request);

const questResults = await usecases.getQuestResultsForCampaignParticipation({ userId, campaignParticipationId });

const serializedQuestResults = dependencies.questResultSerializer.serialize(questResults);

return h.response(serializedQuestResults);
};

const questController = {
getQuestResults,
};

export { questController };
26 changes: 24 additions & 2 deletions api/src/quest/domain/models/Eligibility.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
export class Eligibility {
#campaignParticipations;

constructor({ organizationLearner, organization, campaignParticipations = [] }) {
this.organizationLearner = {
MEFCode: organizationLearner?.MEFCode,
};
this.organization = organization;
this.campaignParticipations = {
targetProfileIds: campaignParticipations.map(({ targetProfileId }) => targetProfileId),
this.#campaignParticipations = campaignParticipations;
}

get campaignParticipations() {
return {
targetProfileIds: this.#campaignParticipations.map(({ targetProfileId }) => targetProfileId),
};
}

hasCampaignParticipation(campaignParticipationId) {
return Boolean(
this.#campaignParticipations.find(
(campaignParticipation) => campaignParticipation.id === campaignParticipationId,
),
);
}

getTargetProfileForCampaignParticipation(campaignParticipationId) {
const campaignParticipation = this.#campaignParticipations.find(
(campaignParticipation) => campaignParticipation.id === campaignParticipationId,
);

return campaignParticipation?.targetProfileId ?? null;
}
}
7 changes: 7 additions & 0 deletions api/src/quest/domain/models/QuestResult.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class QuestResult {
constructor({ id, obtained, reward }) {
this.id = id;
this.obtained = obtained;
this.reward = reward;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export const getQuestResultsForCampaignParticipation = async ({
userId,
campaignParticipationId,
questRepository,
eligibilityRepository,
rewardRepository,
}) => {
const quests = await questRepository.findAll();

if (quests.length === 0) {
return [];
}

const eligibilities = await eligibilityRepository.find({ userId });
const eligibility = eligibilities.find((e) => e.hasCampaignParticipation(campaignParticipationId));

if (!eligibility) return [];

eligibility.campaignParticipations.targetProfileIds = [
// getTargetProfileForCampaignParticipation returns null but this usecase is used for campaign participation result page for now, so the campaign participation ID always exists
// if this usecase is to be used in another context, the edge case must be handled
eligibility.getTargetProfileForCampaignParticipation(campaignParticipationId),
];

const questResults = [];
for (const quest of quests) {
const isEligibleForQuest = quest.isEligible(eligibility);

if (!isEligibleForQuest) continue;

const questResult = await rewardRepository.getByQuestAndUserId({ userId, quest });
questResults.push(questResult);
}

return questResults;
};
6 changes: 6 additions & 0 deletions api/src/quest/infrastructure/repositories/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import * as knowledgeElementsApi from '../../../evaluation/application/api/knowledge-elements-api.js';
import * as organizationLearnerWithParticipationApi from '../../../prescription/organization-learner/application/api/organization-learners-with-participations-api.js';
import * as profileRewardApi from '../../../profile/application/api/profile-reward-api.js';
import * as rewardApi from '../../../profile/application/api/reward-api.js';
import { temporaryStorage } from '../../../shared/infrastructure/temporary-storage/index.js';
import { injectDependencies } from '../../../shared/infrastructure/utils/dependency-injection.js';
import * as eligibilityRepository from './eligibility-repository.js';
import * as rewardRepository from './reward-repository.js';
import * as successRepository from './success-repository.js';

const profileRewardTemporaryStorage = temporaryStorage.withPrefix('profile-rewards:');

const repositoriesWithoutInjectedDependencies = {
eligibilityRepository,
successRepository,
Expand All @@ -16,6 +20,8 @@ const dependencies = {
organizationLearnerWithParticipationApi,
knowledgeElementsApi,
profileRewardApi,
profileRewardTemporaryStorage,
rewardApi,
};

const repositories = injectDependencies(repositoriesWithoutInjectedDependencies, dependencies);
Expand Down
39 changes: 39 additions & 0 deletions api/src/quest/infrastructure/repositories/reward-repository.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
import { QuestResult } from '../../domain/models/QuestResult.js';

export const reward = async ({ userId, rewardId, profileRewardApi }) => {
return profileRewardApi.save(userId, rewardId);
};

export const getByUserId = async ({ userId, profileRewardApi }) => {
return profileRewardApi.getByUserId(userId);
};

export const getByQuestAndUserId = async ({
userId,
quest,
rewardApi,
profileRewardApi,
profileRewardTemporaryStorage,
}) => {
const reward = await rewardApi.getByIdAndType({ rewardId: quest.rewardId, rewardType: quest.rewardType });
const profileRewards = await profileRewardApi.getByUserId(userId);

const profileRewardForQuest = profileRewards.find(
(profileReward) => profileReward.rewardType === quest.rewardType && profileReward.rewardId === quest.rewardId,
);

if (profileRewardForQuest) {
return new QuestResult({
id: quest.id,
obtained: true,
reward,
});
}

let obtained = false;

const isProcessing = Number(await profileRewardTemporaryStorage.get(userId)) > 0;

if (isProcessing) {
obtained = null;
}

return new QuestResult({
id: quest.id,
obtained,
reward,
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import jsonapiSerializer from 'jsonapi-serializer';

const { Serializer } = jsonapiSerializer;

const serialize = function (questResult) {
return new Serializer('quest-result', {
attributes: ['obtained', 'reward'],
}).serialize(questResult);
};

export { serialize };
5 changes: 5 additions & 0 deletions api/src/quest/routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as questRoute from './application/index.js';

const questRoutes = [questRoute];

export { questRoutes };
Loading

0 comments on commit 86ea1dc

Please sign in to comment.