Skip to content

Commit bf60481

Browse files
[FEATURE] Ajoute un endpoint pour supprimer des campagnes d'une organisation (PIX-12689)
#9383
2 parents 54dbd96 + a7aeb54 commit bf60481

24 files changed

+979
-271
lines changed

api/src/prescription/campaign-participation/domain/models/CampaignParticipation.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ class CampaignParticipation {
3636
this.assessments = assessments;
3737
this.userId = userId;
3838
this.status = status;
39-
this.validatedSkillsCount = validatedSkillsCount;
40-
this.pixScore = pixScore;
39+
this.validatedSkillsCount = validatedSkillsCount || null;
40+
this.pixScore = pixScore || null;
4141
this.organizationLearnerId = organizationLearnerId;
4242
}
4343

api/src/prescription/campaign-participation/infrastructure/repositories/campaign-participation-repository.js

+75-9
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import lodash from 'lodash';
2+
13
import { knex } from '../../../../../db/knex-database-connection.js';
24
import { NotFoundError } from '../../../../../lib/domain/errors.js';
35
import { Campaign } from '../../../../../lib/domain/models/Campaign.js';
@@ -9,6 +11,21 @@ import { ApplicationTransaction } from '../../../shared/infrastructure/Applicati
911
import { CampaignParticipation } from '../../domain/models/CampaignParticipation.js';
1012
import { AvailableCampaignParticipation } from '../../domain/read-models/AvailableCampaignParticipation.js';
1113

