Repository: 1024pix/pix
Pull request #11818: [TECH] utilise un verrou pour protéger l'accès à une participation avant de la partagée (PIX-17156)
Author: login: lionelB
Labels: name: Tech Review OK
name: :rocket: Ready to Merge
name: Func Review OK
description: PO validated functionally the PR
name: team-prescription
description: Adopt US - La Team qui mange et parle fort
Created: 2025-03-24T16:09:55Z | Merged: 2025-03-26T14:43:22Z

## 🌸 Problème

On a des doublons dans la table `knowledge-element-snapshots` probablement du a des appel concurrents.

## 🌳 Proposition

On crée une nouvelle méthode (`getLocked`) qui utilise un verrou pour lire les infos d'une participation avant de la partager.

## 🐝 Remarques

Ras

## 🤧 Pour tester

- Faire une campagne PROASSMUL mais ne pas partager les résultats
- via la terminal, lancer 2 requete curl pour partager la participation 
 ```sh
 ACCESS_TOKEN=$(curl -s 'https://pix-api-review-pr11818.osc-fr1.scalingo.io/api/token' --data-raw 'grant_type=password&username=admin-orga%40example.net&password=pix123&scope=pix-orga' | jq -r .access_token)
for i in {1..2}; do (curl  -H "Authorization: Bearer $ACCESS_TOKEN" -X PATCH https://pix-api-review-pr11818.osc-fr1.scalingo.io//api/campaign-participations/{campaignParticipationId}
```
- la deuxième requete doit echouée


