Skip to content

Commit

Permalink
[FEATURE] Ajoute un endpoint pour supprimer des campagnes d'une organ…
Browse files Browse the repository at this point in the history
…isation (PIX-12689)

 #9383
  • Loading branch information
pix-service-auto-merge committed Jul 4, 2024
2 parents 54dbd96 + a7aeb54 commit bf60481
Show file tree
Hide file tree
Showing 24 changed files with 979 additions and 271 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ class CampaignParticipation {
this.assessments = assessments;
this.userId = userId;
this.status = status;
this.validatedSkillsCount = validatedSkillsCount;
this.pixScore = pixScore;
this.validatedSkillsCount = validatedSkillsCount || null;
this.pixScore = pixScore || null;
this.organizationLearnerId = organizationLearnerId;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import lodash from 'lodash';

import { knex } from '../../../../../db/knex-database-connection.js';
import { NotFoundError } from '../../../../../lib/domain/errors.js';
import { Campaign } from '../../../../../lib/domain/models/Campaign.js';
Expand All @@ -9,6 +11,21 @@ import { ApplicationTransaction } from '../../../shared/infrastructure/Applicati
import { CampaignParticipation } from '../../domain/models/CampaignParticipation.js';
import { AvailableCampaignParticipation } from '../../domain/read-models/AvailableCampaignParticipation.js';

const { pick } = lodash;

import { CampaignParticipationStatuses } from '../../../shared/domain/constants.js';

const CAMPAIGN_PARTICIPATION_ATTRIBUTES = [
'participantExternalId',
'sharedAt',
'status',
'campaignId',
'userId',
'organizationLearnerId',
'deletedAt',
'deletedBy',
];

const updateWithSnapshot = async function (campaignParticipation) {
const domainTransaction = ApplicationTransaction.getTransactionAsDomainTransaction();
await this.update(campaignParticipation);
Expand All @@ -29,16 +46,13 @@ const updateWithSnapshot = async function (campaignParticipation) {
const update = async function (campaignParticipation, domainTransaction) {
const knexConn = ApplicationTransaction.getConnection(domainTransaction);

const attributes = {
participantExternalId: campaignParticipation.participantExternalId,
sharedAt: campaignParticipation.sharedAt,
status: campaignParticipation.status,
campaignId: campaignParticipation.campaignId,
userId: campaignParticipation.userId,
organizationLearnerId: campaignParticipation.organizationLearnerId,
};
await knexConn('campaign-participations')
.where({ id: campaignParticipation.id })
.update(pick(campaignParticipation, CAMPAIGN_PARTICIPATION_ATTRIBUTES));
};

await knexConn('campaign-participations').where({ id: campaignParticipation.id }).update(attributes);
const batchUpdate = async function (campaignParticipations) {
return Promise.all(campaignParticipations.map((campaignParticipation) => update(campaignParticipation)));
};

const get = async function (id, domainTransaction) {
Expand All @@ -55,6 +69,14 @@ const get = async function (id, domainTransaction) {
});
};

const getByCampaignIds = async function (campaignIds) {
const knexConn = ApplicationTransaction.getConnection();
const campaignParticipations = await knexConn('campaign-participations')
.whereNull('deletedAt')
.whereIn('campaignId', campaignIds);
return campaignParticipations.map((campaignParticipation) => new CampaignParticipation(campaignParticipation));
};

const getAllCampaignParticipationsInCampaignForASameLearner = async function ({ campaignId, campaignParticipationId }) {
const knexConn = DomainTransaction.getConnection();
const result = await knexConn('campaign-participations')
Expand Down Expand Up @@ -99,9 +121,53 @@ const remove = async function ({ id, deletedAt, deletedBy }) {
return await knexConn('campaign-participations').where({ id }).update({ deletedAt, deletedBy });
};

const findProfilesCollectionResultDataByCampaignId = async function (campaignId) {
const results = await knex('campaign-participations')
.select([
'campaign-participations.*',
'view-active-organization-learners.studentNumber',
'view-active-organization-learners.division',
'view-active-organization-learners.group',
'view-active-organization-learners.firstName',
'view-active-organization-learners.lastName',
])
.join(
'view-active-organization-learners',
'view-active-organization-learners.id',
'campaign-participations.organizationLearnerId',
)
.where({ campaignId, 'campaign-participations.deletedAt': null })
.orderBy('lastName', 'ASC')
.orderBy('firstName', 'ASC')
.orderBy('createdAt', 'DESC');

return results.map(_rowToResult);
};

function _rowToResult(row) {
return {
id: row.id,
createdAt: new Date(row.createdAt),
isShared: row.status === CampaignParticipationStatuses.SHARED,
sharedAt: row.sharedAt ? new Date(row.sharedAt) : null,
participantExternalId: row.participantExternalId,
userId: row.userId,
isCompleted: row.state === 'completed',
studentNumber: row.studentNumber,
participantFirstName: row.firstName,
participantLastName: row.lastName,
division: row.division,
pixScore: row.pixScore,
group: row.group,
};
}

export {
batchUpdate,
findProfilesCollectionResultDataByCampaignId,
get,
getAllCampaignParticipationsInCampaignForASameLearner,
getByCampaignIds,
getCampaignParticipationsForOrganizationLearner,
remove,
update,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,34 @@ const register = async function (server) {
],
},
},
{
method: 'DELETE',
path: '/api/organizations/{organizationId}/campaigns',
config: {
pre: [
{
method: securityPreHandlers.checkUserBelongsToOrganization,
},
],
validate: {
params: Joi.object({
organizationId: identifiersType.organizationId,
}),
payload: Joi.object({
data: Joi.array()
.required()
.items(Joi.object({ type: Joi.string().required(), id: identifiersType.campaignId })),
}),
},
handler: campaignAdministrationController.deleteCampaigns,
notes: [
'- **Cette route est restreinte aux utilisateurs authentifiés**\n' +
"- Suppression d'une ou plusieurs campagne(s)\n" +
'- L‘utilisateur doit appartenir à l‘organisation',
],
tags: ['api', 'orga', 'campaign'],
},
},
]);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as csvSerializer from '../../../../lib/infrastructure/serializers/csv/c
import { usecases } from '../../../../src/prescription/campaign/domain/usecases/index.js';
import * as queryParamsUtils from '../../../shared/infrastructure/utils/query-params-utils.js';
import * as requestResponseUtils from '../../../shared/infrastructure/utils/request-response-utils.js';
import { extractUserIdFromRequest } from '../../../shared/infrastructure/utils/request-response-utils.js';
import * as csvCampaignsIdsParser from '../infrastructure/serializers/csv/csv-campaigns-ids-parser.js';
import * as campaignManagementSerializer from '../infrastructure/serializers/jsonapi/campaign-management-serializer.js';
import * as campaignReportSerializer from '../infrastructure/serializers/jsonapi/campaign-report-serializer.js';
Expand Down Expand Up @@ -133,6 +134,16 @@ const findPaginatedCampaignManagements = async function (
return dependencies.campaignManagementSerializer.serialize(campaigns, meta);
};