14+
const { pick } = lodash;
15+
16+
import { CampaignParticipationStatuses } from '../../../shared/domain/constants.js';
17+
18+
const CAMPAIGN_PARTICIPATION_ATTRIBUTES = [
19+
'participantExternalId',
20+
'sharedAt',
21+
'status',
22+
'campaignId',
23+
'userId',
24+
'organizationLearnerId',
25+
'deletedAt',
26+
'deletedBy',
27+
];
28+
1229
const updateWithSnapshot = async function (campaignParticipation) {
1330
const domainTransaction = ApplicationTransaction.getTransactionAsDomainTransaction();
1431
await this.update(campaignParticipation);
@@ -29,16 +46,13 @@ const updateWithSnapshot = async function (campaignParticipation) {
2946
const update = async function (campaignParticipation, domainTransaction) {
3047
const knexConn = ApplicationTransaction.getConnection(domainTransaction);
3148

32-
const attributes = {
33-
participantExternalId: campaignParticipation.participantExternalId,
34-
sharedAt: campaignParticipation.sharedAt,
35-
status: campaignParticipation.status,
36-
campaignId: campaignParticipation.campaignId,
37-
userId: campaignParticipation.userId,
38-
organizationLearnerId: campaignParticipation.organizationLearnerId,
39-
};
49+
await knexConn('campaign-participations')
50+
.where({ id: campaignParticipation.id })
51+
.update(pick(campaignParticipation, CAMPAIGN_PARTICIPATION_ATTRIBUTES));
52+
};
4053

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

4458
const get = async function (id, domainTransaction) {
@@ -55,6 +69,14 @@ const get = async function (id, domainTransaction) {
5569
});
5670
};
5771

72+
const getByCampaignIds = async function (campaignIds) {
73+
const knexConn = ApplicationTransaction.getConnection();
74+
const campaignParticipations = await knexConn('campaign-participations')
75+
.whereNull('deletedAt')
76+
.whereIn('campaignId', campaignIds);
77+
return campaignParticipations.map((campaignParticipation) => new CampaignParticipation(campaignParticipation));
78+
};
79+
5880
const getAllCampaignParticipationsInCampaignForASameLearner = async function ({ campaignId, campaignParticipationId }) {
5981
const knexConn = DomainTransaction.getConnection();
6082
const result = await knexConn('campaign-participations')
@@ -99,9 +121,53 @@ const remove = async function ({ id, deletedAt, deletedBy }) {
99121
return await knexConn('campaign-participations').where({ id }).update({ deletedAt, deletedBy });
100122
};
101123

124+
const findProfilesCollectionResultDataByCampaignId = async function (campaignId) {
125+
const results = await knex('campaign-participations')
126+
.select([
127+
'campaign-participations.*',
128+
'view-active-organization-learners.studentNumber',
129+
'view-active-organization-learners.division',
130+
'view-active-organization-learners.group',
131+
'view-active-organization-learners.firstName',
132+
'view-active-organization-learners.lastName',
133+
])
134+
.join(
135+
'view-active-organization-learners',
136+
'view-active-organization-learners.id',
137+
'campaign-participations.organizationLearnerId',
138+
)
139+
.where({ campaignId, 'campaign-participations.deletedAt': null })
140+
.orderBy('lastName', 'ASC')
141+
.orderBy('firstName', 'ASC')
142+
.orderBy('createdAt', 'DESC');
143+
144+
return results.map(_rowToResult);
145+
};
146+
147+
function _rowToResult(row) {
148+
return {
149+
id: row.id,
150+
createdAt: new Date(row.createdAt),
151+
isShared: row.status === CampaignParticipationStatuses.SHARED,
152+
sharedAt: row.sharedAt ? new Date(row.sharedAt) : null,
153+
participantExternalId: row.participantExternalId,
154+
userId: row.userId,
155+
isCompleted: row.state === 'completed',
156+
studentNumber: row.studentNumber,
157+
participantFirstName: row.firstName,
158+
participantLastName: row.lastName,
159+
division: row.division,
160+
pixScore: row.pixScore,
161+
group: row.group,
162+
};
163+
}
164+
102165
export {
166+
batchUpdate,
167+
findProfilesCollectionResultDataByCampaignId,
103168
get,
104169
getAllCampaignParticipationsInCampaignForASameLearner,
170+
getByCampaignIds,
105171
getCampaignParticipationsForOrganizationLearner,
106172
remove,
107173
update,

api/src/prescription/campaign/application/campaign-administration-route.js

+28
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,34 @@ const register = async function (server) {
291291
],
292292
},
293293
},
294+
{
295+
method: 'DELETE',
296+
path: '/api/organizations/{organizationId}/campaigns',
297+
config: {
298+
pre: [
299+
{
300+
method: securityPreHandlers.checkUserBelongsToOrganization,
301+
},
302+
],
303+
validate: {
304+
params: Joi.object({
305+
organizationId: identifiersType.organizationId,
306+
}),
307+
payload: Joi.object({
308+
data: Joi.array()
309+
.required()
310+
.items(Joi.object({ type: Joi.string().required(), id: identifiersType.campaignId })),
311+
}),
312+
},
313+
handler: campaignAdministrationController.deleteCampaigns,
314+
notes: [
315+
'- **Cette route est restreinte aux utilisateurs authentifiés**\n' +
316+
"- Suppression d'une ou plusieurs campagne(s)\n" +
317+
'- L‘utilisateur doit appartenir à l‘organisation',
318+
],
319+
tags: ['api', 'orga', 'campaign'],
320+
},
321+
},
294322
]);
295323
};
296324

api/src/prescription/campaign/application/campaign-adminstration-controller.js

+12
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as csvSerializer from '../../../../lib/infrastructure/serializers/csv/c
55
import { usecases } from '../../../../src/prescription/campaign/domain/usecases/index.js';
66
import * as queryParamsUtils from '../../../shared/infrastructure/utils/query-params-utils.js';
77
import * as requestResponseUtils from '../../../shared/infrastructure/utils/request-response-utils.js';
8+
import { extractUserIdFromRequest } from '../../../shared/infrastructure/utils/request-response-utils.js';
89
import * as csvCampaignsIdsParser from '../infrastructure/serializers/csv/csv-campaigns-ids-parser.js';
910
import * as campaignManagementSerializer from '../infrastructure/serializers/jsonapi/campaign-management-serializer.js';
1011
import * as campaignReportSerializer from '../infrastructure/serializers/jsonapi/campaign-report-serializer.js';
@@ -133,6 +134,16 @@ const findPaginatedCampaignManagements = async function (
133134
return dependencies.campaignManagementSerializer.serialize(campaigns, meta);
134135
};
135136

