Repository: 1024pix/pix
Pull request #12124: [FEATURE] Créer une route d'archivage des centres de certification en masse (PIX-17597).
Author: login: Libouk
Labels: name: Tech Review OK
name: :rocket: Ready to Merge
name: team-acces
Created: 2025-04-23T15:22:44Z | Merged: 2025-04-30T15:14:31Z

## 🌸 Problème & 🌳 Proposition

Créer la route `/api/admin/certification-centers/batch-archive`. Elle prendra en paramètre le fichier csv à importer. Réutiliser le usecase déjà existant en faisant la boucle dans le controller.

## 🤧 Pour tester

- Pas encore d'interface pour appeler la route (ticket suivant)
- Vérifier que les tests passent


Diff:
diff --git a/api/src/organizational-entities/application/certification-center/certification-center.admin.controller.js b/api/src/organizational-entities/application/certification-center/certification-center.admin.controller.js
index 53c8394aff6..6f09b648a78 100644
--- a/api/src/organizational-entities/application/certification-center/certification-center.admin.controller.js
+++ b/api/src/organizational-entities/application/certification-center/certification-center.admin.controller.js
@@ -1,6 +1,7 @@
 import { usecases as certificationConfigurationUsecases } from '../../../certification/configuration/domain/usecases/index.js';
 import { DomainTransaction } from '../../../shared/domain/DomainTransaction.js';
 import { extractUserIdFromRequest } from '../../../shared/infrastructure/monitoring-tools.js';
+import * as csvSerializer from '../../../shared/infrastructure/serializers/csv/csv-serializer.js';
 import { usecases } from '../../domain/usecases/index.js';
 import * as certificationCenterSerializer from '../../infrastructure/serializers/jsonapi/certification-center/certification-center.serializer.js';
 import * as certificationCenterForAdminSerializer from '../../infrastructure/serializers/jsonapi/certification-center/certification-center-for-admin.serializer.js';
@@ -13,6 +14,16 @@ const archiveCertificationCenter = async function (request, h) {
   return h.response().code(204);
 };
 
+const archiveInBatch = async function (request, h) {
+  const userId = extractUserIdFromRequest(request);
+  const certificationCenterIds = await csvSerializer.deserializeForCertificationCenterBatchArchive(
+    request.payload.path,
+  );
+  await usecases.archiveCertificationCentersInBatch({ certificationCenterIds, userId });
+
+  return h.response().code(204);
+};
+
 const create = async function (request) {
   const certificationCenter = certificationCenterForAdminSerializer.deserialize(request.payload);
   const complementaryCertificationIds =
@@ -78,6 +89,7 @@ const update = async function (request) {
 
 const certificationCenterAdminController = {
   archiveCertificationCenter,
+  archiveInBatch,
   create,
   findPaginatedFilteredCertificationCenters,
   getCertificationCenterDetails,
diff --git a/api/src/organizational-entities/application/certification-center/certification-center.admin.route.js b/api/src/organizational-entities/application/certification-center/certification-center.admin.route.js
index 497b8b114e0..327c21ff115 100644
--- a/api/src/organizational-entities/application/certification-center/certification-center.admin.route.js
+++ b/api/src/organizational-entities/application/certification-center/certification-center.admin.route.js
@@ -1,9 +1,15 @@
 import Joi from 'joi';
 
+import { PayloadTooLargeError, sendJsonApiError } from '../../../shared/application/http-errors.js';
 import { securityPreHandlers } from '../../../shared/application/security-pre-handlers.js';
+import { MAX_FILE_SIZE_UPLOAD } from '../../../shared/domain/constants.js';
 import { identifiersType, optionalIdentifiersType } from '../../../shared/domain/types/identifiers-type.js';
 import { certificationCenterAdminController } from './certification-center.admin.controller.js';
 
+const ERRORS = {
+  PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
+};
+
 const register = async function (server) {
   server.route([
     {
@@ -149,6 +155,40 @@ const register = async function (server) {
         ],
       },
     },
+    {
+      method: 'POST',
+      path: '/api/admin/certification-centers/batch-archive',
+      config: {
+        pre: [
+          {
+            method: (request, h) =>
+              securityPreHandlers.hasAtLeastOneAccessOf([securityPreHandlers.checkAdminMemberHasRoleSuperAdmin])(
+                request,
+                h,
+              ),
+            assign: 'hasAuthorizationToAccessAdminScope',
+          },
+        ],
+        payload: {
+          maxBytes: MAX_FILE_SIZE_UPLOAD,
+          output: 'file',
+          failAction: (request, h) => {
+            return sendJsonApiError(
+              new PayloadTooLargeError('An error occurred, payload is too large', ERRORS.PAYLOAD_TOO_LARGE, {
+                maxSize: '20',
+              }),
+              h,
+            );
+          },
+        },
+        handler: (request, h) => certificationCenterAdminController.archiveInBatch(request, h),
+        tags: ['api', 'admin', 'organizational-entities', 'certification-centers'],
+        notes: [
+          "- **Cette route est restreinte aux utilisateurs authentifiés ayant les droits d'accès**\n" +
+            "- Elle permet d'archiver plusieurs centres de certification dont les ID sont transmis par un CSV",
+        ],
+      },
+    },
   ]);
 };
 
diff --git a/api/src/organizational-entities/application/http-error-mapper-configuration.js b/api/src/organizational-entities/application/http-error-mapper-configuration.js
index 1355b74cf51..1321e2ff178 100644
--- a/api/src/organizational-entities/application/http-error-mapper-configuration.js
+++ b/api/src/organizational-entities/application/http-error-mapper-configuration.js
@@ -1,6 +1,7 @@
 import { HttpErrors } from '../../shared/application/http-errors.js';
 import { DomainErrorMappingConfiguration } from '../../shared/application/models/domain-error-mapping-configuration.js';
 import {
+  ArchiveCertificationCentersInBatchError,
   DpoEmailInvalid,
   FeatureNotFound,
   FeatureParamsNotProcessable,
@@ -39,6 +40,10 @@ const organizationalEntitiesDomainErrorMappingConfiguration = [
     name: OrganizationBatchUpdateError.name,
     httpErrorFn: (error) => new HttpErrors.UnprocessableEntityError(error.message, error.code, error.meta),
   },
+  {
+    name: ArchiveCertificationCentersInBatchError.name,
+    httpErrorFn: (error) => new HttpErrors.UnprocessableEntityError(error.message, error.code, error.meta),
+  },
 ].map((domainErrorMappingConfiguration) => new DomainErrorMappingConfiguration(domainErrorMappingConfiguration));
 
 export { organizationalEntitiesDomainErrorMappingConfiguration };
diff --git a/api/src/organizational-entities/domain/errors.js b/api/src/organizational-entities/domain/errors.js
index 7238754c8cf..2ec0a898323 100644
--- a/api/src/organizational-entities/domain/errors.js
+++ b/api/src/organizational-entities/domain/errors.js
@@ -12,6 +12,14 @@ class UnableToAttachChildOrganizationToParentOrganizationError extends DomainErr
   }
 }
 