const deleteCampaigns = async function (request, h) {
const userId = extractUserIdFromRequest(request);
const { organizationId } = request.params;
const campaignIds = request.deserializedPayload.map(({ id }) => id);

await usecases.deleteCampaigns({ userId, organizationId, campaignIds });

return h.response(null).code(204);
};

const campaignAdministrationController = {
save,
update,
Expand All @@ -144,6 +155,7 @@ const campaignAdministrationController = {
archiveCampaign,
archiveCampaigns,
unarchiveCampaign,
deleteCampaigns,
};

export { campaignAdministrationController };
Expand Down
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 @@ -46,10 +46,17 @@ class ArchivedCampaignError extends DomainError {
}
}

class DeletedCampaignError extends DomainError {
constructor(message = 'Cette campagne est déjà supprimée.') {
super(message);
}
}

export {
ArchivedCampaignError,
CampaignCodeFormatError,
CampaignUniqueCodeError,
DeletedCampaignError,
IsForAbsoluteNoviceUpdateError,
MultipleSendingsUpdateError,
SwapCampaignMismatchOrganizationError,
Expand Down
31 changes: 28 additions & 3 deletions api/src/prescription/campaign/domain/models/Campaign.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { ObjectValidationError } from '../../../../../lib/domain/errors.js';
import { CampaignTypes } from '../../../shared/domain/constants.js';
import { ArchivedCampaignError } from '../errors.js';
import { CampaignCodeFormatError, IsForAbsoluteNoviceUpdateError, MultipleSendingsUpdateError } from '../errors.js';
import {
ArchivedCampaignError,
CampaignCodeFormatError,
DeletedCampaignError,
IsForAbsoluteNoviceUpdateError,
MultipleSendingsUpdateError,
} from '../errors.js';

class Campaign {
constructor({
Expand All @@ -27,6 +32,8 @@ class Campaign {
ownerId,
archivedAt,
archivedBy,
deletedAt = null,
deletedBy = null,
participationCount,
} = {}) {
this.id = id;
Expand All @@ -52,6 +59,8 @@ class Campaign {
this.createdAt = createdAt;
this.archivedAt = archivedAt;
this.archivedBy = archivedBy;
this.deletedAt = deletedAt;
this.deletedBy = deletedBy;
this.hasParticipation = participationCount > 0;
}

Expand All @@ -67,9 +76,25 @@ class Campaign {
return Boolean(this.archivedAt);
}

get isDeleted() {
return Boolean(this.deletedAt);
}

delete(userId) {
if (this.deletedAt) {
throw new DeletedCampaignError();
}
if (!userId) {
throw new ObjectValidationError('userId Missing');
}

this.deletedAt = new Date();
this.deletedBy = userId;
}

archive(archivedAt, archivedBy) {
if (this.archivedAt) {
throw new ArchivedCampaignError('Campaign Already Archived');
throw new ArchivedCampaignError();
}
if (!archivedAt) {
throw new ObjectValidationError('ArchivedAt Missing');
Expand Down
59 changes: 59 additions & 0 deletions api/src/prescription/campaign/domain/models/CampaignsDestructor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ObjectValidationError } from '../../../../../lib/domain/errors.js';

/**
* @typedef {import ('./Campaign.js').Campaign} Campaign
* @typedef {import ('../../../campaign-participation/domain/models/CampaignParticipation.js').CampaignParticipation} CampaignParticipation
* @typedef {import ('../read-models/OrganizationMembership.js').OrganizationMembership} OrganizationMembership
*/

class CampaignsDestructor {
#campaignsToDelete;
#campaignParticipationsToDelete;
#userId;
#organizationId;
#membership;

/**
* @param {Object} params
* @param {Array<Campaign>} params.campaignsToDelete - campaigns object to be deleted
* @param {Array<CampaignParticipation>} params.campaignParticipationsToDelete - campaigns participations object to be deleted
* @param {number} params.userId - userId for deletedBy
* @param {number} params.organizationId - organizationId to check if campaigns belongs to given organizationId
* @param {OrganizationMembership} params.membership - class with property isAdmin to check is user is admin in organization or not
*/
constructor({ campaignsToDelete, campaignParticipationsToDelete, userId, organizationId, membership }) {
this.#campaignsToDelete = campaignsToDelete;
this.#campaignParticipationsToDelete = campaignParticipationsToDelete;
this.#userId = userId;
this.#organizationId = organizationId;
this.#membership = membership;
this.#validate();
}

#validate() {
const isUserOwnerOfAllCampaigns = this.#campaignsToDelete.every((campaign) => campaign.ownerId === this.#userId);
const isAllCampaignsBelongsToOrganization = this.#campaignsToDelete.every(
(campaign) => campaign.organizationId === this.#organizationId,
);

if (!isAllCampaignsBelongsToOrganization)
throw new ObjectValidationError('Some campaigns does not belong to organization.');
if (!this.#membership.isAdmin && !isUserOwnerOfAllCampaigns)
throw new ObjectValidationError('User does not have right to delete some campaigns.');
}

delete() {
this.#campaignParticipationsToDelete.forEach((campaignParticipation) => campaignParticipation.delete(this.#userId));
this.#campaignsToDelete.forEach((campaign) => campaign.delete(this.#userId));
}

get campaignParticipations() {
return this.#campaignParticipationsToDelete;
}

get campaigns() {
return this.#campaignsToDelete;
}
}

export { CampaignsDestructor };
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class OrganizationMembership {
constructor({ isAdmin } = {}) {
this.isAdmin = isAdmin;
}
}

export { OrganizationMembership };
28 changes: 28 additions & 0 deletions api/src/prescription/campaign/domain/usecases/delete-campaigns.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { CampaignsDestructor } from '../models/CampaignsDestructor.js';

const deleteCampaigns = async ({
userId,
organizationId,
campaignIds,
organizationMembershipRepository,
campaignAdministrationRepository,
campaignParticipationRepository,
}) => {
const membership = await organizationMembershipRepository.getByUserIdAndOrganizationId({ userId, organizationId });
const campaignsToDelete = await campaignAdministrationRepository.getByIds(campaignIds);
const campaignParticipationsToDelete = await campaignParticipationRepository.getByCampaignIds(campaignIds);

const campaignDestructor = new CampaignsDestructor({
campaignsToDelete,
campaignParticipationsToDelete,
userId,
organizationId,
membership,
});
campaignDestructor.delete();

await campaignParticipationRepository.batchUpdate(campaignParticipationsToDelete);
await campaignAdministrationRepository.batchUpdate(campaignsToDelete);
};

export { deleteCampaigns };
Loading

0 comments on commit bf60481

Please sign in to comment.