From ff1d0e7be23bca693976056076b46b8ae6c84bad Mon Sep 17 00:00:00 2001
From: Anis Safine Laget <anis.safine@beta.gouv.fr>
Date: Tue, 1 Apr 2025 16:37:15 +0200
Subject: [PATCH 1/6] =?UTF-8?q?chore(api):=20passage=20=C3=A0=20effect=20d?=
 =?UTF-8?q?e=20getEtape?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/api/src/api/rest/etapes.ts           | 113 ++++++++++++------
 packages/api/src/server/rest.ts               |   2 +-
 packages/common/src/rest.ts                   |   2 +-
 packages/ui/src/components/etape-edition.tsx  |   7 +-
 .../src/components/etape/etape-api-client.ts  |  14 ++-
 5 files changed, 95 insertions(+), 43 deletions(-)

diff --git a/packages/api/src/api/rest/etapes.ts b/packages/api/src/api/rest/etapes.ts
index 46487ac62..d163adf4a 100644
--- a/packages/api/src/api/rest/etapes.ts
+++ b/packages/api/src/api/rest/etapes.ts
@@ -64,7 +64,7 @@ import { KM2 } from 'camino-common/src/number'
 import { FeatureMultiPolygon, FeatureCollectionPoints } from 'camino-common/src/perimetre'
 import { canHaveForages } from 'camino-common/src/permissions/titres'
 import { SecteursMaritimes, getSecteurMaritime } from 'camino-common/src/static/facades'
-import { callAndExit, shortCircuitError, zodParseEffect } from '../../tools/fp-tools'
+import { shortCircuitError, zodParseEffect } from '../../tools/fp-tools'
 import { RestNewGetCall, RestNewPostCall, RestNewPutCall } from '../../server/rest'
 import { Effect, Match } from 'effect'
 import { EffectDbQueryAndValidateErrors } from '../../pg-database'
@@ -251,42 +251,85 @@ export const deleteEtape =
       }
     }
   }
-export const getEtape =
-  (_pool: Pool) =>
-  async (req: CaminoRequest, res: CustomResponse<FlattenEtape>): Promise<void> => {
-    const user = req.auth
-
-    const etapeId = etapeIdOrSlugValidator.safeParse(req.params.etapeIdOrSlug)
-    if (!etapeId.success) {
-      res.sendStatus(HTTP_STATUS.BAD_REQUEST)
-    } else if (isNullOrUndefined(user)) {
-      res.sendStatus(HTTP_STATUS.FORBIDDEN)
-    } else {
-      try {
-        const titreEtape = await titreEtapeGet(etapeId.data, { fields: { demarche: { titre: { pointsEtape: { id: {} } } } }, fetchHeritage: true }, user)
 
-        if (isNullOrUndefined(titreEtape)) {
-          res.sendStatus(HTTP_STATUS.NOT_FOUND)
-        } else if (isNullOrUndefined(titreEtape.titulaireIds) || isNullOrUndefined(titreEtape.demarche?.titre) || titreEtape.demarche.titre.administrationsLocales === undefined) {
-          console.error('la démarche n’est pas chargée complètement')
-          res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR)
-        } else if (
-          !canEditEtape(user, titreEtape.typeId, titreEtape.isBrouillon, titreEtape.titulaireIds ?? [], titreEtape.demarche.titre.administrationsLocales ?? [], titreEtape.demarche.typeId, {
-            typeId: titreEtape.demarche.titre.typeId,
-            titreStatutId: titreEtape.demarche.titre.titreStatutId,
+const etapeFetchError = `Une erreur s'est produite lors de la récupération de l'étape` as const
+const etapeIntrouvableError = `L'étape n'existe pas` as const
+const etapeIncompletError = `L'étape n'a pas été chargée de manière complète` as const
+const droitsManquantsError = `Les droits d'accès à cette étape ne sont pas suffisants` as const
+type GetEtapeErrors = EffectDbQueryAndValidateErrors | TitreEtapeToFlattenEtapeErrors | typeof etapeFetchError | typeof etapeIntrouvableError | typeof etapeIncompletError | typeof droitsManquantsError
+export const getEtape: RestNewGetCall<'/rest/etapes/:etapeIdOrSlug'> = (rootPipe): Effect.Effect<FlattenEtape, CaminoApiError<GetEtapeErrors>> =>
+  rootPipe.pipe(
+    Effect.bind('etape', ({ user, params }) =>
+      Effect.Do.pipe(
+        () =>
+          Effect.tryPromise({
+            try: () => titreEtapeGet(params.etapeIdOrSlug, { fields: { demarche: { titre: { pointsEtape: { id: {} } } } }, fetchHeritage: true }, user),
+            catch: error => ({ message: etapeFetchError, extra: error }),
+          }),
+        Effect.filterOrFail(
+          (etape): etape is ITitreEtape => isNotNullNorUndefined(etape),
+          () => ({ message: etapeIntrouvableError })
+        ),
+        Effect.filterOrFail(
+          etape => isNotNullNorUndefined(etape.titulaireIds),
+          () => ({ message: etapeIncompletError, detail: 'titulaireIds est manquant' })
+        )
+      )
+    ),
+    Effect.bind('demarche', ({ etape }) =>
+      Effect.Do.pipe(
+        Effect.map(() => etape.demarche),
+        Effect.filterOrFail(
+          (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche),
+          () => ({ message: etapeIncompletError, detail: 'demarche est manquant' })
+        )
+      )
+    ),
+    Effect.bind('titre', ({ demarche }) =>
+      Effect.Do.pipe(
+        Effect.map(() => demarche.titre),
+        Effect.filterOrFail(
+          (titre): titre is ITitre => isNotNullNorUndefined(titre) && titre.administrationsLocales !== undefined,
+          () => ({ message: etapeIncompletError, detail: 'titre est manquant' })
+        )
+      )
+    ),
+    Effect.filterOrFail(
+      ({ titre, demarche, etape, user }) =>
+        canEditEtape(user, etape.typeId, etape.isBrouillon, etape.titulaireIds ?? [], titre.administrationsLocales ?? [], demarche.typeId, {
+          typeId: titre.typeId,
+          titreStatutId: titre.titreStatutId,
+        }),
+      () => ({ message: droitsManquantsError })
+    ),
+    Effect.flatMap(({ etape }) => iTitreEtapeToFlattenEtape(etape)),
+    Effect.mapError(caminoError =>
+      Match.value(caminoError.message).pipe(
+        Match.when("L'étape n'existe pas", () => ({
+          ...caminoError,
+          status: HTTP_STATUS.NOT_FOUND,
+        })),
+        Match.when("Les droits d'accès à cette étape ne sont pas suffisants", () => ({
+          ...caminoError,
+          status: HTTP_STATUS.FORBIDDEN,
+        })),
+        Match.whenOr(
+          "L'étape n'a pas été chargée de manière complète",
+          'Problème de validation de données',
+          "Une erreur s'est produite lors de la récupération de l'étape",
+          "pas d'héritage chargé",
+          'pas de démarche chargée',
+          'pas de démarche ou de titre chargé',
+          'pas de slug',
+          () => ({
+            ...caminoError,
+            status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
           })
-        ) {
-          res.sendStatus(HTTP_STATUS.FORBIDDEN)
-        } else {
-          res.json(await callAndExit(iTitreEtapeToFlattenEtape(titreEtape)))
-        }
-      } catch (e) {
-        console.error(e)
-
-        res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR)
-      }
-    }
-  }
+        ),
+        Match.exhaustive
+      )
+    )
+  )
 
 const documentDEntrepriseIncorrects = "document d'entreprise incorrects" as const
 type ValidateAndGetEntrepriseDocumentsErrors = typeof documentDEntrepriseIncorrects | EffectDbQueryAndValidateErrors
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index 891111944..457c1ed47 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -232,7 +232,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k
   },
   '/rest/demarches/:demarcheId/geojson': { newGetCall: getPerimetreInfosByDemarche, ...CaminoRestRoutes['/rest/demarches/:demarcheId/geojson'] },
   '/rest/etapes/:etapeId/geojson': { newGetCall: getPerimetreInfosByEtape, ...CaminoRestRoutes['/rest/etapes/:etapeId/geojson'] },