+class ArchiveCertificationCentersInBatchError extends DomainError {
+  constructor({ code = 'ARCHIVE_CERTIFICATION_CENTERS_IN_BATCH_ERROR', meta } = {}) {
+    super();
+    this.code = code;
+    this.meta = meta;
+  }
+}
+
 class DpoEmailInvalid extends DomainError {
   constructor({ code = 'DPO_EMAIL_INVALID', message = 'DPO email invalid', meta } = {}) {
     super(message);
@@ -60,6 +68,7 @@ class FeatureParamsNotProcessable extends DomainError {
 }
 
 export {
+  ArchiveCertificationCentersInBatchError,
   DpoEmailInvalid,
   FeatureNotFound,
   FeatureParamsNotProcessable,
diff --git a/api/src/organizational-entities/domain/usecases/archive-certification-centers-in-batch.usecase.js b/api/src/organizational-entities/domain/usecases/archive-certification-centers-in-batch.usecase.js
new file mode 100644
index 00000000000..3ee6ce1baf6
--- /dev/null
+++ b/api/src/organizational-entities/domain/usecases/archive-certification-centers-in-batch.usecase.js
@@ -0,0 +1,36 @@
+import { ArchiveCertificationCentersInBatchError } from '../errors.js';
+
+/**
+ * @param {Object} params
+ * @param {Array} params.certificationCenterIds
+ * @param {Number} params.userId
+ * @param {Object} params.certificationCenterForAdminRepository
+ * @param {Object} params.certificationCenterApiRepository
+ */
+export const archiveCertificationCentersInBatch = async function ({
+  certificationCenterIds,
+  userId,
+  certificationCenterForAdminRepository,
+  certificationCenterApiRepository,
+}) {
+  // we don't use a transaction here not to rollback lines 0 to N-1 in case of an error on line N
+  const archiveDate = new Date();
+
+  for (const [index, certificationCenterId] of certificationCenterIds.entries()) {
+    try {
+      await certificationCenterApiRepository.archiveCertificationCenterData({
+        certificationCenterId,
+        archivedBy: userId,
+        archiveDate,
+      });
+      await certificationCenterForAdminRepository.archive({ certificationCenterId, archivedBy: userId, archiveDate });
+    } catch {
+      throw new ArchiveCertificationCentersInBatchError({
+        meta: {
+          currentLine: index + 1,
+          totalLines: certificationCenterIds.length,
+        },
+      });
+    }
+  }
+};
diff --git a/api/src/shared/infrastructure/serializers/csv/csv-serializer.js b/api/src/shared/infrastructure/serializers/csv/csv-serializer.js
index debfd974d71..dcf07f43f48 100644
--- a/api/src/shared/infrastructure/serializers/csv/csv-serializer.js
+++ b/api/src/shared/infrastructure/serializers/csv/csv-serializer.js
@@ -176,6 +176,37 @@ async function deserializeForOrganizationsImport(file) {
   return await parseCsvWithHeader(file, batchOrganizationOptionsWithHeader);
 }
 
+async function deserializeForCertificationCenterBatchArchive(
+  file,
+  { checkCsvHeader, readCsvFile, parseCsvData } = csvHelper,
+) {
+  const requiredFieldNames = ['ID du centre de certification'];
+
+  await checkCsvHeader({ filePath: file, requiredFieldNames });
+  const cleanedData = await readCsvFile(file);
+
+  const batchCertificationCenterOptionsWithHeader = {
+    skipEmptyLines: true,
+    header: true,
+    transformHeader: (header) => header?.trim(),
+    transform: (value, columnName) => {
+      if (typeof value === 'string') {
+        value = value.trim();
+      }
+      if (!isEmpty(value)) {
+        if (columnName === 'ID du centre de certification') {
+          value = Number(value);
+        }
+      }
+      return value;
+    },
+  };
+
+  const parsedData = await parseCsvData(cleanedData, batchCertificationCenterOptionsWithHeader);
+
+  return parsedData.map((data) => data['ID du centre de certification']);
+}
+
 const requiredFieldNamesForCampaignsImport = [
   "Identifiant de l'organisation*",
   'Nom de la campagne*',
@@ -450,6 +481,7 @@ function serializeLine(lineArray) {
 
 export {
   deserializeForCampaignsImport,
+  deserializeForCertificationCenterBatchArchive,
   deserializeForOrganizationsImport,
   deserializeForSessionsImport,
   parseForCampaignsImport,
diff --git a/api/tests/organizational-entities/acceptance/application/certification-center/certification-center.admin.route.test.js b/api/tests/organizational-entities/acceptance/application/certification-center/certification-center.admin.route.test.js
index 243e5d46bad..73d14e5379e 100644
--- a/api/tests/organizational-entities/acceptance/application/certification-center/certification-center.admin.route.test.js
+++ b/api/tests/organizational-entities/acceptance/application/certification-center/certification-center.admin.route.test.js
@@ -424,4 +424,114 @@ describe('Acceptance | Organization Entities | Admin | Route | Certification Cen
       expect(cancelledInvitation.updatedAt).to.deep.equal(archivedCenter.archivedAt);
     });
   });
+
+  describe('POST /api/admin/certification-centers/batch-archive', function () {
+    context('success case', function () {
+      it('returns a 204 http request', async function () {
+        // given
+        const certificationCenterId1 = databaseBuilder.factory.buildCertificationCenter({
+          archivedAt: null,
+          archivedBy: null,
+        }).id;
+        const certificationCenterId2 = databaseBuilder.factory.buildCertificationCenter({
+          archivedAt: null,
+          archivedBy: null,
+        }).id;
+
+        await databaseBuilder.commit();
+        const buffer =
+          `ID du centre de certification\n` + `${certificationCenterId1}\n` + `${certificationCenterId2}\n`;
+
+        // when
+        const response = await server.inject({
+          method: 'POST',
+          url: `/api/admin/certification-centers/batch-archive`,
+          headers: generateAuthenticatedUserRequestHeaders({ userId: adminMember.id }),
+          payload: buffer,
+        });
+
+        // then
+        const archivedCertificationCenter1 = await knex('certification-centers')
+          .where({ id: certificationCenterId1 })
+          .first();
+        const archivedCertificationCenter2 = await knex('certification-centers')
+          .where({ id: certificationCenterId2 })
+          .first();
+
+        expect(response.statusCode).to.equal(204);
+        expect(archivedCertificationCenter1.archivedBy).to.deep.equal(adminMember.id);
+        expect(archivedCertificationCenter2.archivedBy).to.deep.equal(adminMember.id);
+        expect(archivedCertificationCenter1.archivedAt).not.to.be.null;
+        expect(archivedCertificationCenter2.archivedAt).not.to.be.null;
+      });
+    });
+
+    context('error case', function () {
+      it('returns an archive in batch error with meta info', async function () {
+        // given
+        const certificationCenterId1 = databaseBuilder.factory.buildCertificationCenter({
+          archivedAt: null,
+          archivedBy: null,
+        }).id;
+        const certificationCenterId2 = databaseBuilder.factory.buildCertificationCenter({
+          archivedAt: null,
+          archivedBy: null,
+        }).id;
+
+        const nonExistingCertifcationCenterId1 = 7895;
+        const nonExistingCertifcationCenterId2 = 8513;
+
+        await databaseBuilder.commit();
+        const buffer =
+          `ID du centre de certification\n` +
+          `${certificationCenterId1}\n` +
+          `${certificationCenterId2}\n` +
+          `${nonExistingCertifcationCenterId1}\n` +
+          `${nonExistingCertifcationCenterId2}\n`;
+
+        // when
+        const response = await server.inject({
+          method: 'POST',
+          url: `/api/admin/certification-centers/batch-archive`,
+          headers: generateAuthenticatedUserRequestHeaders({ userId: adminMember.id }),
+          payload: buffer,
+        });
+
+        // then
+        const archivedCertificationCenter1 = await knex('certification-centers')
+          .where({ id: certificationCenterId1 })
+          .first();
+        const archivedCertificationCenter2 = await knex('certification-centers')
+          .where({ id: certificationCenterId2 })
+          .first();
+
+        expect(response.statusCode).to.equal(422);
+        expect(response.result.errors[0].code).to.deep.equal('ARCHIVE_CERTIFICATION_CENTERS_IN_BATCH_ERROR');
+        expect(response.result.errors[0].meta).to.deep.equal({
+          currentLine: 3,
+          totalLines: 4,
+        });
+        expect(archivedCertificationCenter1.archivedBy).to.deep.equal(adminMember.id);
+        expect(archivedCertificationCenter2.archivedBy).to.deep.equal(adminMember.id);
+        expect(archivedCertificationCenter1.archivedAt).not.to.be.null;
+        expect(archivedCertificationCenter2.archivedAt).not.to.be.null;
+      });
+
+      it('fails when the file payload is too large', async function () {
+        const buffer = Buffer.alloc(1048576 * 22, 'B'); // > 10 Mo buffer
+
+        const options = {
+          method: 'POST',
+          url: '/api/admin/certification-centers/batch-archive',
+          headers: generateAuthenticatedUserRequestHeaders({ userId: adminMember.id }),
+          payload: buffer,
+        };
+
+        const response = await server.inject(options);
+        expect(response.statusCode).to.equal(413);
+        expect(response.result.errors[0].code).to.equal('PAYLOAD_TOO_LARGE');
+        expect(response.result.errors[0].meta.maxSize).to.equal('20');
+      });
+    });
+  });
 });
diff --git a/api/tests/organizational-entities/integration/domain/usecases/archive-certification-centers-in-batch.usecase.test.js b/api/tests/organizational-entities/integration/domain/usecases/archive-certification-centers-in-batch.usecase.test.js
new file mode 100644
index 00000000000..e951a1b8c3c
--- /dev/null
+++ b/api/tests/organizational-entities/integration/domain/usecases/archive-certification-centers-in-batch.usecase.test.js
@@ -0,0 +1,149 @@
+import { knex } from '../../../../../db/knex-database-connection.js';
+import { ArchiveCertificationCentersInBatchError } from '../../../../../src/organizational-entities/domain/errors.js';
+import { usecases } from '../../../../../src/organizational-entities/domain/usecases/index.js';
+import { CertificationCenterInvitation } from '../../../../../src/team/domain/models/CertificationCenterInvitation.js';
+import { catchErr, databaseBuilder, expect, sinon } from '../../../../test-helper.js';
+
+describe('Integration | Organizational Entities | Domain | UseCase | archive-certification-centers-in-batch', function () {
+  it('archives the certification centers and related data', async function () {
+    // given
+    const creationDate = new Date(2021, 1, 1);
+    const lastUpdateBeforeArchive = new Date(2022, 2, 2);
+    const now = new Date(2023, 3, 3);
+    const clock = sinon.useFakeTimers({ now, toFake: ['Date'] });
+    const pendingStatus = CertificationCenterInvitation.StatusType.PENDING;
+    const superAdminUser = databaseBuilder.factory.buildUser();
+    const user = databaseBuilder.factory.buildUser({});
+
+    const certificationCenter1 = databaseBuilder.factory.buildCertificationCenter({
+      updatedAt: lastUpdateBeforeArchive,
+      createdAt: creationDate,
+    });
+
+    databaseBuilder.factory.buildCertificationCenterMembership({
+      certificationCenterId: certificationCenter1.id,
+      userId: user.id,
+      createdAt: creationDate,
+      updatedAt: lastUpdateBeforeArchive,
+    });
+
+    databaseBuilder.factory.buildCertificationCenterInvitation({
+      certificationCenterId: certificationCenter1.id,
+      email: user.email,
+      status: pendingStatus,
+    });
+
+    const certificationCenter2 = databaseBuilder.factory.buildCertificationCenter({
+      updatedAt: lastUpdateBeforeArchive,
+      createdAt: creationDate,
+    });
+
+    databaseBuilder.factory.buildCertificationCenterMembership({
+      certificationCenterId: certificationCenter2.id,
+      userId: user.id,
+      createdAt: creationDate,
+      updatedAt: lastUpdateBeforeArchive,
+    });
+
+    databaseBuilder.factory.buildCertificationCenterInvitation({
+      certificationCenterId: certificationCenter2.id,
+      email: user.email,
+      status: pendingStatus,
+    });
+
+    await databaseBuilder.commit();
+
+    // when
+    await usecases.archiveCertificationCentersInBatch({
+      certificationCenterIds: [certificationCenter1.id, certificationCenter2.id],
+      userId: superAdminUser.id,
+    });
+
+    // then
+    const archivedCertificationCenter1 = await knex('certification-centers')
+      .where({ id: certificationCenter1.id })
+      .first();
+    const disabledCertificationCenter1Membership = await knex('certification-center-memberships')
+      .where({ certificationCenterId: certificationCenter1.id })
+      .first();
+    const cancelledCertificationCenter1Invitation = await knex('certification-center-invitations')
+      .where({ certificationCenterId: certificationCenter1.id })
+      .first();
+
+    const archivedCertificationCenter2 = await knex('certification-centers')
+      .where({ id: certificationCenter2.id })
+      .first();
+    const disabledCertificationCenter2Membership = await knex('certification-center-memberships')
+      .where({ certificationCenterId: certificationCenter2.id })
+      .first();
+    const cancelledCertificationCenter2Invitation = await knex('certification-center-invitations')
+      .where({ certificationCenterId: certificationCenter2.id })
+      .first();
+
+    expect(archivedCertificationCenter1.archivedAt).to.deep.equal(now);
+    expect(archivedCertificationCenter1.archivedBy).to.deep.equal(superAdminUser.id);
+    expect(archivedCertificationCenter1.updatedAt).to.deep.equal(lastUpdateBeforeArchive);
+
+    expect(disabledCertificationCenter1Membership.disabledAt).to.deep.equal(now);
+    expect(disabledCertificationCenter1Membership.updatedByUserId).to.deep.equal(superAdminUser.id);
+    expect(disabledCertificationCenter1Membership.updatedAt).to.deep.equal(lastUpdateBeforeArchive);
+
+    expect(cancelledCertificationCenter1Invitation.status).to.deep.equal('cancelled');
+    expect(cancelledCertificationCenter1Invitation.updatedAt).to.deep.equal(now);
+
+    expect(archivedCertificationCenter2.archivedAt).to.deep.equal(now);
+    expect(archivedCertificationCenter2.archivedBy).to.deep.equal(superAdminUser.id);
+    expect(archivedCertificationCenter2.updatedAt).to.deep.equal(lastUpdateBeforeArchive);
+
+    expect(disabledCertificationCenter2Membership.disabledAt).to.deep.equal(now);
+    expect(disabledCertificationCenter2Membership.updatedByUserId).to.deep.equal(superAdminUser.id);
+    expect(disabledCertificationCenter2Membership.updatedAt).to.deep.equal(lastUpdateBeforeArchive);
+
+    expect(cancelledCertificationCenter2Invitation.status).to.deep.equal('cancelled');
+    expect(cancelledCertificationCenter2Invitation.updatedAt).to.deep.equal(now);
+
+    clock.restore();
+  });
+
+  context('when certification center does not exist', function () {
+    it('throws an error archive certification centers in batch error', async function () {
+      // given
+      const creationDate = new Date(2021, 1, 1);
+      const lastUpdateBeforeArchive = new Date(2022, 2, 2);
+      const pendingStatus = CertificationCenterInvitation.StatusType.PENDING;
+      const superAdminUser = databaseBuilder.factory.buildUser();
+      const user = databaseBuilder.factory.buildUser({});
+
+      const certificationCenter = databaseBuilder.factory.buildCertificationCenter({
+        updatedAt: lastUpdateBeforeArchive,
+        createdAt: creationDate,
+      });
+      databaseBuilder.factory.buildCertificationCenterMembership({
+        certificationCenterId: certificationCenter.id,
+        userId: user.id,
+        createdAt: creationDate,
+        updatedAt: lastUpdateBeforeArchive,
+      });
+      databaseBuilder.factory.buildCertificationCenterInvitation({
+        certificationCenterId: certificationCenter.id,
+        email: user.email,
+        status: pendingStatus,
+      });
+
+      await databaseBuilder.commit();
+
+      // when
+      const error = await catchErr(usecases.archiveCertificationCentersInBatch)({
+        certificationCenterIds: [certificationCenter.id, 1234],
+        userId: superAdminUser.id,
+      });
+
+      // then
+      expect(error).to.be.instanceOf(ArchiveCertificationCentersInBatchError);
+      expect(error.meta).to.deep.equal({
+        currentLine: 2,
+        totalLines: 2,
+      });
+    });
+  });
+});
diff --git a/api/tests/shared/unit/infrastructure/serializers/csv/csv-serializer_test.js b/api/tests/shared/unit/infrastructure/serializers/csv/csv-serializer_test.js
index 41282d6e225..06a62645d96 100644
--- a/api/tests/shared/unit/infrastructure/serializers/csv/csv-serializer_test.js
+++ b/api/tests/shared/unit/infrastructure/serializers/csv/csv-serializer_test.js
@@ -6,7 +6,6 @@ import { FileValidationError } from '../../../../../../src/shared/domain/errors.
 import * as csvSerializer from '../../../../../../src/shared/infrastructure/serializers/csv/csv-serializer.js';
 import { logger } from '../../../../../../src/shared/infrastructure/utils/logger.js';
 import { catchErr, domainBuilder, expect, sinon } from '../../../../../test-helper.js';
-
 describe('Unit | Serializer | CSV | csv-serializer', function () {
   describe('#serializeLine', function () {
     it('should quote strings', async function () {
@@ -1456,6 +1455,30 @@ describe('Unit | Serializer | CSV | csv-serializer', function () {
     });
   });
 
+  describe('#deserializeForCertificationCenterBatchArchive', function () {
+    it('should check the required header', async function () {
+      // given
+      const filePath = 'file://certification-centers.csv';
+      const checkCsvHeaderStub = sinon.stub();
+      const readCsvFileStub = sinon.stub();
+      const parseCsvDataStub = sinon.stub();
+      parseCsvDataStub.resolves([{ 'ID du centre de certification': 1234 }, { 'ID du centre de certification': 5678 }]);
+
+      const requiredFieldNames = ['ID du centre de certification'];
+
+      // when
+      const serializedData = await csvSerializer.deserializeForCertificationCenterBatchArchive(filePath, {
+        checkCsvHeader: checkCsvHeaderStub,
+        readCsvFile: readCsvFileStub,
+        parseCsvData: parseCsvDataStub,
+      });
+
+      // then
+      expect(checkCsvHeaderStub).to.have.been.calledWithExactly({ filePath, requiredFieldNames });
+      expect(serializedData).to.deep.equal([1234, 5678]);
+    });
+  });
+
   describe('#parseForCampaignsImport', function () {
     const headerCsv =
       "Identifiant de l'organisation*;Nom de la campagne*;Identifiant du profil cible*;Libellé de l'identifiant externe;Identifiant du créateur*;Titre du parcours;Descriptif du parcours;Envoi multiple;Identifiant du propriétaire*;Texte de la page de fin de parcours;Texte du bouton de la page de fin de parcours;URL du bouton de la page de fin de parcours\n";


Reviews:
user: login: theotime2005
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:04:12Z
user: login: Libouk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T08:06:53Z
user: login: Libouk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T08:11:54Z
user: login: Libouk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T08:38:44Z
user: login: Libouk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T08:41:34Z
user: login: Libouk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T08:57:51Z
user: login: P-Jeremy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T09:08:03Z
user: login: Libouk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T10:01:48Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T12:58:19Z
user: login: P-Jeremy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:00:53Z
user: login: P-Jeremy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:01:35Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:05:05Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:08:10Z
user: login: P-Jeremy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:14:45Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:16:56Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T13:24:45Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T13:34:12Z
user: login: lego-technix
body: ✅ Lu et testé fonctionnellement avec succès avec curl sur Linux 🐧
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T14:48:40Z

Review comments:
diff_hunk: @@ -176,6 +176,37 @@ async function deserializeForOrganizationsImport(file) {
   return await parseCsvWithHeader(file, batchOrganizationOptionsWithHeader);
 }
 
+async function deserializeForCertificationCenterBatchArchive(
+  file,
+  { checkCsvHeader, readCsvFile, parseCsvData } = csvHelper,
+) {
+  const requiredFieldNames = ['certificationCenterId'];
path: api/src/shared/infrastructure/serializers/csv/csv-serializer.js
user: login: Libouk
body: Change-t-on le nom du champ attendu pour le mettre en Rançais ?
created_at: 2025-04-29T08:06:53Z
author_association: MEMBER
diff_hunk: @@ -176,6 +176,37 @@ async function deserializeForOrganizationsImport(file) {
   return await parseCsvWithHeader(file, batchOrganizationOptionsWithHeader);
 }
 
+async function deserializeForCertificationCenterBatchArchive(
+  file,
+  { checkCsvHeader, readCsvFile, parseCsvData } = csvHelper,
+) {
+  const requiredFieldNames = ['certificationCenterId'];
path: api/src/shared/infrastructure/serializers/csv/csv-serializer.js
user: login: Libouk
body: C'est le cas pour la création de session en masse et pour l'import des organisations
created_at: 2025-04-29T08:11:54Z
author_association: MEMBER
diff_hunk: @@ -176,6 +176,37 @@ async function deserializeForOrganizationsImport(file) {
   return await parseCsvWithHeader(file, batchOrganizationOptionsWithHeader);
 }
 
+async function deserializeForCertificationCenterBatchArchive(
+  file,
+  { checkCsvHeader, readCsvFile, parseCsvData } = csvHelper,
+) {
+  const requiredFieldNames = ['certificationCenterId'];
path: api/src/shared/infrastructure/serializers/csv/csv-serializer.js
user: login: Libouk
body: Réponse de Mathias : Oui on passe en français 
created_at: 2025-04-29T08:38:44Z
author_association: MEMBER
path: api/src/organizational-entities/application/certification-center/certification-center.admin.controller.js
user: login: Libouk
body: On ajoute un test unitaire ?
created_at: 2025-04-29T08:41:34Z
author_association: MEMBER
diff_hunk: @@ -1,9 +1,15 @@
 import Joi from 'joi';
 
+import { PayloadTooLargeError, sendJsonApiError } from '../../../shared/application/http-errors.js';
 import { securityPreHandlers } from '../../../shared/application/security-pre-handlers.js';
+import { MAX_FILE_SIZE_UPLOAD } from '../../../shared/domain/constants.js';
 import { identifiersType, optionalIdentifiersType } from '../../../shared/domain/types/identifiers-type.js';
 import { certificationCenterAdminController } from './certification-center.admin.controller.js';
 
+const ERRORS = {
path: api/src/organizational-entities/application/certification-center/certification-center.admin.route.js
user: login: Libouk
body: On a oublié de tester ces erreurs ?
created_at: 2025-04-29T08:57:50Z
author_association: MEMBER
diff_hunk: @@ -1,9 +1,15 @@
 import Joi from 'joi';
 
+import { PayloadTooLargeError, sendJsonApiError } from '../../../shared/application/http-errors.js';
 import { securityPreHandlers } from '../../../shared/application/security-pre-handlers.js';
+import { MAX_FILE_SIZE_UPLOAD } from '../../../shared/domain/constants.js';
 import { identifiersType, optionalIdentifiersType } from '../../../shared/domain/types/identifiers-type.js';
 import { certificationCenterAdminController } from './certification-center.admin.controller.js';
 
+const ERRORS = {
path: api/src/organizational-entities/application/certification-center/certification-center.admin.route.js
user: login: P-Jeremy
body: Disons qu'elles sont testées indirectement avec le test d'acceptance et le test d'integ du usecase
created_at: 2025-04-29T09:08:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,15 @@
 import Joi from 'joi';
 
+import { PayloadTooLargeError, sendJsonApiError } from '../../../shared/application/http-errors.js';
 import { securityPreHandlers } from '../../../shared/application/security-pre-handlers.js';
+import { MAX_FILE_SIZE_UPLOAD } from '../../../shared/domain/constants.js';
 import { identifiersType, optionalIdentifiersType } from '../../../shared/domain/types/identifiers-type.js';
 import { certificationCenterAdminController } from './certification-center.admin.controller.js';
 
+const ERRORS = {
path: api/src/organizational-entities/application/certification-center/certification-center.admin.route.js
user: login: Libouk
body: Je ne les ai pas vu dans des tests ces erreurs-ci 
created_at: 2025-04-29T10:01:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,35 @@
+import { ArchiveCertificationCentersInBatchError } from '../errors.js';
+
+/**
+ * @param {Object} params
+ * @param {Array} params.certificationCenterIds
+ * @param {Number} params.userId
+ * @param {Object} params.certificationCenterForAdminRepository
+ * @param {Object} params.certificationCenterApiRepository
+ */
+export const archiveCertificationCentersInBatch = async function ({
path: api/src/organizational-entities/domain/usecases/archive-certification-centers-in-batch.usecase.js
user: login: lego-technix
body: C'est un usecase de modification, aussi ne devrait-il pas être transactionnel avec `withTransaction` ?
created_at: 2025-04-29T12:58:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,15 @@
 import Joi from 'joi';
 
+import { PayloadTooLargeError, sendJsonApiError } from '../../../shared/application/http-errors.js';
 import { securityPreHandlers } from '../../../shared/application/security-pre-handlers.js';
+import { MAX_FILE_SIZE_UPLOAD } from '../../../shared/domain/constants.js';
 import { identifiersType, optionalIdentifiersType } from '../../../shared/domain/types/identifiers-type.js';
 import { certificationCenterAdminController } from './certification-center.admin.controller.js';
 
+const ERRORS = {
path: api/src/organizational-entities/application/certification-center/certification-center.admin.route.js
user: login: P-Jeremy
body: Ah oui tu as raison je confondais avec l'erreur du domain créée plus haut
created_at: 2025-04-29T13:00:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import { ArchiveCertificationCentersInBatchError } from '../errors.js';
+
+/**
+ * @param {Object} params
+ * @param {Array} params.certificationCenterIds
+ * @param {Number} params.userId
+ * @param {Object} params.certificationCenterForAdminRepository
+ * @param {Object} params.certificationCenterApiRepository
+ */
+export const archiveCertificationCentersInBatch = async function ({
path: api/src/organizational-entities/domain/usecases/archive-certification-centers-in-batch.usecase.js
user: login: P-Jeremy
body: Non, car on ne veut pas de rollback en cas d'echec. Si on jette une erreur à la ligne 67, on veut que les 66 lignes précédentes soient traitées
created_at: 2025-04-29T13:01:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import { ArchiveCertificationCentersInBatchError } from '../errors.js';
+
+/**
+ * @param {Object} params
+ * @param {Array} params.certificationCenterIds
+ * @param {Number} params.userId
+ * @param {Object} params.certificationCenterForAdminRepository
+ * @param {Object} params.certificationCenterApiRepository
+ */
+export const archiveCertificationCentersInBatch = async function ({
path: api/src/organizational-entities/domain/usecases/archive-certification-centers-in-batch.usecase.js
user: login: lego-technix
body: Bien reçu, compris. Il serait bien que cette information soit documentée d'une manière ou d'une autre pour que des maniacs de la généralisation ne viennent pas plus tard ajouter un `withTransaction` … 😉
created_at: 2025-04-29T13:05:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import { ArchiveCertificationCentersInBatchError } from '../errors.js';
+
+/**
+ * @param {Object} params
+ * @param {Array} params.certificationCenterIds
+ * @param {Number} params.userId
+ * @param {Object} params.certificationCenterForAdminRepository
+ * @param {Object} params.certificationCenterApiRepository
+ */
+export const archiveCertificationCentersInBatch = async function ({
path: api/src/organizational-entities/domain/usecases/archive-certification-centers-in-batch.usecase.js
user: login: lego-technix
body: Est-ce que la bonne approche ne serait pas plutôt de mettre un `withTransaction` au usecase et de faire des commits de transaction dans le `for` ? Ainsi on aurait mieux que de la doc, on aurait du code qui parlerait par lui-même. Et de cette manière ça serait compatible avec la manière générique de faire.
created_at: 2025-04-29T13:08:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import { ArchiveCertificationCentersInBatchError } from '../errors.js';
+
+/**
+ * @param {Object} params
+ * @param {Array} params.certificationCenterIds
+ * @param {Number} params.userId
+ * @param {Object} params.certificationCenterForAdminRepository
+ * @param {Object} params.certificationCenterApiRepository
+ */
+export const archiveCertificationCentersInBatch = async function ({
path: api/src/organizational-entities/domain/usecases/archive-certification-centers-in-batch.usecase.js
user: login: P-Jeremy
body: >Est-ce que la bonne approche ne serait pas plutôt de mettre un withTransaction au usecase et de faire des commits de transaction dans le for ? Ainsi on aurait mieux que de la doc, on aurait du code qui parlerait par lui-même. Et de cette manière ça serait compatible avec la manière générique de faire.


Je ne suis pas du tout familier avec cette façon de faire. Et je me pose vraiment la question si ça a un interêt de vouloir mettre des transactions partout. Je ne trouve pas de trace (doc, slack) qui parle de la mise en place systématique de usecase transactionnel 🤔 

Les transactions sont utiles quand on ne veut pas d'écriture en cas d'echec dans certains cas mais je ne vois pas la plus value à en mettre tout le temps.
created_at: 2025-04-29T13:14:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,35 @@
+import { ArchiveCertificationCentersInBatchError } from '../errors.js';
+
+/**
+ * @param {Object} params
+ * @param {Array} params.certificationCenterIds
+ * @param {Number} params.userId
+ * @param {Object} params.certificationCenterForAdminRepository
+ * @param {Object} params.certificationCenterApiRepository
+ */
+export const archiveCertificationCentersInBatch = async function ({
path: api/src/organizational-entities/domain/usecases/archive-certification-centers-in-batch.usecase.js
user: login: lego-technix
body: Bien reçu. On peut alors juste mettre un petit commentaire dans ce usecase et nous organiserons un atelier quand nous aurons toute l'équipe pour définir notre stratégie sur ce sujet.
created_at: 2025-04-29T13:16:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+import { knex } from '../../../../../db/knex-database-connection.js';
+import { ArchiveCertificationCentersInBatchError } from '../../../../../src/organizational-entities/domain/errors.js';
+import { usecases } from '../../../../../src/organizational-entities/domain/usecases/index.js';
+import { CertificationCenterInvitation } from '../../../../../src/team/domain/models/CertificationCenterInvitation.js';
+import { catchErr, databaseBuilder, expect, sinon } from '../../../../test-helper.js';
+
+describe('Integration | Organizational Entities | Domain | UseCase | archive-certification-centers-in-batch', function () {
+  it('archives the certification centers and related data', async function () {
+    // given
+    const creationDate = new Date(2021, 1, 1);
+    const lastUpdateBeforeArchive = new Date(2022, 2, 2);
+    const now = new Date(2023, 3, 3);
+    const clock = sinon.useFakeTimers({ now, toFake: ['Date'] });
+    const pendingStatus = CertificationCenterInvitation.StatusType.PENDING;
+    const superAdminUser = databaseBuilder.factory.buildUser();
+    const user = databaseBuilder.factory.buildUser({});
+
+    const certificationCenter1 = databaseBuilder.factory.buildCertificationCenter({
+      updatedAt: lastUpdateBeforeArchive,
+      createdAt: creationDate,
+    });
+
+    databaseBuilder.factory.buildCertificationCenterMembership({
+      certificationCenterId: certificationCenter1.id,
+      userId: user.id,
+      createdAt: creationDate,
+      updatedAt: lastUpdateBeforeArchive,
+    });
+
+    databaseBuilder.factory.buildCertificationCenterInvitation({
+      certificationCenterId: certificationCenter1.id,
+      email: user.email,
+      status: pendingStatus,
+    });
+
+    const certificationCenter2 = databaseBuilder.factory.buildCertificationCenter({
+      updatedAt: lastUpdateBeforeArchive,
+      createdAt: creationDate,
+    });
+
+    databaseBuilder.factory.buildCertificationCenterMembership({
+      certificationCenterId: certificationCenter2.id,
+      userId: user.id,
+      createdAt: creationDate,
+      updatedAt: lastUpdateBeforeArchive,
+    });
+
+    databaseBuilder.factory.buildCertificationCenterInvitation({
+      certificationCenterId: certificationCenter2.id,
+      email: user.email,
+      status: pendingStatus,
+    });
+
+    await databaseBuilder.commit();
+
+    // when
+    await usecases.archiveCertificationCentersInBatch({
+      certificationCenterIds: [certificationCenter1.id, certificationCenter2.id],
+      userId: superAdminUser.id,
+    });
+
+    // then
+    const archivedCertificationCenter1 = await knex('certification-centers')
+      .where({ id: certificationCenter1.id })
+      .first();
+    const disabledCertificationCenter1Membership = await knex('certification-center-memberships')
+      .where({ certificationCenterId: certificationCenter1.id })
+      .first();
+    const cancelledCertificationCenter1Invitation = await knex('certification-center-invitations')
+      .where({ certificationCenterId: certificationCenter1.id })
+      .first();
+
+    const archivedCertificationCenter2 = await knex('certification-centers')
+      .where({ id: certificationCenter2.id })
+      .first();
+    const disabledCertificationCenter2Membership = await knex('certification-center-memberships')
+      .where({ certificationCenterId: certificationCenter2.id })
+      .first();
+    const cancelledCertificationCenter2Invitation = await knex('certification-center-invitations')
+      .where({ certificationCenterId: certificationCenter2.id })
+      .first();
+
+    expect(archivedCertificationCenter1.archivedAt).to.deep.equal(now);
+    expect(archivedCertificationCenter1.archivedBy).to.deep.equal(superAdminUser.id);
+    expect(archivedCertificationCenter1.updatedAt).to.deep.equal(lastUpdateBeforeArchive);
+
+    expect(disabledCertificationCenter1Membership.disabledAt).to.deep.equal(now);
+    expect(disabledCertificationCenter1Membership.updatedByUserId).to.deep.equal(superAdminUser.id);
+    expect(disabledCertificationCenter1Membership.updatedAt).to.deep.equal(lastUpdateBeforeArchive);
+
+    expect(cancelledCertificationCenter1Invitation.status).to.deep.equal('cancelled');
+    expect(cancelledCertificationCenter1Invitation.updatedAt).to.deep.equal(now);
+
+    expect(archivedCertificationCenter2.archivedAt).to.deep.equal(now);
+    expect(archivedCertificationCenter2.archivedBy).to.deep.equal(superAdminUser.id);
+    expect(archivedCertificationCenter2.updatedAt).to.deep.equal(lastUpdateBeforeArchive);
+
+    expect(disabledCertificationCenter2Membership.disabledAt).to.deep.equal(now);
+    expect(disabledCertificationCenter2Membership.updatedByUserId).to.deep.equal(superAdminUser.id);
+    expect(disabledCertificationCenter2Membership.updatedAt).to.deep.equal(lastUpdateBeforeArchive);
+
+    expect(cancelledCertificationCenter2Invitation.status).to.deep.equal('cancelled');
+    expect(cancelledCertificationCenter2Invitation.updatedAt).to.deep.equal(now);
+
+    clock.restore();
+  });
+
+  it('throws an error if certification center does not exist', async function () {
path: api/tests/organizational-entities/integration/domain/usecases/archive-certification-centers-in-batch.usecase.test.js
user: login: lego-technix
body: ```suggestion
  context('when certification center does not exist', function () {
    it('throws an error ', async function () {
```
created_at: 2025-04-30T13:24:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -424,4 +424,114 @@ describe('Acceptance | Organization Entities | Admin | Route | Certification Cen
       expect(cancelledInvitation.updatedAt).to.deep.equal(archivedCenter.archivedAt);
     });
   });
+
+  describe('POST /api/admin/certification-centers/batch-archive', function () {
+    context('success case', function () {
+      it('returns a 200 http request', async function () {
path: api/tests/organizational-entities/acceptance/application/certification-center/certification-center.admin.route.test.js
user: login: lego-technix
body: ```suggestion
      it('returns a 204 HTTP status code', async function () {
```

created_at: 2025-04-30T13:28:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: pix-bot-github
created_at: 2025-04-23T15:24:12Z
body: Une fois les applications déployées, elles seront accessibles via les liens suivants :
  * [App (.fr)](https://app-pr12124.review.pix.fr)
  * [App (.org)](https://app-pr12124.review.pix.org)
  * [Orga (.fr)](https://orga-pr12124.review.pix.fr)
  * [Orga (.org)](https://orga-pr12124.review.pix.org)
  * [Certif (.fr)](https://certif-pr12124.review.pix.fr)
  * [Certif (.org)](https://certif-pr12124.review.pix.org)
  * [Junior](https://junior-pr12124.review.pix.fr)
  * [Admin](https://admin-pr12124.review.pix.fr)
  * [API](https://api-pr12124.review.pix.fr/api/)
  * [API MaDDo](https://pix-api-maddo-review-pr12124.osc-fr1.scalingo.io/api/)
  * [Audit Logger](https://pix-audit-logger-review-pr12124.osc-fr1.scalingo.io/api/)

Les variables d'environnement seront accessibles via les liens suivants :
  * [scalingo front](https://dashboard.scalingo.com/apps/osc-fr1/pix-front-review-pr12124/environment)
  * [scalingo api](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-review-pr12124/environment)
  * [scalingo api-maddo](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-maddo-review-pr12124/environment)
  * [scalingo audit-logger](https://dashboard.scalingo.com/apps/osc-fr1/pix-audit-logger-review-pr12124/environment)

author_association: NONE
user: login: Libouk
created_at: 2025-04-24T09:01:08Z
body: A rediscuter : Quel code HTTP renvoyer, 204 ? On a mis temporairement 200.
author_association: MEMBER
user: login: lego-technix
created_at: 2025-04-30T13:07:31Z
body: > A rediscuter : Quel code HTTP renvoyer, 204 ? On a mis temporairement 200.

Un `204` serait effectivement plus logique dans l'absolu et aussi plus cohérent avec le reste du code de Pix.
author_association: CONTRIBUTOR