Diff:
diff --git a/api/src/prescription/campaign-participation/domain/usecases/share-campaign-result.js b/api/src/prescription/campaign-participation/domain/usecases/share-campaign-result.js
index 4a819106235..6b15e2534d9 100644
--- a/api/src/prescription/campaign-participation/domain/usecases/share-campaign-result.js
+++ b/api/src/prescription/campaign-participation/domain/usecases/share-campaign-result.js
@@ -9,7 +9,7 @@ const shareCampaignResult = async function ({
   participationResultCalculationJobRepository,
   participationSharedJobRepository,
 }) {
-  const campaignParticipation = await campaignParticipationRepository.get(campaignParticipationId);
+  const campaignParticipation = await campaignParticipationRepository.getLocked(campaignParticipationId);
 
   _checkUserIsOwnerOfCampaignParticipation(campaignParticipation, userId);
 
diff --git a/api/src/prescription/campaign-participation/infrastructure/repositories/campaign-participation-repository.js b/api/src/prescription/campaign-participation/infrastructure/repositories/campaign-participation-repository.js
index d1c642e3d1f..69b91040940 100644
--- a/api/src/prescription/campaign-participation/infrastructure/repositories/campaign-participation-repository.js
+++ b/api/src/prescription/campaign-participation/infrastructure/repositories/campaign-participation-repository.js
@@ -58,6 +58,20 @@ const batchUpdate = async function (campaignParticipations) {
   return Promise.all(campaignParticipations.map((campaignParticipation) => update(campaignParticipation)));
 };
 
+const getLocked = async function (id) {
+  const knexConn = DomainTransaction.getConnection();
+
+  const campaignParticipationDTO = await knexConn.from('campaign-participations').forUpdate().where({ id }).first();
+  const campaignDTO = await knexConn.from('campaigns').where({ id: campaignParticipationDTO.campaignId }).first();
+  const assessmentDTOs = await knexConn.from('assessments').where({ campaignParticipationId: id });
+  const campaign = new Campaign(campaignDTO);
+  return new CampaignParticipation({
+    ...campaignParticipationDTO,
+    campaign,
+    assessments: assessmentDTOs.map((assessmentDTO) => new Assessment({ ...assessmentDTO, campaign })),
+  });
+};
+
 const get = async function (id) {
   const knexConn = DomainTransaction.getConnection();
 
@@ -253,6 +267,7 @@ export {
   getByCampaignIds,
   getCampaignParticipationsForOrganizationLearner,
   getCodeOfLastParticipationToProfilesCollectionCampaignForUser,
+  getLocked,
   hasAssessmentParticipations,
   isRetrying,
   remove,
diff --git a/api/tests/prescription/campaign-participation/integration/infrastructure/repositories/campaign-participation-repository_test.js b/api/tests/prescription/campaign-participation/integration/infrastructure/repositories/campaign-participation-repository_test.js
index e477150eb97..27eff471ebd 100644
--- a/api/tests/prescription/campaign-participation/integration/infrastructure/repositories/campaign-participation-repository_test.js
+++ b/api/tests/prescription/campaign-participation/integration/infrastructure/repositories/campaign-participation-repository_test.js
@@ -302,6 +302,89 @@ describe('Integration | Repository | Campaign Participation', function () {
     });
   });
 
+  describe('#getLocked', function () {
+    let campaignId;
+    let assessment;
+    let participation;
+
+    beforeEach(async function () {
+      campaignId = databaseBuilder.factory.buildCampaign({}).id;
+      participation = databaseBuilder.factory.buildCampaignParticipation({
+        campaignId,
+        validatedSkillsCount: 12,
+      });
+      assessment = databaseBuilder.factory.buildAssessment({
+        type: 'CAMPAIGN',
+        campaignParticipationId: participation.id,
+        createdAt: new Date('2000-01-01T10:00:00Z'),
+      });
+      await databaseBuilder.commit();
+    });
+
+    it('should return a campaign participation object', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      // then
+      expect(foundCampaignParticipation.id).to.equal(participation.id);
+      expect(foundCampaignParticipation.validatedSkillsCount).to.equal(12);
+    });
+
+    it('returns the campaign of campaignParticipation', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      // then
+      expect(foundCampaignParticipation.campaign.id).to.equal(campaignId);
+    });
+
+    it('returns the assessments of campaignParticipation', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      const assessmentIds = foundCampaignParticipation.assessments.map(({ id }) => id);
+
+      // then
+      expect(assessmentIds).to.exactlyContain([assessment.id]);
+    });
+
+    it('should lock table for update', async function () {
+      const error = await catchErr(DomainTransaction.execute)(async () => {
+        campaignParticipationRepository.getLocked(participation.id);
+        // we mimick a concurrent call on the campaign-participations table on the same row
+        return knex('campaign-participations').where({ id: participation.id }).first().forUpdate().timeout(100);
+      });
+      expect(error).instanceOf(Error);
+      expect(error.message).to.equal('Defined query timeout of 100ms exceeded when running query.');
+    });
+
+    it('should not lock table for update', async function () {
+      const campaignParticipationNotSharedId = databaseBuilder.factory.buildCampaignParticipation({
+        campaignId,
+        status: STARTED,
+        sharedAt: null,
+      }).id;
+      await databaseBuilder.commit();
+
+      const notLockedParticipation = await DomainTransaction.execute(async () => {
+        campaignParticipationRepository.getLocked(participation.id);
+        // we mimick a concurrent call on the campaign-participations table on another row
+        return knex('campaign-participations')
+          .where({ id: campaignParticipationNotSharedId })
+          .first()
+          .forUpdate()
+          .timeout(100);
+      });
+      expect(notLockedParticipation.id).to.equal(campaignParticipationNotSharedId);
+    });
+  });
+
   describe('#getByCampaignIds', function () {
     it('should return participations', async function () {
       // given
diff --git a/api/tests/prescription/campaign-participation/unit/domain/usecases/share-campaign-result_test.js b/api/tests/prescription/campaign-participation/unit/domain/usecases/share-campaign-result_test.js
index a911a0ec80d..e04fa9eef58 100644
--- a/api/tests/prescription/campaign-participation/unit/domain/usecases/share-campaign-result_test.js
+++ b/api/tests/prescription/campaign-participation/unit/domain/usecases/share-campaign-result_test.js
@@ -19,7 +19,7 @@ describe('Unit | UseCase | share-campaign-result', function () {
       performAsync: sinon.stub(),
     };
     campaignParticipationRepository = {
-      get: sinon.stub(),
+      getLocked: sinon.stub(),
       updateWithSnapshot: sinon.stub(),
     };
     userId = 123;
@@ -29,7 +29,7 @@ describe('Unit | UseCase | share-campaign-result', function () {
   context('when user is not the owner of the campaign participation', function () {
     it('throws a UserNotAuthorizedToAccessEntityError error ', async function () {
       // given
-      campaignParticipationRepository.get.resolves({ userId: userId + 1 });
+      campaignParticipationRepository.getLocked.resolves({ userId: userId + 1 });
 
       // when
       const error = await catchErr(usecases.shareCampaignResult)({
@@ -53,7 +53,7 @@ describe('Unit | UseCase | share-campaign-result', function () {
         userId,
       });
       sinon.stub(campaignParticipation, 'share');
-      campaignParticipationRepository.get.withArgs(campaignParticipationId).resolves(campaignParticipation);
+      campaignParticipationRepository.getLocked.withArgs(campaignParticipationId).resolves(campaignParticipation);
 
       // when
       await usecases.shareCampaignResult({
@@ -77,7 +77,7 @@ describe('Unit | UseCase | share-campaign-result', function () {
       });
       sinon.stub(campaignParticipation, 'share');
 
-      campaignParticipationRepository.get.resolves(campaignParticipation);
+      campaignParticipationRepository.getLocked.resolves(campaignParticipation);
 
       // when
       await usecases.shareCampaignResult({


Reviews:
user: login: machestla
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T15:00:54Z
user: login: machestla
body: tech review ok ✅ 🐕 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T15:01:59Z
user: login: alicegoarnisson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T15:32:31Z
user: login: lionelB
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T09:00:18Z
user: login: Alexandre-Monney
body: Rien de bloquant 🚀 🐈‍⬛ 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T11:00:40Z
user: login: lionelB
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T13:58:52Z
user: login: lionelB
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T14:00:49Z

Review comments:
diff_hunk: @@ -58,6 +58,20 @@ const batchUpdate = async function (campaignParticipations) {
   return Promise.all(campaignParticipations.map((campaignParticipation) => update(campaignParticipation)));
 };
 
+const getLocked = async function (id) {
+  const knexConn = DomainTransaction.getConnection();
+
+  const campaignParticipationDTO = await knexConn.from('campaign-participations').forUpdate().where({ id }).first();
+  const campaignDTO = await knexConn.from('campaigns').where({ id: campaignParticipationDTO.campaignId }).first();
+  const assessmentDTOs = await knexConn.from('assessments').where({ campaignParticipationId: id });
+  const campaign = new Campaign(campaignDTO);
+  return new CampaignParticipation({
+    ...campaignParticipationDTO,
+    campaign,
+    assessments: assessmentDTOs.map((assessmentDTO) => new Assessment({ ...assessmentDTO, campaign })),
+  });
+};
+
 const get = async function (id) {
path: api/src/prescription/campaign-participation/infrastructure/repositories/campaign-participation-repository.js
user: login: machestla
body: **question :** ce get est utilisé ailleurs ?
created_at: 2025-03-25T15:00:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,6 +303,89 @@ describe('Integration | Repository | Campaign Participation', function () {
     });
   });
 
+  describe('#getLocked', function () {
+    let campaignId;
+    let assessment;
+    let participation;
+
+    beforeEach(async function () {
+      campaignId = databaseBuilder.factory.buildCampaign({}).id;
+      participation = databaseBuilder.factory.buildCampaignParticipation({
+        campaignId,
+        validatedSkillsCount: 12,
+      });
+      assessment = databaseBuilder.factory.buildAssessment({
+        type: 'CAMPAIGN',
+        campaignParticipationId: participation.id,
+        createdAt: new Date('2000-01-01T10:00:00Z'),
+      });
+      await databaseBuilder.commit();
+    });
+
+    it('should return a campaign participation object', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      // then
+      expect(foundCampaignParticipation.id).to.equal(participation.id);
+      expect(foundCampaignParticipation.validatedSkillsCount).to.equal(12);
+    });
+
+    it('returns the campaign of campaignParticipation', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.get(participation.id);
+      });
+
path: api/tests/prescription/campaign-participation/integration/infrastructure/repositories/campaign-participation-repository_test.js
user: login: alicegoarnisson
body: **thought**: on cherche pas à appeler getLocked plutôt ici (à la place de get) ?
created_at: 2025-03-25T15:32:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,6 +58,20 @@ const batchUpdate = async function (campaignParticipations) {
   return Promise.all(campaignParticipations.map((campaignParticipation) => update(campaignParticipation)));
 };
 
+const getLocked = async function (id) {
+  const knexConn = DomainTransaction.getConnection();
+
+  const campaignParticipationDTO = await knexConn.from('campaign-participations').forUpdate().where({ id }).first();
+  const campaignDTO = await knexConn.from('campaigns').where({ id: campaignParticipationDTO.campaignId }).first();
+  const assessmentDTOs = await knexConn.from('assessments').where({ campaignParticipationId: id });
+  const campaign = new Campaign(campaignDTO);
+  return new CampaignParticipation({
+    ...campaignParticipationDTO,
+    campaign,
+    assessments: assessmentDTOs.map((assessmentDTO) => new Assessment({ ...assessmentDTO, campaign })),
+  });
+};
+
 const get = async function (id) {
path: api/src/prescription/campaign-participation/infrastructure/repositories/campaign-participation-repository.js
user: login: lionelB
body: oui, dans pas mal de usecase.
created_at: 2025-03-26T08:58:12Z
author_association: MEMBER
diff_hunk: @@ -303,6 +303,89 @@ describe('Integration | Repository | Campaign Participation', function () {
     });
   });
 
+  describe('#getLocked', function () {
+    let campaignId;
+    let assessment;
+    let participation;
+
+    beforeEach(async function () {
+      campaignId = databaseBuilder.factory.buildCampaign({}).id;
+      participation = databaseBuilder.factory.buildCampaignParticipation({
+        campaignId,
+        validatedSkillsCount: 12,
+      });
+      assessment = databaseBuilder.factory.buildAssessment({
+        type: 'CAMPAIGN',
+        campaignParticipationId: participation.id,
+        createdAt: new Date('2000-01-01T10:00:00Z'),
+      });
+      await databaseBuilder.commit();
+    });
+
+    it('should return a campaign participation object', async function () {
path: api/tests/prescription/campaign-participation/integration/infrastructure/repositories/campaign-participation-repository_test.js
user: login: Alexandre-Monney
body: Le it parle de campaign participation object, mais il est pas dans un expect ce model ? Ou alors j'ai pas compris 😞 
created_at: 2025-03-26T10:58:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,6 +303,89 @@ describe('Integration | Repository | Campaign Participation', function () {
     });
   });
 
+  describe('#getLocked', function () {
+    let campaignId;
+    let assessment;
+    let participation;
+
+    beforeEach(async function () {
+      campaignId = databaseBuilder.factory.buildCampaign({}).id;
+      participation = databaseBuilder.factory.buildCampaignParticipation({
+        campaignId,
+        validatedSkillsCount: 12,
+      });
+      assessment = databaseBuilder.factory.buildAssessment({
+        type: 'CAMPAIGN',
+        campaignParticipationId: participation.id,
+        createdAt: new Date('2000-01-01T10:00:00Z'),
+      });
+      await databaseBuilder.commit();
+    });
+
+    it('should return a campaign participation object', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      // then
+      expect(foundCampaignParticipation.id).to.equal(participation.id);
+      expect(foundCampaignParticipation.validatedSkillsCount).to.equal(12);
+    });
+
+    it('returns the campaign of campaignParticipation', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      // then
+      expect(foundCampaignParticipation.campaign.id).to.equal(campaignId);
+    });
+
+    it('returns the assessments of campaignParticipation', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      const assessmentIds = foundCampaignParticipation.assessments.map(({ id }) => id);
+
+      // then
+      expect(assessmentIds).to.exactlyContain([assessment.id]);
+    });
+
+    it('should lock table for update', async function () {
+      const error = await catchErr(DomainTransaction.execute)(async () => {
+        campaignParticipationRepository.getLocked(participation.id);
+        // we mimick a concurrent call on the campaign-participations table on the same row
+        return knex('campaign-participations').where({ id: participation.id }).first().forUpdate().timeout(100);
+      });
+      expect(error).instanceOf(Error);
+      expect(error.message).to.equal('Defined query timeout of 100ms exceeded when running query.');
+    });
+
+    it('should not lock table for update', async function () {
path: api/tests/prescription/campaign-participation/integration/infrastructure/repositories/campaign-participation-repository_test.js
user: login: Alexandre-Monney
body: **thought** : Est ce qu'on rajoute pas un expect avec to.not.throw pour expliciter ? C'est du détail en vrai
created_at: 2025-03-26T11:00:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,6 +303,89 @@ describe('Integration | Repository | Campaign Participation', function () {
     });
   });
 
+  describe('#getLocked', function () {
+    let campaignId;
+    let assessment;
+    let participation;
+
+    beforeEach(async function () {
+      campaignId = databaseBuilder.factory.buildCampaign({}).id;
+      participation = databaseBuilder.factory.buildCampaignParticipation({
+        campaignId,
+        validatedSkillsCount: 12,
+      });
+      assessment = databaseBuilder.factory.buildAssessment({
+        type: 'CAMPAIGN',
+        campaignParticipationId: participation.id,
+        createdAt: new Date('2000-01-01T10:00:00Z'),
+      });
+      await databaseBuilder.commit();
+    });
+
+    it('should return a campaign participation object', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      // then
+      expect(foundCampaignParticipation.id).to.equal(participation.id);
+      expect(foundCampaignParticipation.validatedSkillsCount).to.equal(12);
+    });
+
+    it('returns the campaign of campaignParticipation', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      // then
+      expect(foundCampaignParticipation.campaign.id).to.equal(campaignId);
+    });
+
+    it('returns the assessments of campaignParticipation', async function () {
+      // when
+      const foundCampaignParticipation = await DomainTransaction.execute(async () => {
+        return campaignParticipationRepository.getLocked(participation.id);
+      });
+
+      const assessmentIds = foundCampaignParticipation.assessments.map(({ id }) => id);
+
+      // then
+      expect(assessmentIds).to.exactlyContain([assessment.id]);
+    });
+
+    it('should lock table for update', async function () {
+      const error = await catchErr(DomainTransaction.execute)(async () => {
+        campaignParticipationRepository.getLocked(participation.id);
+        // we mimick a concurrent call on the campaign-participations table on the same row
+        return knex('campaign-participations').where({ id: participation.id }).first().forUpdate().timeout(100);
+      });
+      expect(error).instanceOf(Error);
+      expect(error.message).to.equal('Defined query timeout of 100ms exceeded when running query.');
+    });
+
+    it('should not lock table for update', async function () {
path: api/tests/prescription/campaign-participation/integration/infrastructure/repositories/campaign-participation-repository_test.js
user: login: lionelB
body: le j'ai pas reussi à faire fonctionner le `not.throw` à pririo sur les methode async ca passe meme si la promesse échoue
created_at: 2025-03-26T13:58:52Z
author_association: MEMBER
diff_hunk: @@ -303,6 +303,89 @@ describe('Integration | Repository | Campaign Participation', function () {
     });
   });
 
+  describe('#getLocked', function () {
+    let campaignId;
+    let assessment;
+    let participation;
+
+    beforeEach(async function () {
+      campaignId = databaseBuilder.factory.buildCampaign({}).id;
+      participation = databaseBuilder.factory.buildCampaignParticipation({
+        campaignId,
+        validatedSkillsCount: 12,
+      });
+      assessment = databaseBuilder.factory.buildAssessment({
+        type: 'CAMPAIGN',
+        campaignParticipationId: participation.id,
+        createdAt: new Date('2000-01-01T10:00:00Z'),
+      });
+      await databaseBuilder.commit();
+    });
+
+    it('should return a campaign participation object', async function () {
path: api/tests/prescription/campaign-participation/integration/infrastructure/repositories/campaign-participation-repository_test.js
user: login: lionelB
body: J'ai repris les test de la méthode `get` pour que ça fasse la même chose, le verrou en plus
created_at: 2025-03-26T14:00:49Z
author_association: MEMBER

PR comments:
user: login: pix-bot-github
created_at: 2025-03-24T16:11:28Z
body: Une fois les applications déployées, elles seront accessibles via les liens suivants :
  * [App (.fr)](https://app-pr11818.review.pix.fr)
  * [App (.org)](https://app-pr11818.review.pix.org)
  * [Orga (.fr)](https://orga-pr11818.review.pix.fr)
  * [Orga (.org)](https://orga-pr11818.review.pix.org)
  * [Certif (.fr)](https://certif-pr11818.review.pix.fr)
  * [Certif (.org)](https://certif-pr11818.review.pix.org)
  * [Junior](https://junior-pr11818.review.pix.fr)
  * [Admin](https://admin-pr11818.review.pix.fr)
  * [API](https://api-pr11818.review.pix.fr/api/)
  * [API MaDDo](https://pix-api-maddo-review-pr11818.osc-fr1.scalingo.io/api/)
  * [Audit Logger](https://pix-audit-logger-review-pr11818.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-pr11818/environment)
  * [scalingo api](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-review-pr11818/environment)
  * [scalingo api-maddo](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-maddo-review-pr11818/environment)
  * [scalingo audit-logger](https://dashboard.scalingo.com/apps/osc-fr1/pix-audit-logger-review-pr11818/environment)

author_association: NONE
user: login: pix-bot-github
created_at: 2025-03-25T08:15:22Z
body: Une fois les applications déployées, elles seront accessibles via les liens suivants :
  * [App (.fr)](https://app-pr11818.review.pix.fr)
  * [App (.org)](https://app-pr11818.review.pix.org)
  * [Orga (.fr)](https://orga-pr11818.review.pix.fr)
  * [Orga (.org)](https://orga-pr11818.review.pix.org)
  * [Certif (.fr)](https://certif-pr11818.review.pix.fr)
  * [Certif (.org)](https://certif-pr11818.review.pix.org)
  * [Junior](https://junior-pr11818.review.pix.fr)
  * [Admin](https://admin-pr11818.review.pix.fr)
  * [API](https://api-pr11818.review.pix.fr/api/)
  * [API MaDDo](https://pix-api-maddo-review-pr11818.osc-fr1.scalingo.io/api/)
  * [Audit Logger](https://pix-audit-logger-review-pr11818.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-pr11818/environment)
  * [scalingo api](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-review-pr11818/environment)
  * [scalingo api-maddo](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-maddo-review-pr11818/environment)
  * [scalingo audit-logger](https://dashboard.scalingo.com/apps/osc-fr1/pix-audit-logger-review-pr11818/environment)

author_association: NONE