-  '/rest/etapes/:etapeIdOrSlug': { deleteCall: deleteEtape, getCall: getEtape, ...CaminoRestRoutes['/rest/etapes/:etapeIdOrSlug'] },
+  '/rest/etapes/:etapeIdOrSlug': { deleteCall: deleteEtape, newGetCall: getEtape, ...CaminoRestRoutes['/rest/etapes/:etapeIdOrSlug'] },
   '/rest/etapes': { newPostCall: createEtape, newPutCall: updateEtape, ...CaminoRestRoutes['/rest/etapes'] },
   '/rest/etapes/:etapeId/depot': { newPutCall: deposeEtape, ...CaminoRestRoutes['/rest/etapes/:etapeId/depot'] },
   '/rest/etapes/:etapeId/entrepriseDocuments': { newGetCall: getEtapeEntrepriseDocuments, ...CaminoRestRoutes['/rest/etapes/:etapeId/entrepriseDocuments'] },
diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts
index c5e809b96..a7d5dbab8 100644
--- a/packages/common/src/rest.ts
+++ b/packages/common/src/rest.ts
@@ -221,7 +221,7 @@ export const CaminoRestRoutes = {
   '/rest/etapes/:etapeId/etapeDocuments': { params: etapeIdParamsValidator, newGet: { output: getEtapeDocumentsByEtapeIdValidator } },
   '/rest/etapes/:etapeId/etapeAvis': { params: etapeIdParamsValidator, newGet: { output: getEtapeAvisByEtapeIdValidator } },
   '/rest/etapes/:etapeId/entrepriseDocuments': { params: etapeIdParamsValidator, newGet: { output: z.array(etapeEntrepriseDocumentValidator) } },
-  '/rest/etapes/:etapeIdOrSlug': { params: z.object({ etapeIdOrSlug: etapeIdOrSlugValidator }), delete: true, get: { output: flattenEtapeValidator } },
+  '/rest/etapes/:etapeIdOrSlug': { params: z.object({ etapeIdOrSlug: etapeIdOrSlugValidator }), delete: true, newGet: { output: flattenEtapeValidator } },
   '/rest/etapes/:etapeId/depot': { params: etapeIdParamsValidator, newPut: { input: z.object({}), output: z.object({ id: etapeIdValidator }) } },
   '/rest/etapes': {
     params: noParamsValidator,
diff --git a/packages/ui/src/components/etape-edition.tsx b/packages/ui/src/components/etape-edition.tsx
index 1888b4ded..65b9cedd6 100644
--- a/packages/ui/src/components/etape-edition.tsx
+++ b/packages/ui/src/components/etape-edition.tsx
@@ -91,10 +91,11 @@ export const PureEtapeEdition = defineComponent<Props>(props => {
   onMounted(async () => {
     try {
       if (isNotNullNorUndefined(props.etapeIdOrSlug)) {
-        const { etape, demarche } = await props.apiClient.getEtape(props.etapeIdOrSlug)
-        if ('message' in demarche) {
-          setAsyncData({ status: 'NEW_ERROR', error: demarche })
+        const result = await props.apiClient.getEtape(props.etapeIdOrSlug)
+        if ('message' in result) {
+          setAsyncData({ status: 'NEW_ERROR', error: result })
         } else {
+          const { etape, demarche } = result
           const perimetre = await props.apiClient.getPerimetreInfosByEtapeId(etape.id)
           if ('message' in perimetre) {
             setAsyncData({ status: 'NEW_ERROR', error: perimetre })
diff --git a/packages/ui/src/components/etape/etape-api-client.ts b/packages/ui/src/components/etape/etape-api-client.ts
index 37235b203..ae97078e3 100644
--- a/packages/ui/src/components/etape/etape-api-client.ts
+++ b/packages/ui/src/components/etape/etape-api-client.ts
@@ -1,5 +1,5 @@
 import { apiGraphQLFetch } from '@/api/_client'
-import { deleteWithJson, getWithJson, newGetWithJson, newPostWithJson, newPutWithJson } from '@/api/client-rest'
+import { deleteWithJson, newGetWithJson, newPostWithJson, newPutWithJson } from '@/api/client-rest'
 import { CaminoDate, caminoDateValidator } from 'camino-common/src/date'
 import { DemarcheId } from 'camino-common/src/demarche'
 import { entrepriseIdValidator } from 'camino-common/src/entreprise'
@@ -85,7 +85,7 @@ export interface EtapeApiClient {
   getEtapeDocumentsByEtapeId: (etapeId: EtapeId) => Promise<GetEtapeDocumentsByEtapeId | CaminoError<string>>
   getEtapeHeritagePotentiel: (etape: Pick<CoreEtapeCreationOrModification, 'id' | 'date' | 'typeId'>, titreDemarcheId: DemarcheId) => Promise<GetEtapeHeritagePotentiel>
   getEtapeAvisByEtapeId: (etapeId: EtapeId) => Promise<GetEtapeAvisByEtapeId | CaminoError<string>>
-  getEtape: (etapeIdOrSlug: EtapeIdOrSlug) => Promise<{ etape: FlattenEtape; demarche: GetDemarcheByIdOrSlug | CaminoError<string> }>
+  getEtape: (etapeIdOrSlug: EtapeIdOrSlug) => Promise<{ etape: FlattenEtape; demarche: GetDemarcheByIdOrSlug } | CaminoError<string>>
   etapeCreer: (etape: RestEtapeCreation) => Promise<CaminoError<string> | { id: EtapeId }>
   etapeModifier: (etape: RestEtapeModification) => Promise<CaminoError<string> | { id: EtapeId }>
 }
@@ -102,8 +102,16 @@ export const etapeApiClient: EtapeApiClient = {
   getEtapeAvisByEtapeId: async etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeAvis', { etapeId }),
 
   getEtape: async etapeIdOrSlug => {
-    const etape = await getWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug })
+    const etape = await newGetWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug })
+    if ('message' in etape) {
+      return etape
+    }
+
     const demarche = await newGetWithJson('/rest/demarches/:demarcheIdOrSlug', { demarcheIdOrSlug: etape.titreDemarcheId })
+    if ('message' in demarche) {
+      return demarche
+    }
+
     return { etape, demarche }
   },
   getEtapeHeritagePotentiel: async (etape, titreDemarcheId) => {
-- 
GitLab


From a7cc3a37b7bfc0de99a1e8eff67025c4c7800ed9 Mon Sep 17 00:00:00 2001
From: Anis Safine Laget <anis.safine@beta.gouv.fr>
Date: Tue, 1 Apr 2025 17:45:28 +0200
Subject: [PATCH 2/6] wip

---
 packages/api/src/api/rest/etapes.ts | 192 +++++++++++++++++-----------
 packages/api/src/server/rest.ts     |  15 ++-
 packages/common/src/rest.ts         |   2 +-
 3 files changed, 130 insertions(+), 79 deletions(-)

diff --git a/packages/api/src/api/rest/etapes.ts b/packages/api/src/api/rest/etapes.ts
index d163adf4a..18a64f14f 100644
--- a/packages/api/src/api/rest/etapes.ts
+++ b/packages/api/src/api/rest/etapes.ts
@@ -65,8 +65,8 @@ import { FeatureMultiPolygon, FeatureCollectionPoints } from 'camino-common/src/
 import { canHaveForages } from 'camino-common/src/permissions/titres'
 import { SecteursMaritimes, getSecteurMaritime } from 'camino-common/src/static/facades'
 import { shortCircuitError, zodParseEffect } from '../../tools/fp-tools'
-import { RestNewGetCall, RestNewPostCall, RestNewPutCall } from '../../server/rest'
-import { Effect, Match } from 'effect'
+import { RestNewDeleteCall, RestNewGetCall, RestNewPostCall, RestNewPutCall } from '../../server/rest'
+import { Effect, Match, Option } from 'effect'
 import { EffectDbQueryAndValidateErrors } from '../../pg-database'
 import { CaminoError } from 'camino-common/src/zod-tools'
 import { machineFind } from '../../business/rules-demarches/machines'
@@ -179,83 +179,127 @@ export const getEtapeAvis: RestNewGetCall<'/rest/etapes/:etapeId/etapeAvis'> = (
     )
   )
 
-export const deleteEtape =
-  (pool: Pool) =>
-  async (req: CaminoRequest, res: CustomResponse<void>): Promise<void> => {
-    const user = req.auth
-
-    const etapeId = etapeIdOrSlugValidator.safeParse(req.params.etapeIdOrSlug)
-    if (!etapeId.success) {
-      res.sendStatus(HTTP_STATUS.BAD_REQUEST)
-    } else if (isNullOrUndefined(user)) {
-      res.sendStatus(HTTP_STATUS.NOT_FOUND)
-    } else {
-      try {
-        const titreEtape = await titreEtapeGet(
-          etapeId.data,
+const etapeFetchError = `Une erreur s'est produite lors de la récupération de l'étape` as const
+const etapeIntrouvableError = `L'étape n'existe pas` as const
+const etapeIncompletError = `L'étape n'a pas été chargée de manière complète` as const
+const droitsManquantsError = `Les droits d'accès à cette étape ne sont pas suffisants` as const
+const fixMeError = 'FIXME' as const
+type DeleteEtapeErrors = EffectDbQueryAndValidateErrors | typeof etapeFetchError | typeof etapeIntrouvableError | typeof etapeIncompletError | typeof droitsManquantsError | typeof fixMeError
+export const deleteEtape: RestNewDeleteCall<'/rest/etapes/:etapeIdOrSlug'> =
+  (rootPipe): Effect.Effect<Option.Option<never>, CaminoApiError<DeleteEtapeErrors>> =>
+  rootPipe.pipe(
+    Effect.bind('etape', ({ params, user }) =>
+      Effect.Do.pipe(
+        () => Effect.tryPromise({
+          try: () => titreEtapeGet(
+            params.etapeIdOrSlug,
+            {
+              fields: {
+                demarche: { titre: { pointsEtape: { id: {} } } },
+              },
+            },
+            user
+          ),
+          catch: (error) => ({ message: etapeFetchError, extra: error })
+        }),
+        Effect.filterOrFail(
+          (etape): etape is ITitreEtape => isNotNullNorUndefined(etape),
+          () => ({ message: etapeIntrouvableError })
+        )
+      )
+    ),
+    Effect.bind('demarche', ({ etape }) =>
+      Effect.Do.pipe(
+        Effect.map(() => etape.demarche),
+        Effect.filterOrFail(
+          (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche),
+          () => ({ message: etapeIncompletError, detail: 'demarche est manquant' })
+        )
+      )
+    ),
+    Effect.bind('titre', ({ demarche }) =>
+      Effect.Do.pipe(
+        Effect.map(() => demarche.titre),
+        Effect.filterOrFail(
+          (titre): titre is ITitre => isNotNullNorUndefined(titre) && titre.administrationsLocales !== undefined,
+          () => ({ message: etapeIncompletError, detail: 'titre est manquant ou incomplet' })
+        )
+      )
+    ),
+    Effect.filterOrFail(
+      ({ titre, demarche, etape, user }) =>
+        canDeleteEtape(
+          user,
+          etape.typeId,
+          etape.isBrouillon,
+          etape.titulaireIds ?? [],
+          titre.administrationsLocales ?? [],
+          demarche.typeId,
+          {
+            typeId: titre.typeId,
+            titreStatutId: titre.titreStatutId,
+          },
+        ),
+      () => ({ message: droitsManquantsError })
+    ),
+    Effect.bind('fullDemarche', ({ etape }) => Effect.Do.pipe(
+      () => Effect.tryPromise({
+        try: () => titreDemarcheGet(
+          etape.titreDemarcheId,
           {
             fields: {
-              demarche: { titre: { pointsEtape: { id: {} } } },
+              titre: {
+                demarches: { etapes: { id: {} } },
+              },
+              etapes: { id: {} },
             },
           },
-          user
-        )
-
-        if (isNullOrUndefined(titreEtape)) {
-          res.sendStatus(HTTP_STATUS.NOT_FOUND)
-        } else {
-          if (!titreEtape.demarche || !titreEtape.demarche.titre || titreEtape.demarche.titre.administrationsLocales === undefined) {
-            throw new Error('la démarche n’est pas chargée complètement')
-          }
-
-          if (
-            !canDeleteEtape(user, titreEtape.typeId, titreEtape.isBrouillon, titreEtape.titulaireIds ?? [], titreEtape.demarche.titre.administrationsLocales ?? [], titreEtape.demarche.typeId, {
-              typeId: titreEtape.demarche.titre.typeId,
-              titreStatutId: titreEtape.demarche.titre.titreStatutId,
-            })
-          ) {
-            res.sendStatus(HTTP_STATUS.FORBIDDEN)
-          } else {
-            const titreDemarche = await titreDemarcheGet(
-              titreEtape.titreDemarcheId,
-              {
-                fields: {
-                  titre: {
-                    demarches: { etapes: { id: {} } },
-                  },
-                  etapes: { id: {} },
-                },
-              },
-              userSuper
-            )
-
-            if (!titreDemarche) throw new Error("la démarche n'existe pas")
-
-            if (!titreDemarche.titre) throw new Error("le titre n'existe pas")
-
-            const { valid, errors: rulesErrors } = titreDemarcheUpdatedEtatValidate(titreDemarche.typeId, titreDemarche.titre, titreEtape, titreDemarche.id, titreDemarche.etapes!, true)
-
-            if (!valid) {
-              throw new Error(rulesErrors.join(', '))
-            }
-            await titreEtapeUpdate(titreEtape.id, { archive: true }, user, titreDemarche.titreId)
-
-            await titreEtapeUpdateTask(pool, null, titreEtape.titreDemarcheId, user)
-
-            res.sendStatus(HTTP_STATUS.NO_CONTENT)
-          }
-        }
-      } catch (e) {
-        res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR)
-        console.error(e)
+          userSuper
+        ),
+        catch: (error) => ({ message: fixMeError, extra: error })
+      }),
+      Effect.filterOrFail(
+        (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche),
+        () => ({ message: fixMeError })
+      ),
+    )),
+    Effect.bind('fullTitre', ({ fullDemarche }) => Effect.Do.pipe(
+      Effect.map(() => fullDemarche.titre),
+      Effect.filterOrFail(
+        (titre): titre is ITitre => isNotNullNorUndefined(titre),
+        () => ({ message: fixMeError, detail: 'titre est manquant ou incomplet' })
+      )
+    )),
+    Effect.tap(({ fullDemarche, fullTitre, etape }) => {
+      const { valid, errors: rulesErrors } = titreDemarcheUpdatedEtatValidate(fullDemarche.typeId, fullTitre, etape, fullDemarche.id, fullDemarche.etapes!, true)
+      if (!valid) {
+        return Effect.fail({ message: fixMeError, detail: rulesErrors.join(', ') })
       }
-    }
-  }
 
-const etapeFetchError = `Une erreur s'est produite lors de la récupération de l'étape` as const
-const etapeIntrouvableError = `L'étape n'existe pas` as const
-const etapeIncompletError = `L'étape n'a pas été chargée de manière complète` as const
-const droitsManquantsError = `Les droits d'accès à cette étape ne sont pas suffisants` as const
+      return Effect.succeed(true)
+    }),
+    Effect.tap(({ etape, fullDemarche, user }) => Effect.tryPromise({
+      try: () => titreEtapeUpdate(etape.id, { archive: true }, user, fullDemarche.titreId)
+    })),
+    Effect.tap(({ pool, etape, user }) => Effect.tryPromise({
+      try: () => titreEtapeUpdateTask(pool, null, etape.titreDemarcheId, user)
+    })),
+    Effect.map(() => Option.none()),
+    Effect.mapError(caminoError =>
+      Match.value(caminoError.message).pipe(
+        Match.whenOr(
+          "L'étape n'existe pas",
+          '',
+          () => ({
+            ...caminoError,
+            status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
+          })
+        ),
+        Match.exhaustive
+      )
+    )
+  )
+
 type GetEtapeErrors = EffectDbQueryAndValidateErrors | TitreEtapeToFlattenEtapeErrors | typeof etapeFetchError | typeof etapeIntrouvableError | typeof etapeIncompletError | typeof droitsManquantsError
 export const getEtape: RestNewGetCall<'/rest/etapes/:etapeIdOrSlug'> = (rootPipe): Effect.Effect<FlattenEtape, CaminoApiError<GetEtapeErrors>> =>
   rootPipe.pipe(
@@ -290,7 +334,7 @@ export const getEtape: RestNewGetCall<'/rest/etapes/:etapeIdOrSlug'> = (rootPipe
         Effect.map(() => demarche.titre),
         Effect.filterOrFail(
           (titre): titre is ITitre => isNotNullNorUndefined(titre) && titre.administrationsLocales !== undefined,
-          () => ({ message: etapeIncompletError, detail: 'titre est manquant' })
+          () => ({ message: etapeIncompletError, detail: 'titre est manquant ou incomplet' })
         )
       )
     ),
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index 457c1ed47..aad3d8a55 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -129,7 +129,7 @@ export type RestNewDeleteCall<Route extends NewDeleteRestRoutes> = (
   data: Effect.Effect<
     {
       pool: Pool
-      user: User
+      user: UserNotNull
       params: z.infer<CaminoRestRoutesType[Route]['params']>
       cookie: CookieParams
     },
@@ -232,7 +232,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k
   },
   '/rest/demarches/:demarcheId/geojson': { newGetCall: getPerimetreInfosByDemarche, ...CaminoRestRoutes['/rest/demarches/:demarcheId/geojson'] },
   '/rest/etapes/:etapeId/geojson': { newGetCall: getPerimetreInfosByEtape, ...CaminoRestRoutes['/rest/etapes/:etapeId/geojson'] },
-  '/rest/etapes/:etapeIdOrSlug': { deleteCall: deleteEtape, newGetCall: getEtape, ...CaminoRestRoutes['/rest/etapes/:etapeIdOrSlug'] },
+  '/rest/etapes/:etapeIdOrSlug': { newDeleteCall: deleteEtape, newGetCall: getEtape, ...CaminoRestRoutes['/rest/etapes/:etapeIdOrSlug'] },
   '/rest/etapes': { newPostCall: createEtape, newPutCall: updateEtape, ...CaminoRestRoutes['/rest/etapes'] },
   '/rest/etapes/:etapeId/depot': { newPutCall: deposeEtape, ...CaminoRestRoutes['/rest/etapes/:etapeId/depot'] },
   '/rest/etapes/:etapeId/entrepriseDocuments': { newGetCall: getEtapeEntrepriseDocuments, ...CaminoRestRoutes['/rest/etapes/:etapeId/entrepriseDocuments'] },
@@ -486,15 +486,22 @@ export const restWithPool = (dbPool: Pool): Router => {
         rest.delete(route, async (req: CaminoRequest, res: express.Response, _next: express.NextFunction) => {
           try {
             const call = Effect.Do.pipe(
+              Effect.bind('user', () => {
+                if (isNotNullNorUndefined(req.auth)) {
+                  return Effect.succeed(req.auth as UserNotNull)
+                } else {
+                  return Effect.fail({ message: 'Accès interdit', status: HTTP_STATUS.FORBIDDEN })
+                }
+              }),
               Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)),
               Effect.mapError(caminoError => {
                 return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST }
               }),
               // TODO 2024-06-26 ici, si on ne met pas les params et les searchParams à any, on se retrouve avec une typescript union hell qui fait tout planter
-              Effect.bind<'result', { params: any }, Option.Option<never>, CaminoApiError<string>, never>('result', ({ params }) => {
+              Effect.bind<'result', { params: any; user: UserNotNull }, Option.Option<never>, CaminoApiError<string>, never>('result', ({ params, user }) => {
                 return maRoute.newDeleteCall(
                   Effect.Do.pipe(
-                    Effect.let('user', () => req.auth),
+                    Effect.let('user', () => user),
                     Effect.let('pool', () => dbPool),
                     Effect.let('params', () => params),
                     Effect.let('cookie', () => ({
diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts
index a7d5dbab8..193ce0d15 100644
--- a/packages/common/src/rest.ts
+++ b/packages/common/src/rest.ts
@@ -221,7 +221,7 @@ export const CaminoRestRoutes = {
   '/rest/etapes/:etapeId/etapeDocuments': { params: etapeIdParamsValidator, newGet: { output: getEtapeDocumentsByEtapeIdValidator } },
   '/rest/etapes/:etapeId/etapeAvis': { params: etapeIdParamsValidator, newGet: { output: getEtapeAvisByEtapeIdValidator } },
   '/rest/etapes/:etapeId/entrepriseDocuments': { params: etapeIdParamsValidator, newGet: { output: z.array(etapeEntrepriseDocumentValidator) } },
-  '/rest/etapes/:etapeIdOrSlug': { params: z.object({ etapeIdOrSlug: etapeIdOrSlugValidator }), delete: true, newGet: { output: flattenEtapeValidator } },
+  '/rest/etapes/:etapeIdOrSlug': { params: z.object({ etapeIdOrSlug: etapeIdOrSlugValidator }), newDelete: true, newGet: { output: flattenEtapeValidator } },
   '/rest/etapes/:etapeId/depot': { params: etapeIdParamsValidator, newPut: { input: z.object({}), output: z.object({ id: etapeIdValidator }) } },
   '/rest/etapes': {
     params: noParamsValidator,
-- 
GitLab


From c5ab5f788566688a593d838d0a3dbb74527062f2 Mon Sep 17 00:00:00 2001
From: Anis Safine Laget <anis.safine@beta.gouv.fr>
Date: Wed, 2 Apr 2025 10:40:28 +0200
Subject: [PATCH 3/6] =?UTF-8?q?delete=20etape=20effectis=C3=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../api/rest/etape-creer.test.integration.ts  |   2 +-
 .../rest/etape-modifier.test.integration.ts   |   2 +-
 .../src/api/rest/etapes.test.integration.ts   |  10 +-
 packages/api/src/api/rest/etapes.ts           | 174 +++++++++++-------
 packages/api/src/server/rest.ts               |  29 +--
 packages/ui/src/api/client-rest.ts            |   2 +-
 .../src/components/etape/etape-api-client.ts  |  18 +-
 7 files changed, 138 insertions(+), 99 deletions(-)

diff --git a/packages/api/src/api/rest/etape-creer.test.integration.ts b/packages/api/src/api/rest/etape-creer.test.integration.ts
index 93d9b9dc7..0fbb6a097 100644
--- a/packages/api/src/api/rest/etape-creer.test.integration.ts
+++ b/packages/api/src/api/rest/etape-creer.test.integration.ts
@@ -176,7 +176,7 @@ describe('etapeCreer', () => {
     expect(result.body).toMatchInlineSnapshot(`
       {
         "detail": "La valeur du champ "date" est invalide.",
-        "message": "Problème de validation de données",
+        "message": "Corps de la requête manquant ou invalide",
         "status": 400,
         "zodErrorReadableMessage": "Validation error: Invalid at "date"; date invalide at "date"; Required at "duree"; Required at "dateDebut"; Required at "dateFin"; Required at "substances"; Required at "geojson4326Perimetre"; Required at "geojson4326Points"; Required at "geojsonOriginePoints"; Required at "geojsonOriginePerimetre"; Required at "geojsonOrigineForages"; Required at "geojsonOrigineGeoSystemeId"; Required at "titulaireIds"; Required at "amodiataireIds"; Required at "note"; Required at "contenu"; Required at "heritageProps"; Required at "heritageContenu"; Required at "etapeDocuments"; Required at "entrepriseDocumentIds"; Required at "etapeAvis"",
       }
diff --git a/packages/api/src/api/rest/etape-modifier.test.integration.ts b/packages/api/src/api/rest/etape-modifier.test.integration.ts
index cb95b8617..fa59f8348 100644
--- a/packages/api/src/api/rest/etape-modifier.test.integration.ts
+++ b/packages/api/src/api/rest/etape-modifier.test.integration.ts
@@ -75,7 +75,7 @@ describe('etapeModifier', () => {
     expect(result.body).toMatchInlineSnapshot(`
       {
         "detail": "La valeur du champ "date" est invalide.",
-        "message": "Problème de validation de données",
+        "message": "Corps de la requête invalide",
         "status": 400,
         "zodErrorReadableMessage": "Validation error: Invalid at "date"; date invalide at "date"; Required at "duree"; Required at "dateDebut"; Required at "dateFin"; Required at "substances"; Required at "geojson4326Perimetre"; Required at "geojson4326Points"; Required at "geojsonOriginePoints"; Required at "geojsonOriginePerimetre"; Required at "geojsonOrigineForages"; Required at "geojsonOrigineGeoSystemeId"; Required at "titulaireIds"; Required at "amodiataireIds"; Required at "note"; Required at "contenu"; Required at "titreDemarcheId"; Required at "heritageProps"; Required at "heritageContenu"; Required at "etapeDocuments"; Required at "entrepriseDocumentIds"; Required at "etapeAvis"",
       }
diff --git a/packages/api/src/api/rest/etapes.test.integration.ts b/packages/api/src/api/rest/etapes.test.integration.ts
index 26487e6ee..f68ea39b8 100644
--- a/packages/api/src/api/rest/etapes.test.integration.ts
+++ b/packages/api/src/api/rest/etapes.test.integration.ts
@@ -1,6 +1,6 @@
 import { dbManager } from '../../../tests/db-manager'
 import { userSuper } from '../../database/user-super'
-import { restCall, restDeleteCall, restNewCall, restNewPostCall } from '../../../tests/_utils/index'
+import { restCall, restNewCall, restNewDeleteCall, restNewPostCall } from '../../../tests/_utils/index'
 import { caminoDateValidator, dateAddDays, toCaminoDate } from 'camino-common/src/date'
 import { afterAll, beforeAll, test, expect, describe, vi } from 'vitest'
 import type { Pool } from 'pg'
@@ -284,14 +284,14 @@ describe('etapeSupprimer', () => {
       ],
     })
 
-    const tested = await restDeleteCall(
+    const tested = await restNewDeleteCall(
       dbPool,
       '/rest/etapes/:etapeIdOrSlug',
       { etapeIdOrSlug: etapeId },
       role && isAdministrationRole(role) ? { role, administrationId: 'min-mctrct-dgcl-01' } : undefined
     )
 
-    expect(tested.statusCode).toBe(HTTP_STATUS.FORBIDDEN)
+    expect(tested.statusCode, JSON.stringify(tested.body)).toBe(HTTP_STATUS.FORBIDDEN)
   })
 
   test('peut supprimer une étape (utilisateur super)', async () => {
@@ -324,7 +324,7 @@ describe('etapeSupprimer', () => {
       ],
     })
 
-    const tested = await restDeleteCall(dbPool, '/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug: etapeId }, userSuper)
+    const tested = await restNewDeleteCall(dbPool, '/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug: etapeId }, userSuper)
 
     expect(tested.statusCode).toBe(HTTP_STATUS.NO_CONTENT)
   })
@@ -380,7 +380,7 @@ describe('etapeSupprimer', () => {
     const getEtape = await restCall(dbPool, '/rest/titres/:titreId', { titreId: titreId }, user)
     expect(getEtape.statusCode).toBe(HTTP_STATUS.OK)
 
-    const tested = await restDeleteCall(dbPool, '/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug: etapeId }, user)
+    const tested = await restNewDeleteCall(dbPool, '/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug: etapeId }, user)
 
     expect(tested.statusCode).toBe(HTTP_STATUS.FORBIDDEN)
   })
diff --git a/packages/api/src/api/rest/etapes.ts b/packages/api/src/api/rest/etapes.ts
index 18a64f14f..209ab4882 100644
--- a/packages/api/src/api/rest/etapes.ts
+++ b/packages/api/src/api/rest/etapes.ts
@@ -1,11 +1,8 @@
-import { CaminoRequest, CustomResponse } from './express-type'
 import {
   EtapeTypeEtapeStatutWithMainStep,
-  etapeIdValidator,
   EtapeId,
   GetEtapeDocumentsByEtapeId,
   ETAPE_IS_NOT_BROUILLON,
-  etapeIdOrSlugValidator,
   GetEtapeAvisByEtapeId,
   EtapeBrouillon,
   etapeSlugValidator,
@@ -64,13 +61,14 @@ import { KM2 } from 'camino-common/src/number'
 import { FeatureMultiPolygon, FeatureCollectionPoints } from 'camino-common/src/perimetre'
 import { canHaveForages } from 'camino-common/src/permissions/titres'
 import { SecteursMaritimes, getSecteurMaritime } from 'camino-common/src/static/facades'
-import { shortCircuitError, zodParseEffect } from '../../tools/fp-tools'
+import { shortCircuitError } from '../../tools/fp-tools'
 import { RestNewDeleteCall, RestNewGetCall, RestNewPostCall, RestNewPutCall } from '../../server/rest'
 import { Effect, Match, Option } from 'effect'
 import { EffectDbQueryAndValidateErrors } from '../../pg-database'
 import { CaminoError } from 'camino-common/src/zod-tools'
 import { machineFind } from '../../business/rules-demarches/machines'
 import { TitreEtapeForMachine } from '../../business/rules-demarches/machine-common'
+import { newEtapeId } from '../../database/models/_format/id-create'
 
 type GetEtapeEntrepriseDocumentsErrors = EffectDbQueryAndValidateErrors
 export const getEtapeEntrepriseDocuments: RestNewGetCall<'/rest/etapes/:etapeId/entrepriseDocuments'> = (
@@ -183,25 +181,42 @@ const etapeFetchError = `Une erreur s'est produite lors de la récupération de
 const etapeIntrouvableError = `L'étape n'existe pas` as const
 const etapeIncompletError = `L'étape n'a pas été chargée de manière complète` as const
 const droitsManquantsError = `Les droits d'accès à cette étape ne sont pas suffisants` as const
-const fixMeError = 'FIXME' as const
-type DeleteEtapeErrors = EffectDbQueryAndValidateErrors | typeof etapeFetchError | typeof etapeIntrouvableError | typeof etapeIncompletError | typeof droitsManquantsError | typeof fixMeError
-export const deleteEtape: RestNewDeleteCall<'/rest/etapes/:etapeIdOrSlug'> =
-  (rootPipe): Effect.Effect<Option.Option<never>, CaminoApiError<DeleteEtapeErrors>> =>
+const etapeUpdateFail = `La mise à jour de l'étape a échoué` as const
+const etapeUpdateTaskFail = `Les tâches après mise à jour de l'étape ont échoué` as const
+const demarcheFetchError = `Une erreur s'est produite lors de la récupération de la démarche` as const
+const demarcheIntrouvableError = `La démarche associée à l'étape est vide` as const
+const titreIntrouvableError = `Le titre associé à l'étape est vide` as const
+const demarcheInvalideError = `La suppression de cette étape mène à une démarche invalide` as const
+type DeleteEtapeErrors =
+  | EffectDbQueryAndValidateErrors
+  | typeof etapeFetchError
+  | typeof etapeIntrouvableError
+  | typeof etapeIncompletError
+  | typeof droitsManquantsError
+  | typeof etapeUpdateFail
+  | typeof etapeUpdateTaskFail
+  | typeof demarcheFetchError
+  | typeof demarcheIntrouvableError
+  | typeof titreIntrouvableError
+  | typeof demarcheInvalideError
+export const deleteEtape: RestNewDeleteCall<'/rest/etapes/:etapeIdOrSlug'> = (rootPipe): Effect.Effect<Option.Option<never>, CaminoApiError<DeleteEtapeErrors>> =>
   rootPipe.pipe(
     Effect.bind('etape', ({ params, user }) =>
       Effect.Do.pipe(
-        () => Effect.tryPromise({
-          try: () => titreEtapeGet(
-            params.etapeIdOrSlug,
-            {
-              fields: {
-                demarche: { titre: { pointsEtape: { id: {} } } },
-              },
-            },
-            user
-          ),
-          catch: (error) => ({ message: etapeFetchError, extra: error })
-        }),
+        () =>
+          Effect.tryPromise({
+            try: () =>
+              titreEtapeGet(
+                params.etapeIdOrSlug,
+                {
+                  fields: {
+                    demarche: { titre: { pointsEtape: { id: {} } } },
+                  },
+                },
+                user
+              ),
+            catch: error => ({ message: etapeFetchError, extra: error }),
+          }),
         Effect.filterOrFail(
           (etape): etape is ITitreEtape => isNotNullNorUndefined(etape),
           () => ({ message: etapeIntrouvableError })
@@ -228,68 +243,89 @@ export const deleteEtape: RestNewDeleteCall<'/rest/etapes/:etapeIdOrSlug'> =
     ),
     Effect.filterOrFail(
       ({ titre, demarche, etape, user }) =>
-        canDeleteEtape(
-          user,
-          etape.typeId,
-          etape.isBrouillon,
-          etape.titulaireIds ?? [],
-          titre.administrationsLocales ?? [],
-          demarche.typeId,
-          {
-            typeId: titre.typeId,
-            titreStatutId: titre.titreStatutId,
-          },
-        ),
+        canDeleteEtape(user, etape.typeId, etape.isBrouillon, etape.titulaireIds ?? [], titre.administrationsLocales ?? [], demarche.typeId, {
+          typeId: titre.typeId,
+          titreStatutId: titre.titreStatutId,
+        }),
       () => ({ message: droitsManquantsError })
     ),
-    Effect.bind('fullDemarche', ({ etape }) => Effect.Do.pipe(
-      () => Effect.tryPromise({
-        try: () => titreDemarcheGet(
-          etape.titreDemarcheId,
-          {
-            fields: {
-              titre: {
-                demarches: { etapes: { id: {} } },
-              },
-              etapes: { id: {} },
-            },
-          },
-          userSuper
-        ),
-        catch: (error) => ({ message: fixMeError, extra: error })
-      }),
-      Effect.filterOrFail(
-        (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche),
-        () => ({ message: fixMeError })
-      ),
-    )),
-    Effect.bind('fullTitre', ({ fullDemarche }) => Effect.Do.pipe(
-      Effect.map(() => fullDemarche.titre),
-      Effect.filterOrFail(
-        (titre): titre is ITitre => isNotNullNorUndefined(titre),
-        () => ({ message: fixMeError, detail: 'titre est manquant ou incomplet' })
+    Effect.bind('fullDemarche', ({ etape }) =>
+      Effect.Do.pipe(
+        () =>
+          Effect.tryPromise({
+            try: () =>
+              titreDemarcheGet(
+                etape.titreDemarcheId,
+                {
+                  fields: {
+                    titre: {
+                      demarches: { etapes: { id: {} } },
+                    },
+                    etapes: { id: {} },
+                  },
+                },
+                userSuper
+              ),
+            catch: error => ({ message: demarcheFetchError, extra: error }),
+          }),
+        Effect.filterOrFail(
+          (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche),
+          () => ({ message: demarcheIntrouvableError })
+        )
+      )
+    ),
+    Effect.bind('fullTitre', ({ fullDemarche }) =>
+      Effect.Do.pipe(
+        Effect.map(() => fullDemarche.titre),
+        Effect.filterOrFail(
+          (titre): titre is ITitre => isNotNullNorUndefined(titre),
+          () => ({ message: titreIntrouvableError, detail: 'titre est manquant ou incomplet' })
+        )
       )
-    )),
+    ),
     Effect.tap(({ fullDemarche, fullTitre, etape }) => {
       const { valid, errors: rulesErrors } = titreDemarcheUpdatedEtatValidate(fullDemarche.typeId, fullTitre, etape, fullDemarche.id, fullDemarche.etapes!, true)
       if (!valid) {
-        return Effect.fail({ message: fixMeError, detail: rulesErrors.join(', ') })
+        return Effect.fail({ message: demarcheInvalideError, detail: rulesErrors.join(', ') })
       }
 
       return Effect.succeed(true)
     }),
-    Effect.tap(({ etape, fullDemarche, user }) => Effect.tryPromise({
-      try: () => titreEtapeUpdate(etape.id, { archive: true }, user, fullDemarche.titreId)
-    })),
-    Effect.tap(({ pool, etape, user }) => Effect.tryPromise({
-      try: () => titreEtapeUpdateTask(pool, null, etape.titreDemarcheId, user)
-    })),
+    Effect.tap(({ etape, fullDemarche, user }) =>
+      Effect.tryPromise({
+        try: () => titreEtapeUpdate(etape.id, { archive: true }, user, fullDemarche.titreId),
+        catch: error => ({ message: etapeUpdateFail, extra: error }),
+      })
+    ),
+    Effect.tap(({ pool, etape, user }) =>
+      Effect.tryPromise({
+        try: () => titreEtapeUpdateTask(pool, null, etape.titreDemarcheId, user),
+        catch: error => ({ message: etapeUpdateTaskFail, extra: error }),
+      })
+    ),
     Effect.map(() => Option.none()),
     Effect.mapError(caminoError =>
       Match.value(caminoError.message).pipe(
+        Match.when("L'étape n'existe pas", () => ({
+          ...caminoError,
+          status: HTTP_STATUS.NOT_FOUND,
+        })),
+        Match.when("Les droits d'accès à cette étape ne sont pas suffisants", () => ({
+          ...caminoError,
+          status: HTTP_STATUS.FORBIDDEN,
+        })),
+        Match.when('La suppression de cette étape mène à une démarche invalide', () => ({
+          ...caminoError,
+          status: HTTP_STATUS.BAD_REQUEST,
+        })),
         Match.whenOr(
-          "L'étape n'existe pas",
-          '',
+          "L'étape n'a pas été chargée de manière complète",
+          "La démarche associée à l'étape est vide",
+          "La mise à jour de l'étape a échoué",
+          "Le titre associé à l'étape est vide",
+          "Les tâches après mise à jour de l'étape ont échoué",
+          "Une erreur s'est produite lors de la récupération de l'étape",
+          "Une erreur s'est produite lors de la récupération de la démarche",
           () => ({
             ...caminoError,
             status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
@@ -529,7 +565,7 @@ const getFlattenEtape = (
       }, {})
       return Effect.succeed(heritageContenu)
     }),
-    Effect.bind('fakeEtapeId', () => zodParseEffect(etapeIdValidator, 'newId')),
+    Effect.let('fakeEtapeId', () => newEtapeId('newId')),
     Effect.bind('flattenEtape', ({ perimetreInfos, heritageProps, heritageContenu, fakeEtapeId }) =>
       iTitreEtapeToFlattenEtape({
         ...etape,
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index aad3d8a55..40264ccf3 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -56,7 +56,7 @@ import { titreDemandeCreer } from '../api/rest/titre-demande'
 import { config } from '../config/index'
 import { addLog } from '../api/rest/logs.queries'
 import { HTTP_STATUS } from 'camino-common/src/http'
-import { zodParseEffect } from '../tools/fp-tools'
+import { zodParseEffectTyped } from '../tools/fp-tools'
 import { Cause, Effect, Exit, Option, pipe } from 'effect'
 
 interface IRestResolverResult {
@@ -265,12 +265,12 @@ export const restWithPool = (dbPool: Pool): Router => {
             const call = Effect.Do.pipe(
               Effect.bind('searchParams', () => {
                 if ('searchParams' in maRoute.newGet) {
-                  return zodParseEffect(maRoute.newGet.searchParams, req.query)
+                  return zodParseEffectTyped(maRoute.newGet.searchParams, req.query, 'Paramètres de recherche manquants ou invalides' as const)
                 }
 
                 return Effect.succeed(undefined)
               }),
-              Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)),
+              Effect.bind('params', () => zodParseEffectTyped(maRoute.params, req.params, 'Paramètres manquants ou invalides')),
               Effect.mapError(caminoError => {
                 return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST }
               }),
@@ -291,7 +291,7 @@ export const restWithPool = (dbPool: Pool): Router => {
               }),
               Effect.bind('parsedResult', ({ result }) =>
                 pipe(
-                  zodParseEffect(maRoute.newGet.output, result),
+                  zodParseEffectTyped(maRoute.newGet.output, result, 'Résultat de la requête invalide'),
                   Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR }))
                 )
               ),
@@ -342,8 +342,8 @@ export const restWithPool = (dbPool: Pool): Router => {
                   return Effect.fail({ message: 'Accès interdit', status: HTTP_STATUS.FORBIDDEN })
                 }
               }),
-              Effect.bind('body', () => zodParseEffect(maRoute.newPost.input, req.body)),
-              Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)),
+              Effect.bind('body', () => zodParseEffectTyped(maRoute.newPost.input, req.body, 'Corps de la requête manquant ou invalide')),
+              Effect.bind('params', () => zodParseEffectTyped(maRoute.params, req.params, 'Paramètres manquants ou invalides')),
               Effect.mapError(caminoError => {
                 if (!('status' in caminoError)) {
                   return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST }
@@ -364,7 +364,7 @@ export const restWithPool = (dbPool: Pool): Router => {
               ),
               Effect.bind('parsedResult', ({ result }) =>
                 pipe(
-                  zodParseEffect(maRoute.newPost.output, result),
+                  zodParseEffectTyped(maRoute.newPost.output, result, 'Résultat de la requête invalide'),
                   Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR }))
                 )
               ),
@@ -413,8 +413,8 @@ export const restWithPool = (dbPool: Pool): Router => {
                   return Effect.fail({ message: 'Accès interdit', status: HTTP_STATUS.FORBIDDEN })
                 }
               }),
-              Effect.bind('body', () => zodParseEffect(maRoute.newPut.input, req.body)),
-              Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)),
+              Effect.bind('body', () => zodParseEffectTyped(maRoute.newPut.input, req.body, 'Corps de la requête invalide')),
+              Effect.bind('params', () => zodParseEffectTyped(maRoute.params, req.params, 'Paramètres manquants ou invalides')),
               Effect.mapError(caminoError => {
                 if (!('status' in caminoError)) {
                   return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST }
@@ -435,7 +435,7 @@ export const restWithPool = (dbPool: Pool): Router => {
               ),
               Effect.bind('parsedResult', ({ result }) =>
                 pipe(
-                  zodParseEffect(maRoute.newPut.output, result),
+                  zodParseEffectTyped(maRoute.newPut.output, result, 'Résultat de la requête invalide'),
                   Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR }))
                 )
               ),
@@ -493,9 +493,14 @@ export const restWithPool = (dbPool: Pool): Router => {
                   return Effect.fail({ message: 'Accès interdit', status: HTTP_STATUS.FORBIDDEN })
                 }
               }),
-              Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)),
+              // TODO 2025-04-02 ici, si on ne met pas les params à any, on se retrouve avec une typescript union hell qui fait tout planter
+              Effect.bind('params', () => zodParseEffectTyped(maRoute.params, req.params as any, 'Paramètres manquants ou invalides')),
               Effect.mapError(caminoError => {
-                return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST }
+                if (!('status' in caminoError)) {
+                  return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST }
+                }
+
+                return caminoError
               }),
               // TODO 2024-06-26 ici, si on ne met pas les params et les searchParams à any, on se retrouve avec une typescript union hell qui fait tout planter
               Effect.bind<'result', { params: any; user: UserNotNull }, Option.Option<never>, CaminoApiError<string>, never>('result', ({ params, user }) => {
diff --git a/packages/ui/src/api/client-rest.ts b/packages/ui/src/api/client-rest.ts
index 529076e74..b5300bfec 100644
--- a/packages/ui/src/api/client-rest.ts
+++ b/packages/ui/src/api/client-rest.ts
@@ -190,7 +190,7 @@ export const newDeleteWithJson = async <T extends NewDeleteRestRoutes>(path: T,
 }
 
 /**
- * @deprecated use deleteWithJson
+ * @deprecated use newDeleteWithJson
  **/
 export const deleteWithJson = async <T extends DeleteRestRoutes>(path: T, params: CaminoRestParams<T>, searchParams: Record<string, string | string[]> = {}): Promise<void> =>
   callFetch(path, params, 'delete', searchParams)
diff --git a/packages/ui/src/components/etape/etape-api-client.ts b/packages/ui/src/components/etape/etape-api-client.ts
index ae97078e3..7f4ab41a8 100644
--- a/packages/ui/src/components/etape/etape-api-client.ts
+++ b/packages/ui/src/components/etape/etape-api-client.ts
@@ -1,5 +1,5 @@
 import { apiGraphQLFetch } from '@/api/_client'
-import { deleteWithJson, newGetWithJson, newPostWithJson, newPutWithJson } from '@/api/client-rest'
+import { newDeleteWithJson, newGetWithJson, newPostWithJson, newPutWithJson } from '@/api/client-rest'
 import { CaminoDate, caminoDateValidator } from 'camino-common/src/date'
 import { DemarcheId } from 'camino-common/src/demarche'
 import { entrepriseIdValidator } from 'camino-common/src/entreprise'
@@ -80,7 +80,7 @@ export type GetEtapeHeritagePotentiel = z.infer<typeof heritageValidator>
 export type CoreEtapeCreationOrModification = Pick<Nullable<FlattenEtape>, 'id' | 'slug'> & DistributiveOmit<FlattenEtape, 'id' | 'slug'>
 export interface EtapeApiClient {
   getEtapesTypesEtapesStatuts: (titreDemarcheId: DemarcheId, titreEtapeId: EtapeId | null, date: CaminoDate) => Promise<EtapeTypeEtapeStatutWithMainStep | CaminoError<string>>
-  deleteEtape: (titreEtapeId: EtapeId) => Promise<void>
+  deleteEtape: (titreEtapeId: EtapeId) => Promise<void | CaminoError<string>>
   deposeEtape: (titreEtapeId: EtapeId) => Promise<{ id: EtapeId } | CaminoError<string>>
   getEtapeDocumentsByEtapeId: (etapeId: EtapeId) => Promise<GetEtapeDocumentsByEtapeId | CaminoError<string>>
   getEtapeHeritagePotentiel: (etape: Pick<CoreEtapeCreationOrModification, 'id' | 'date' | 'typeId'>, titreDemarcheId: DemarcheId) => Promise<GetEtapeHeritagePotentiel>
@@ -93,13 +93,11 @@ export interface EtapeApiClient {
 export const etapeApiClient: EtapeApiClient = {
   getEtapesTypesEtapesStatuts: async (demarcheId, etapeId, date) => newGetWithJson('/rest/etapesTypes/:demarcheId/:date', { demarcheId, date }, etapeId ? { etapeId } : {}),
 
-  deleteEtape: async etapeIdOrSlug => {
-    await deleteWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug })
-  },
-  deposeEtape: async etapeId => newPutWithJson('/rest/etapes/:etapeId/depot', { etapeId }, {}),
+  deleteEtape: etapeIdOrSlug => newDeleteWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug }),
+  deposeEtape: etapeId => newPutWithJson('/rest/etapes/:etapeId/depot', { etapeId }, {}),
 
-  getEtapeDocumentsByEtapeId: async etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeDocuments', { etapeId }),
-  getEtapeAvisByEtapeId: async etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeAvis', { etapeId }),
+  getEtapeDocumentsByEtapeId: etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeDocuments', { etapeId }),
+  getEtapeAvisByEtapeId: etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeAvis', { etapeId }),
 
   getEtape: async etapeIdOrSlug => {
     const etape = await newGetWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug })
@@ -199,6 +197,6 @@ export const etapeApiClient: EtapeApiClient = {
     return heritageData
   },
 
-  etapeCreer: async etape => newPostWithJson('/rest/etapes', {}, restEtapeCreationValidator.parse(etape)),
-  etapeModifier: async etape => newPutWithJson('/rest/etapes', {}, restEtapeModificationValidator.parse(etape)),
+  etapeCreer: etape => newPostWithJson('/rest/etapes', {}, restEtapeCreationValidator.parse(etape)),
+  etapeModifier: etape => newPutWithJson('/rest/etapes', {}, restEtapeModificationValidator.parse(etape)),
 }
-- 
GitLab


From da1b3b02348f76bff7d65a1c5338e5c2fccbb8ee Mon Sep 17 00:00:00 2001
From: Anis Safine Laget <anis.safine@beta.gouv.fr>
Date: Wed, 2 Apr 2025 10:55:35 +0200
Subject: [PATCH 4/6] add test

---
 .../src/components/_ui/functional-popup.tsx   |  4 ++-
 .../demarche/remove-etape-popup.stories.tsx   | 21 ++++++++++++
 ...e-etape-popup.stories_snapshots_Error.html | 33 +++++++++++++++++++
 .../demarche/remove-etape-popup.tsx           |  4 +--
 packages/ui/src/components/titre.tsx          |  6 +++-
 5 files changed, 63 insertions(+), 5 deletions(-)
 create mode 100644 packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html

diff --git a/packages/ui/src/components/_ui/functional-popup.tsx b/packages/ui/src/components/_ui/functional-popup.tsx
index f47660e98..27e08a6e8 100644
--- a/packages/ui/src/components/_ui/functional-popup.tsx
+++ b/packages/ui/src/components/_ui/functional-popup.tsx
@@ -127,7 +127,9 @@ export const FunctionalPopup = defineComponent(<T,>(props: Props<T>) => {
                     <div class={fr.cx('fr-container')}>
                       {props.content()}
                       {validateProcess.value.status === 'ERROR' || validateProcess.value.status === 'NEW_ERROR' ? (
-                        <LoadingElement class={fr.cx('fr-mt-4v')} data={validateProcess.value} renderItem={() => null} />
+                        <>
+                          <LoadingElement class={fr.cx('fr-mt-4v')} data={validateProcess.value} renderItem={() => null} />
+                        </>
                       ) : null}
                     </div>
                   </div>
diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx
index 42ca2b6ab..6aeb1398a 100644
--- a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx
+++ b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx
@@ -30,3 +30,24 @@ export const Default: StoryFn = () => (
     close={close}
   />
 )
+
+export const Error: StoryFn = () => (
+  <RemoveEtapePopup
+    demarcheTypeId="oct"
+    etapeTypeId="mfr"
+    titreTypeId="arm"
+    id={etapeIdValidator.parse('etapeId')}
+    apiClient={{
+      deleteEtape(titreEtapeId) {
+        deleteEtape(titreEtapeId)
+
+        return Promise.resolve({
+          message: 'Ceci est une erreur',
+          detail: `Ceci est un détail d'erreur`,
+        })
+      },
+    }}
+    titreNom="Nouvelle espérance"
+    close={close}
+  />
+)
diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html b/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html
new file mode 100644
index 000000000..29e3fb1fc
--- /dev/null
+++ b/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html
@@ -0,0 +1,33 @@
+<!--teleport start-->
+<div>
+  <dialog id="monId" class="fr-modal fr-modal--opened" open="" aria-modal="true" role="dialog" aria-labelledby="monId-title" style="z-index: 1000001;">
+    <div class="fr-container fr-container--fluid fr-container-md">
+      <div class="fr-grid-row fr-grid-row--center">
+        <div class="fr-col-12 fr-col-md-8 fr-col-lg-6">
+          <div class="fr-modal__body">
+            <div class="fr-modal__header"><button class="fr-btn--close fr-btn" aria-controls="monId" title="Fermer">Fermer</button></div>
+            <div class="fr-modal__content">
+              <h1 id="monId-title" class="fr-modal__title"><span class="fr-icon-arrow-right-line fr-icon--lg" aria-hidden="true"></span>Suppression de l'étape</h1>
+              <div class="fr-container">
+                <div class="fr-alert fr-alert--warning">
+                  <p class="fr-alert__title fr-h4">Attention : cette opération est définitive et ne peut pas être annulée.</p>Souhaitez vous supprimer l'étape <span class="fr-text--bold">demande</span> de la démarche <span class="fr-text--bold">octroi</span> du titre <span class="fr-text--bold">Nouvelle espérance (autorisation de recherches)</span> ?
+                </div>
+                <!---->
+              </div>
+            </div>
+            <div class="fr-modal__footer">
+              <div style="display: flex; width: 100%; justify-content: end; align-items: center; gap: 1rem;">
+                <!---->
+                <ul class="fr-btns-group fr-btns-group--right fr-btns-group--inline-reverse fr-btns-group--inline-lg fr-btns-group--icon-left" style="width: auto;">
+                  <li><button class="fr-btn fr-btn--icon-left fr-icon-check-line fr-btn--primary fr-btn--md" title="Supprimer" aria-label="Supprimer" type="button">Supprimer</button></li>
+                  <li><button class="fr-btn fr-btn--icon-left fr-icon-arrow-go-back-fill fr-btn--secondary fr-btn--md" title="Annuler" aria-label="Annuler" aria-controls="monId" type="button">Annuler</button></li>
+                </ul>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </dialog>
+</div>
+<!--teleport end-->
\ No newline at end of file
diff --git a/packages/ui/src/components/demarche/remove-etape-popup.tsx b/packages/ui/src/components/demarche/remove-etape-popup.tsx
index 5164c5ed0..37dca1032 100644
--- a/packages/ui/src/components/demarche/remove-etape-popup.tsx
+++ b/packages/ui/src/components/demarche/remove-etape-popup.tsx
@@ -18,9 +18,7 @@ interface Props {
 }
 
 export const RemoveEtapePopup: FunctionalComponent<Props> = props => {
-  const deleteEtape = async () => {
-    await props.apiClient.deleteEtape(props.id)
-  }
+  const deleteEtape = () => props.apiClient.deleteEtape(props.id)
   const content = () => (
     <Alert
       type="warning"
diff --git a/packages/ui/src/components/titre.tsx b/packages/ui/src/components/titre.tsx
index b625a4bc8..c298cee79 100644
--- a/packages/ui/src/components/titre.tsx
+++ b/packages/ui/src/components/titre.tsx
@@ -237,7 +237,11 @@ export const PureTitre = defineComponent<Props>(props => {
     ...props.apiClient,
     deleteEtape: async titreEtapeId => {
       if (titreData.value.status === 'LOADED') {
-        await props.apiClient.deleteEtape(titreEtapeId)
+        const result = await props.apiClient.deleteEtape(titreEtapeId)
+        if (typeof result === 'object' && 'message' in result) {
+          return result
+        }
+
         await retrieveTitre()
       }
     },
-- 
GitLab


From 2fbbdf7043d7bdd69a1b7befbd8c2c8c7743dcf8 Mon Sep 17 00:00:00 2001
From: Anis Safine Laget <anis.safine@beta.gouv.fr>
Date: Wed, 2 Apr 2025 11:03:36 +0200
Subject: [PATCH 5/6] =?UTF-8?q?am=C3=A9liorations=20diverses?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../ui/src/components/_ui/functional-popup.tsx    |  4 +---
 .../demarche/remove-etape-popup.stories.tsx       | 15 +++++++++------
 2 files changed, 10 insertions(+), 9 deletions(-)

diff --git a/packages/ui/src/components/_ui/functional-popup.tsx b/packages/ui/src/components/_ui/functional-popup.tsx
index 27e08a6e8..f47660e98 100644
--- a/packages/ui/src/components/_ui/functional-popup.tsx
+++ b/packages/ui/src/components/_ui/functional-popup.tsx
@@ -127,9 +127,7 @@ export const FunctionalPopup = defineComponent(<T,>(props: Props<T>) => {
                     <div class={fr.cx('fr-container')}>
                       {props.content()}
                       {validateProcess.value.status === 'ERROR' || validateProcess.value.status === 'NEW_ERROR' ? (
-                        <>
-                          <LoadingElement class={fr.cx('fr-mt-4v')} data={validateProcess.value} renderItem={() => null} />
-                        </>
+                        <LoadingElement class={fr.cx('fr-mt-4v')} data={validateProcess.value} renderItem={() => null} />
                       ) : null}
                     </div>
                   </div>
diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx
index 6aeb1398a..7028cf101 100644
--- a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx
+++ b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx
@@ -2,6 +2,9 @@ import { action } from '@storybook/addon-actions'
 import { Meta, StoryFn } from '@storybook/vue3'
 import { RemoveEtapePopup } from './remove-etape-popup'
 import { etapeIdValidator } from 'camino-common/src/etape'
+import { DEMARCHES_TYPES_IDS } from 'camino-common/src/static/demarchesTypes'
+import { ETAPES_TYPES } from 'camino-common/src/static/etapesTypes'
+import { TITRES_TYPES_IDS } from 'camino-common/src/static/titresTypes'
 
 const meta: Meta = {
   title: 'Components/Demarche/RemoveEtapePopup',
@@ -15,9 +18,9 @@ const close = action('close')
 
 export const Default: StoryFn = () => (
   <RemoveEtapePopup
-    demarcheTypeId="oct"
-    etapeTypeId="mfr"
-    titreTypeId="arm"
+    demarcheTypeId={DEMARCHES_TYPES_IDS.Octroi}
+    etapeTypeId={ETAPES_TYPES.demande}
+    titreTypeId={TITRES_TYPES_IDS.AUTORISATION_DE_RECHERCHE_METAUX}
     id={etapeIdValidator.parse('etapeId')}
     apiClient={{
       deleteEtape(titreEtapeId) {
@@ -33,9 +36,9 @@ export const Default: StoryFn = () => (
 
 export const Error: StoryFn = () => (
   <RemoveEtapePopup
-    demarcheTypeId="oct"
-    etapeTypeId="mfr"
-    titreTypeId="arm"
+    demarcheTypeId={DEMARCHES_TYPES_IDS.Octroi}
+    etapeTypeId={ETAPES_TYPES.demande}
+    titreTypeId={TITRES_TYPES_IDS.AUTORISATION_DE_RECHERCHE_METAUX}
     id={etapeIdValidator.parse('etapeId')}
     apiClient={{
       deleteEtape(titreEtapeId) {
-- 
GitLab


From 8e3c1b74d1fd46dd89efee67c41fa0ef7ac12c92 Mon Sep 17 00:00:00 2001
From: Anis Safine Laget <anis.safine@beta.gouv.fr>
Date: Wed, 2 Apr 2025 11:07:58 +0200
Subject: [PATCH 6/6] rename story

---
 .../ui/src/components/demarche/remove-etape-popup.stories.tsx   | 2 +-
 ...html => remove-etape-popup.stories_snapshots_WithError.html} | 0
 2 files changed, 1 insertion(+), 1 deletion(-)
 rename packages/ui/src/components/demarche/{remove-etape-popup.stories_snapshots_Error.html => remove-etape-popup.stories_snapshots_WithError.html} (100%)

diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx
index 7028cf101..dcf4b1c4f 100644
--- a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx
+++ b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx
@@ -34,7 +34,7 @@ export const Default: StoryFn = () => (
   />
 )
 
-export const Error: StoryFn = () => (
+export const WithError: StoryFn = () => (
   <RemoveEtapePopup
     demarcheTypeId={DEMARCHES_TYPES_IDS.Octroi}
     etapeTypeId={ETAPES_TYPES.demande}
diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html b/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_WithError.html
similarity index 100%
rename from packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html
rename to packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_WithError.html
-- 
GitLab