137+
const deleteCampaigns = async function (request, h) {
138+
const userId = extractUserIdFromRequest(request);
139+
const { organizationId } = request.params;
140+
const campaignIds = request.deserializedPayload.map(({ id }) => id);
141+
142+
await usecases.deleteCampaigns({ userId, organizationId, campaignIds });
143+
144+
return h.response(null).code(204);
145+
};
146+
136147
const campaignAdministrationController = {
137148
save,
138149
update,
@@ -144,6 +155,7 @@ const campaignAdministrationController = {
144155
archiveCampaign,
145156
archiveCampaigns,
146157
unarchiveCampaign,
158+
deleteCampaigns,
147159
};
148160

149161
export { campaignAdministrationController };

api/src/prescription/campaign/domain/errors.js

+7
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,17 @@ class ArchivedCampaignError extends DomainError {
4646
}
4747
}
4848

49+
class DeletedCampaignError extends DomainError {
50+
constructor(message = 'Cette campagne est déjà supprimée.') {
51+
super(message);
52+
}
53+
}
54+
4955
export {
5056
ArchivedCampaignError,
5157
CampaignCodeFormatError,
5258
CampaignUniqueCodeError,
59+
DeletedCampaignError,
5360
IsForAbsoluteNoviceUpdateError,
5461
MultipleSendingsUpdateError,
5562
SwapCampaignMismatchOrganizationError,

api/src/prescription/campaign/domain/models/Campaign.js

+28-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { ObjectValidationError } from '../../../../../lib/domain/errors.js';
22
import { CampaignTypes } from '../../../shared/domain/constants.js';
3-
import { ArchivedCampaignError } from '../errors.js';
4-
import { CampaignCodeFormatError, IsForAbsoluteNoviceUpdateError, MultipleSendingsUpdateError } from '../errors.js';
3+
import {
4+
ArchivedCampaignError,
5+
CampaignCodeFormatError,
6+
DeletedCampaignError,
7+
IsForAbsoluteNoviceUpdateError,
8+
MultipleSendingsUpdateError,
9+
} from '../errors.js';
510

611
class Campaign {
712
constructor({
@@ -27,6 +32,8 @@ class Campaign {
2732
ownerId,
2833
archivedAt,
2934
archivedBy,
35+
deletedAt = null,
36+
deletedBy = null,
3037
participationCount,
3138
} = {}) {
3239
this.id = id;
@@ -52,6 +59,8 @@ class Campaign {
5259
this.createdAt = createdAt;
5360
this.archivedAt = archivedAt;
5461
this.archivedBy = archivedBy;
62+
this.deletedAt = deletedAt;
63+
this.deletedBy = deletedBy;
5564
this.hasParticipation = participationCount > 0;
5665
}
5766

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

79+
get isDeleted() {
80+
return Boolean(this.deletedAt);
81+
}
82+
83+
delete(userId) {
84+
if (this.deletedAt) {
85+
throw new DeletedCampaignError();
86+
}
87+
if (!userId) {
88+
throw new ObjectValidationError('userId Missing');
89+
}
90+
91+
this.deletedAt = new Date();
92+
this.deletedBy = userId;
93+
}
94+
7095
archive(archivedAt, archivedBy) {
7196
if (this.archivedAt) {
72-
throw new ArchivedCampaignError('Campaign Already Archived');
97+
throw new ArchivedCampaignError();
7398
}
7499
if (!archivedAt) {
75100
throw new ObjectValidationError('ArchivedAt Missing');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { ObjectValidationError } from '../../../../../lib/domain/errors.js';
2+
3+
/**
4+
* @typedef {import ('./Campaign.js').Campaign} Campaign
5+
* @typedef {import ('../../../campaign-participation/domain/models/CampaignParticipation.js').CampaignParticipation} CampaignParticipation
6+
* @typedef {import ('../read-models/OrganizationMembership.js').OrganizationMembership} OrganizationMembership
7+
*/
8+
9+
class CampaignsDestructor {
10+
#campaignsToDelete;
11+
#campaignParticipationsToDelete;
12+
#userId;
13+
#organizationId;
14+
#membership;
15+
16+
/**
17+
* @param {Object} params
18+
* @param {Array<Campaign>} params.campaignsToDelete - campaigns object to be deleted
19+
* @param {Array<CampaignParticipation>} params.campaignParticipationsToDelete - campaigns participations object to be deleted
20+
* @param {number} params.userId - userId for deletedBy
21+
* @param {number} params.organizationId - organizationId to check if campaigns belongs to given organizationId
22+
* @param {OrganizationMembership} params.membership - class with property isAdmin to check is user is admin in organization or not
23+
*/
24+
constructor({ campaignsToDelete, campaignParticipationsToDelete, userId, organizationId, membership }) {
25+
this.#campaignsToDelete = campaignsToDelete;
26+
this.#campaignParticipationsToDelete = campaignParticipationsToDelete;
27+
this.#userId = userId;
28+
this.#organizationId = organizationId;
29+
this.#membership = membership;
30+
this.#validate();
31+
}
32+
33+
#validate() {
34+
const isUserOwnerOfAllCampaigns = this.#campaignsToDelete.every((campaign) => campaign.ownerId === this.#userId);
35+
const isAllCampaignsBelongsToOrganization = this.#campaignsToDelete.every(
36+
(campaign) => campaign.organizationId === this.#organizationId,
37+
);
38+
39+
if (!isAllCampaignsBelongsToOrganization)
40+
throw new ObjectValidationError('Some campaigns does not belong to organization.');
41+
if (!this.#membership.isAdmin && !isUserOwnerOfAllCampaigns)
42+
throw new ObjectValidationError('User does not have right to delete some campaigns.');
43+
}
44+
45+
delete() {
46+
this.#campaignParticipationsToDelete.forEach((campaignParticipation) => campaignParticipation.delete(this.#userId));
47+
this.#campaignsToDelete.forEach((campaign) => campaign.delete(this.#userId));
48+
}
49+
50+
get campaignParticipations() {
51+
return this.#campaignParticipationsToDelete;
52+
}
53+
54+
get campaigns() {
55+
return this.#campaignsToDelete;
56+
}
57+
}
58+
59+
export { CampaignsDestructor };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class OrganizationMembership {
2+
constructor({ isAdmin } = {}) {
3+
this.isAdmin = isAdmin;
4+
}
5+
}
6+
7+
export { OrganizationMembership };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { CampaignsDestructor } from '../models/CampaignsDestructor.js';
2+
3+
const deleteCampaigns = async ({
4+
userId,
5+
organizationId,
6+
campaignIds,
7+
organizationMembershipRepository,
8+
campaignAdministrationRepository,
9+
campaignParticipationRepository,
10+
}) => {
11+
const membership = await organizationMembershipRepository.getByUserIdAndOrganizationId({ userId, organizationId });
12+
const campaignsToDelete = await campaignAdministrationRepository.getByIds(campaignIds);
13+
const campaignParticipationsToDelete = await campaignParticipationRepository.getByCampaignIds(campaignIds);
14+
15+
const campaignDestructor = new CampaignsDestructor({
16+
campaignsToDelete,
17+
campaignParticipationsToDelete,
18+
userId,
19+
organizationId,
20+
membership,
21+
});
22+
campaignDestructor.delete();
23+
24+
await campaignParticipationRepository.batchUpdate(campaignParticipationsToDelete);
25+
await campaignAdministrationRepository.batchUpdate(campaignsToDelete);
26+
};
27+
28+
export { deleteCampaigns };

0 commit comments

Comments
 (0)