From d86ebe7735b33d4218abdc41e776a571ce80dd0b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bitard=20Micha=C3=ABl?= <bitard.michael@gmail.com>
Date: Thu, 3 Apr 2025 11:56:33 +0200
Subject: [PATCH 1/8] chore(api): passe en effect la route pour supprimer un
 utilisateur

---
 .../api/rest/utilisateurs.test.integration.ts | 10 +-
 packages/api/src/api/rest/utilisateurs.ts     | 97 ++++++++++---------
 .../database/queries/utilisateurs.queries.ts  | 19 ++--
 packages/api/src/server/rest.ts               |  6 +-
 packages/common/src/rest.ts                   |  2 +-
 .../ui/src/components/utilisateur.stories.tsx |  2 +-
 packages/ui/src/components/utilisateur.tsx    | 17 ++--
 .../utilisateur/remove-popup.stories.tsx      | 11 ++-
 .../components/utilisateur/remove-popup.tsx   | 16 ++-
 .../utilisateur/utilisateur-api-client.ts     |  6 +-
 10 files changed, 104 insertions(+), 82 deletions(-)

diff --git a/packages/api/src/api/rest/utilisateurs.test.integration.ts b/packages/api/src/api/rest/utilisateurs.test.integration.ts
index ff81c9bc5..0cf80ab96 100644
--- a/packages/api/src/api/rest/utilisateurs.test.integration.ts
+++ b/packages/api/src/api/rest/utilisateurs.test.integration.ts
@@ -1,4 +1,4 @@
-import { restCall, restNewCall, restNewPostCall, restPostCall, userGenerate } from '../../../tests/_utils/index'
+import { restNewCall, restNewPostCall, restPostCall, userGenerate } from '../../../tests/_utils/index'
 import { dbManager } from '../../../tests/db-manager'
 import { Knex } from 'knex'
 import { expect, test, describe, afterAll, beforeAll, vi, beforeEach } from 'vitest'
@@ -97,7 +97,7 @@ describe('utilisateurModifier', () => {
 
 describe('utilisateurSupprimer', () => {
   test('ne peut pas supprimer un compte (utilisateur anonyme)', async () => {
-    const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('test') }, undefined)
+    const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('test') }, undefined)
     expect(tested.statusCode).toBe(500)
     expect(tested.body).toMatchInlineSnapshot(`
       {
@@ -112,7 +112,7 @@ describe('utilisateurSupprimer', () => {
     renewConfig()
     const user = await userGenerate(dbPool, { role: 'defaut' })
 
-    const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id: user.id }, { role: 'defaut' })
+    const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id: user.id }, { role: 'defaut' })
     expect(tested.statusCode).toBe(302)
     expect(tested.header.location).toBe(`${OAUTH_URL}/oauth2/sign_out`)
   })
@@ -129,12 +129,12 @@ describe('utilisateurSupprimer', () => {
       keycloakId: idUserKeycloakRecognised,
     })
 
-    const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id }, { role: 'super' })
+    const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id }, { role: 'super' })
     expect(tested.statusCode).toBe(204)
   })
 
   test('ne peut pas supprimer un utilisateur inexistant (utilisateur super)', async () => {
-    const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('not-existing') }, { role: 'super' })
+    const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('not-existing') }, { role: 'super' })
     expect(tested.statusCode).toBe(500)
     expect(tested.body).toMatchInlineSnapshot(`
       {
diff --git a/packages/api/src/api/rest/utilisateurs.ts b/packages/api/src/api/rest/utilisateurs.ts
index d6a30267b..e1c25aec9 100644
--- a/packages/api/src/api/rest/utilisateurs.ts
+++ b/packages/api/src/api/rest/utilisateurs.ts
@@ -1,7 +1,7 @@
 import { CaminoRequest, CustomResponse } from './express-type'
 import { CaminoApiError } from '../../types'
 import { HTTP_STATUS } from 'camino-common/src/http'
-import { User, UserNotNull, utilisateurIdValidator } from 'camino-common/src/roles'
+import { User, UserNotNull, UtilisateurId, utilisateurIdValidator } from 'camino-common/src/roles'
 import { utilisateursFormatTable } from './format/utilisateurs'
 import { tableConvert } from './_convert'
 import { fileNameCreate } from '../../tools/file-name-create'
@@ -16,6 +16,7 @@ import { Effect, Match } from 'effect'
 import { RestNewGetCall, RestNewPostCall } from '../../server/rest'
 import {
   getKeycloakIdByUserId,
+  GetKeycloakIdByUserIdErrors,
   getUtilisateurById,
   GetUtilisateurByIdErrors,
   getUtilisateursFilteredAndSorted,
@@ -96,54 +97,58 @@ const getKeycloakApiToken = async (): Promise<string> => {
   }
 }
 
-export const deleteUtilisateur =
-  (pool: Pool) =>
-  async (req: CaminoRequest, res: CustomResponse<void>): Promise<void> => {
-    const user = req.auth
-
-    if (!req.params.id) {
-      res.sendStatus(HTTP_STATUS.FORBIDDEN)
-    } else {
-      try {
-        const utilisateurId = utilisateurIdValidator.parse(req.params.id)
-
-        if (!canDeleteUtilisateur(user, utilisateurId)) {
-          throw new Error('droits insuffisants')
-        }
-
-        const utilisateurKeycloakId = await getKeycloakIdByUserId(pool, utilisateurId)
-        if (isNullOrUndefined(utilisateurKeycloakId)) {
-          throw new Error('aucun utilisateur avec cet id ou droits insuffisants pour voir cet utilisateur')
-        }
-
-        const authorizationToken = await getKeycloakApiToken()
+const droitsInsuffisantsPourSupprimerLUtilisateur = "Droits insuffisants pour supprimer l'utilisateur" as const
+const erreurLorsDeLaSuppressionDeLUtilisateur = "Erreur lors de la suppression de l'utilisateur" as const
 
-        const deleteFromKeycloak = await fetch(`${config().KEYCLOAK_URL}/admin/realms/Camino/users/${utilisateurKeycloakId}`, {
-          method: 'DELETE',
-          headers: {
-            authorization: `Bearer ${authorizationToken}`,
-          },
-        })
-        if (!deleteFromKeycloak.ok) {
-          throw new Error(`une erreur est apparue durant la suppression de l'utilisateur sur keycloak`)
-        }
-
-        await softDeleteUtilisateur(pool, utilisateurId)
-
-        if (isNotNullNorUndefined(user) && user.id === req.params.id) {
-          const uiUrl = config().OAUTH_URL
-          const oauthLogoutUrl = new URL(`${uiUrl}/oauth2/sign_out`)
-          res.redirect(oauthLogoutUrl.href)
-        } else {
-          res.sendStatus(HTTP_STATUS.NO_CONTENT)
-        }
-      } catch (e: any) {
-        console.error(e)
+type DeleteUtilisateurErrors = GetKeycloakIdByUserIdErrors | typeof droitsInsuffisantsPourSupprimerLUtilisateur | typeof erreurLorsDeLaSuppressionDeLUtilisateur
+export const deleteUtilisateur: RestNewGetCall<'/rest/utilisateurs/:id/delete'> = (rootPipe): Effect.Effect<{ id: UtilisateurId }, CaminoApiError<DeleteUtilisateurErrors>> =>
+  rootPipe.pipe(
+    Effect.let('utilisateurId', ({ params }) => params.id),
+    Effect.filterOrFail(
+      ({ user, utilisateurId }) => canDeleteUtilisateur(user, utilisateurId),
+      () => ({ message: droitsInsuffisantsPourSupprimerLUtilisateur })
+    ),
+    Effect.bind('keycloakId', ({ pool, utilisateurId }) => getKeycloakIdByUserId(pool, utilisateurId)),
+    Effect.tap(({ keycloakId }) =>
+      Effect.tryPromise({
+        try: async () => {
+          const authorizationToken = await getKeycloakApiToken()
+
+          const deleteFromKeycloak = await fetch(`${config().KEYCLOAK_URL}/admin/realms/Camino/users/${keycloakId}`, {
+            method: 'DELETE',
+            headers: {
+              authorization: `Bearer ${authorizationToken}`,
+            },
+          })
+          if (!deleteFromKeycloak.ok) {
+            throw new Error(`une erreur est apparue durant la suppression de l'utilisateur sur keycloak`)
+          }
+        },
+        catch: e => ({ message: erreurLorsDeLaSuppressionDeLUtilisateur, extra: e }),
+      })
+    ),
+    Effect.tap(({ pool, utilisateurId }) => softDeleteUtilisateur(pool, utilisateurId)),
 
-        res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: e.message ?? `Une erreur s'est produite` })
+    Effect.tap(({ utilisateurId, redirect, user }) => {
+      if (isNotNullNorUndefined(user) && user.id === utilisateurId) {
+        const uiUrl = config().OAUTH_URL
+        const oauthLogoutUrl = new URL(`${uiUrl}/oauth2/sign_out`)
+        redirect(oauthLogoutUrl.href)
       }
-    }
-  }
+    }),
+    Effect.map(({ utilisateurId }) => ({ id: utilisateurId })),
+    Effect.mapError(caminoError =>
+      Match.value(caminoError.message).pipe(
+        Match.when("Droits insuffisants pour supprimer l'utilisateur", () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })),
+        Match.when("Erreur lors de la suppression de l'utilisateur", () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })),
+        Match.whenOr("Impossible de trouver l'utilisateur", "Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', () => ({
+          ...caminoError,
+          status: HTTP_STATUS.BAD_REQUEST,
+        })),
+        Match.exhaustive
+      )
+    )
+  )
 
 export const moi: RestNewGetCall<'/moi'> = (rootPipe): Effect.Effect<User, CaminoApiError<GetUtilisateurByIdErrors>> => {
   return rootPipe.pipe(
diff --git a/packages/api/src/database/queries/utilisateurs.queries.ts b/packages/api/src/database/queries/utilisateurs.queries.ts
index 93408db21..c293cdb7d 100644
--- a/packages/api/src/database/queries/utilisateurs.queries.ts
+++ b/packages/api/src/database/queries/utilisateurs.queries.ts
@@ -202,11 +202,17 @@ const getUtilisateurByIdDb = sql<Redefine<IGetUtilisateurByIdDbQuery, { id: Util
 
 const getKeycloakIdByUserIdValidator = z.object({ keycloak_id: z.string() })
 type GetKeycloakIdByUser = z.infer<typeof getKeycloakIdByUserIdValidator>
-export const getKeycloakIdByUserId = async (pool: Pool, utilisateurId: UtilisateurId): Promise<string | null> => {
-  const result = await dbQueryAndValidate(getKeycloakIdByUserIdDb, { id: utilisateurId }, pool, getKeycloakIdByUserIdValidator)
 
-  return isNullOrUndefinedOrEmpty(result) ? null : result[0].keycloak_id
-}
+const utilisateurNonTrouve = "Impossible de trouver l'utilisateur" as const
+export type GetKeycloakIdByUserIdErrors = EffectDbQueryAndValidateErrors | typeof utilisateurNonTrouve
+export const getKeycloakIdByUserId = (pool: Pool, utilisateurId: UtilisateurId): Effect.Effect<string, CaminoError<GetKeycloakIdByUserIdErrors>> =>
+  effectDbQueryAndValidate(getKeycloakIdByUserIdDb, { id: utilisateurId }, pool, getKeycloakIdByUserIdValidator).pipe(
+    Effect.filterOrFail(
+      result => isNotNullNorUndefinedNorEmpty(result),
+      () => ({ message: utilisateurNonTrouve })
+    ),
+    Effect.map(result => result[0].keycloak_id)
+  )
 
 const getKeycloakIdByUserIdDb = sql<Redefine<IGetKeycloakIdByUserIdDbQuery, { id: UtilisateurId }, GetKeycloakIdByUser>>`
   select
@@ -359,9 +365,8 @@ const updateUtilisateurRoleDb = sql<Redefine<IUpdateUtilisateurRoleDbQuery, Pick
   update utilisateurs set role = $role!, administration_id = $administrationId! where id = $id!
   `
 
-export const softDeleteUtilisateur = async (pool: Pool, id: UtilisateurId): Promise<void> => {
-  await dbQueryAndValidate(softDeleteUtilisateurDb, { id }, pool, z.void())
-}
+export const softDeleteUtilisateur = (pool: Pool, id: UtilisateurId): Effect.Effect<void, CaminoError<EffectDbQueryAndValidateErrors>> =>
+  effectDbQueryAndValidate(softDeleteUtilisateurDb, { id }, pool, z.void())
 
 const softDeleteUtilisateurDb = sql<Redefine<ISoftDeleteUtilisateurDbQuery, { id: UtilisateurId }, void>>`
   update utilisateurs set keycloak_id = null, email = null where id = $id!
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index 6b8f2c1ed..44e502f51 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -120,6 +120,7 @@ export type RestNewGetCall<Route extends NewGetRestRoutes> = (
       params: z.infer<CaminoRestRoutesType[Route]['params']>
       searchParams: SearchParams<Route>
       cookie: CookieParams
+      redirect: (value: string) => void
     },
     never,
     never
@@ -211,7 +212,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k
   '/rest/demarches/:demarcheId/resultatMiseEnConcurrence': { newGetCall: getResultatEnConcurrence, ...CaminoRestRoutes['/rest/demarches/:demarcheId/resultatMiseEnConcurrence'] },
   '/rest/utilisateur/generateQgisToken': { newPostCall: generateQgisToken, ...CaminoRestRoutes['/rest/utilisateur/generateQgisToken'] },
   '/rest/utilisateurs/:id/permission': { postCall: updateUtilisateurPermission, ...CaminoRestRoutes['/rest/utilisateurs/:id/permission'] },
-  '/rest/utilisateurs/:id/delete': { getCall: deleteUtilisateur, ...CaminoRestRoutes['/rest/utilisateurs/:id/delete'] },
+  '/rest/utilisateurs/:id/delete': { newGetCall: deleteUtilisateur, ...CaminoRestRoutes['/rest/utilisateurs/:id/delete'] },
   '/rest/utilisateurs/:id': { newGetCall: getUtilisateur, ...CaminoRestRoutes['/rest/utilisateurs/:id'] },
   '/rest/utilisateurs': { newGetCall: getUtilisateurs, ...CaminoRestRoutes['/rest/utilisateurs'] },
   '/rest/entreprises/:entrepriseId/fiscalite/:annee': { getCall: fiscalite, ...CaminoRestRoutes['/rest/entreprises/:entrepriseId/fiscalite/:annee'] }, // UNTESTED YET
@@ -287,7 +288,8 @@ export const restWithPool = (dbPool: Pool): Router => {
                     Effect.let('cookie', () => ({
                       clearConnectedCookie: () => res.clearCookie('shouldBeConnected'),
                       addConnectedCookie: () => res.cookie('shouldBeConnected', 'anyValueIsGood, We just check the presence of this cookie'),
-                    }))
+                    })),
+                    Effect.let('redirect', () => (redirect: string) => res.redirect(redirect))
                   )
                 )
               }),
diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts
index 96a20d901..e2e3bc2e4 100644
--- a/packages/common/src/rest.ts
+++ b/packages/common/src/rest.ts
@@ -165,7 +165,7 @@ export const CaminoRestRoutes = {
   '/moi': { params: noParamsValidator, newGet: { output: userValidator } },
   '/rest/utilisateurs/:id': { params: utilisateurIdParamsValidator, newGet: { output: userNotNullValidator } },
   // On passe par un http get plutot qu'un http delete car nous terminons par une redirection vers la deconnexion de oauth2, qui se traduit mal sur certains navigateurs et essaie de faire un delete sur une route get
-  '/rest/utilisateurs/:id/delete': { params: utilisateurIdParamsValidator, get: { output: z.void() } },
+  '/rest/utilisateurs/:id/delete': { params: utilisateurIdParamsValidator, newGet: { output: z.object({ id: utilisateurIdValidator }) } },
   '/rest/utilisateurs/:id/permission': { params: utilisateurIdParamsValidator, post: { input: utilisateurToEdit, output: z.void() } },
   '/rest/utilisateurs': { params: noParamsValidator, newGet: { output: utilisateursTableValidator, searchParams: utilisateursSearchParamsValidator } },
   '/rest/statistiques/minerauxMetauxMetropole': { params: noParamsValidator, get: { output: statistiquesMinerauxMetauxMetropoleValidator } },
diff --git a/packages/ui/src/components/utilisateur.stories.tsx b/packages/ui/src/components/utilisateur.stories.tsx
index d6a7d85ca..1110f24da 100644
--- a/packages/ui/src/components/utilisateur.stories.tsx
+++ b/packages/ui/src/components/utilisateur.stories.tsx
@@ -31,7 +31,7 @@ const apiClientMock: Props['apiClient'] = {
   removeUtilisateur: params => {
     deleteUtilisateur(params)
 
-    return Promise.resolve()
+    return Promise.resolve({ id: params })
   },
   updateUtilisateur: params => {
     updateUtilisateur(params)
diff --git a/packages/ui/src/components/utilisateur.tsx b/packages/ui/src/components/utilisateur.tsx
index c171c9f75..2f591617a 100644
--- a/packages/ui/src/components/utilisateur.tsx
+++ b/packages/ui/src/components/utilisateur.tsx
@@ -30,9 +30,14 @@ export const Utilisateur = defineComponent({
       if (isMe) {
         // TODO 2023-10-23 type window.location pour s'appuyer sur nos routes rest et pas sur n'importe quoi
         window.location.replace(`/apiUrl/rest/utilisateurs/${userId}/delete`)
+        return { id: userId }
       } else {
-        await utilisateurApiClient.removeUtilisateur(userId)
+        const value = await utilisateurApiClient.removeUtilisateur(userId)
+        if ('message' in value) {
+          return value
+        }
         router.push({ name: 'utilisateurs', params: {} })
+        return value
       }
     }
     const updateUtilisateur = async (utilisateur: UtilisateurToEdit) => {
@@ -162,15 +167,7 @@ export const PureUtilisateur = defineComponent<Props>(props => {
       />
 
       {removePopup.value && utilisateur.value.status === 'LOADED' ? (
-        <RemovePopup
-          close={() => (removePopup.value = !removePopup.value)}
-          utilisateur={utilisateur.value.value}
-          deleteUser={async () => {
-            if (utilisateur.value.status === 'LOADED') {
-              await props.apiClient.removeUtilisateur(utilisateur.value.value.id)
-            }
-          }}
-        />
+        <RemovePopup close={() => (removePopup.value = !removePopup.value)} utilisateur={utilisateur.value.value} apiClient={props.apiClient} />
       ) : null}
     </div>
   )
diff --git a/packages/ui/src/components/utilisateur/remove-popup.stories.tsx b/packages/ui/src/components/utilisateur/remove-popup.stories.tsx
index c4847e81f..934735712 100644
--- a/packages/ui/src/components/utilisateur/remove-popup.stories.tsx
+++ b/packages/ui/src/components/utilisateur/remove-popup.stories.tsx
@@ -1,6 +1,7 @@
 import { action } from '@storybook/addon-actions'
 import { Meta, StoryFn } from '@storybook/vue3'
 import { RemovePopup } from './remove-popup'
+import { utilisateurIdValidator } from 'camino-common/src/roles'
 
 const meta: Meta = {
   title: 'Components/Utilisateur/RemovePopup',
@@ -13,11 +14,13 @@ const close = action('close')
 
 export const Default: StoryFn = () => (
   <RemovePopup
-    utilisateur={{ nom: 'Nom', prenom: 'Prénom' }}
-    deleteUser={() => {
-      deleteUser()
+    utilisateur={{ id: utilisateurIdValidator.parse('id'), nom: 'Nom', prenom: 'Prénom' }}
+    apiClient={{
+      removeUtilisateur: utilisateurId => {
+        deleteUser(utilisateurId)
 
-      return Promise.resolve()
+        return Promise.resolve({ id: utilisateurId })
+      },
     }}
     close={close}
   />
diff --git a/packages/ui/src/components/utilisateur/remove-popup.tsx b/packages/ui/src/components/utilisateur/remove-popup.tsx
index 24e216cc9..3fa9da80e 100644
--- a/packages/ui/src/components/utilisateur/remove-popup.tsx
+++ b/packages/ui/src/components/utilisateur/remove-popup.tsx
@@ -1,10 +1,12 @@
 import { FunctionalComponent } from 'vue'
 import { FunctionalPopup } from '../_ui/functional-popup'
 import { Alert } from '@/components/_ui/alert'
+import { UtilisateurApiClient } from './utilisateur-api-client'
+import { UtilisateurId } from 'camino-common/src/roles'
 interface Props {
-  utilisateur: { nom: string; prenom: string }
+  utilisateur: { id: UtilisateurId; nom: string; prenom: string }
   close: () => void
-  deleteUser: () => Promise<void>
+  apiClient: Pick<UtilisateurApiClient, 'removeUtilisateur'>
 }
 
 export const RemovePopup: FunctionalComponent<Props> = props => {
@@ -24,5 +26,13 @@ export const RemovePopup: FunctionalComponent<Props> = props => {
     />
   )
 
-  return <FunctionalPopup title={`Suppression du compte utilisateur`} content={content} close={props.close} validate={{ action: props.deleteUser, text: 'Supprimer' }} canValidate={true} />
+  return (
+    <FunctionalPopup
+      title={`Suppression du compte utilisateur`}
+      content={content}
+      close={props.close}
+      validate={{ action: () => props.apiClient.removeUtilisateur(props.utilisateur.id), text: 'Supprimer' }}
+      canValidate={true}
+    />
+  )
 }
diff --git a/packages/ui/src/components/utilisateur/utilisateur-api-client.ts b/packages/ui/src/components/utilisateur/utilisateur-api-client.ts
index 56322beb5..80a305985 100644
--- a/packages/ui/src/components/utilisateur/utilisateur-api-client.ts
+++ b/packages/ui/src/components/utilisateur/utilisateur-api-client.ts
@@ -1,12 +1,12 @@
 import { QGISTokenRest, UtilisateurToEdit, UtilisateursSearchParamsInput, UtilisateursTable } from 'camino-common/src/utilisateur'
 
-import { getWithJson, newGetWithJson, newPostWithJson, postWithJson } from '../../api/client-rest'
+import { newGetWithJson, newPostWithJson, postWithJson } from '../../api/client-rest'
 import { UserNotNull, UtilisateurId } from 'camino-common/src/roles'
 import { CaminoError } from 'camino-common/src/zod-tools'
 
 export interface UtilisateurApiClient {
   getUtilisateur: (userId: UtilisateurId) => Promise<CaminoError<string> | UserNotNull>
-  removeUtilisateur: (userId: UtilisateurId) => Promise<void>
+  removeUtilisateur: (userId: UtilisateurId) => Promise<{ id: UtilisateurId } | CaminoError<string>>
   updateUtilisateur: (user: UtilisateurToEdit) => Promise<void>
   getQGISToken: () => Promise<CaminoError<string> | QGISTokenRest>
   getUtilisateurs: (params: UtilisateursSearchParamsInput) => Promise<CaminoError<string> | UtilisateursTable>
@@ -19,7 +19,7 @@ export const utilisateurApiClient: UtilisateurApiClient = {
   getUtilisateur: async (userId: UtilisateurId) => {
     return newGetWithJson('/rest/utilisateurs/:id', { id: userId })
   },
-  removeUtilisateur: async (userId: UtilisateurId) => getWithJson('/rest/utilisateurs/:id/delete', { id: userId }),
+  removeUtilisateur: async (userId: UtilisateurId) => newGetWithJson('/rest/utilisateurs/:id/delete', { id: userId }),
   updateUtilisateur: async (utilisateur: UtilisateurToEdit) => postWithJson('/rest/utilisateurs/:id/permission', { id: utilisateur.id }, utilisateur),
   getQGISToken: async () => newPostWithJson('/rest/utilisateur/generateQgisToken', {}, {}),
 }
-- 
GitLab


From f47fc1e73dc464f6c91091dd6f919932e2b241fb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bitard=20Micha=C3=ABl?= <bitard.michael@gmail.com>
Date: Thu, 3 Apr 2025 12:03:55 +0200
Subject: [PATCH 2/8] fix autoredirect

---
 .../src/api/rest/utilisateurs.test.integration.ts  | 14 +++++++++-----
 packages/api/src/server/rest.ts                    | 10 ++++++----
 2 files changed, 15 insertions(+), 9 deletions(-)

diff --git a/packages/api/src/api/rest/utilisateurs.test.integration.ts b/packages/api/src/api/rest/utilisateurs.test.integration.ts
index 0cf80ab96..957b821ae 100644
--- a/packages/api/src/api/rest/utilisateurs.test.integration.ts
+++ b/packages/api/src/api/rest/utilisateurs.test.integration.ts
@@ -98,10 +98,10 @@ describe('utilisateurModifier', () => {
 describe('utilisateurSupprimer', () => {
   test('ne peut pas supprimer un compte (utilisateur anonyme)', async () => {
     const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('test') }, undefined)
-    expect(tested.statusCode).toBe(500)
     expect(tested.body).toMatchInlineSnapshot(`
       {
-        "error": "droits insuffisants",
+        "message": "Droits insuffisants pour supprimer l'utilisateur",
+        "status": 403,
       }
     `)
   })
@@ -130,15 +130,19 @@ describe('utilisateurSupprimer', () => {
     })
 
     const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id }, { role: 'super' })
-    expect(tested.statusCode).toBe(204)
+    expect(tested.body).toMatchInlineSnapshot(`
+      {
+        "id": "user-todelete",
+      }
+    `)
   })
 
   test('ne peut pas supprimer un utilisateur inexistant (utilisateur super)', async () => {
     const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('not-existing') }, { role: 'super' })
-    expect(tested.statusCode).toBe(500)
     expect(tested.body).toMatchInlineSnapshot(`
       {
-        "error": "aucun utilisateur avec cet id ou droits insuffisants pour voir cet utilisateur",
+        "message": "Impossible de trouver l'utilisateur",
+        "status": 400,
       }
     `)
   })
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index 44e502f51..d21947faa 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -305,10 +305,12 @@ export const restWithPool = (dbPool: Pool): Router => {
                   res.status(caminoError.status).json(caminoError)
                 },
                 onSuccess: ({ parsedResult }) => {
-                  if (isNullOrUndefined(parsedResult)) {
-                    res.sendStatus(HTTP_STATUS.NO_CONTENT)
-                  } else {
-                    res.json(parsedResult)
+                  if (!res.writableEnded) {
+                    if (isNullOrUndefined(parsedResult)) {
+                      res.sendStatus(HTTP_STATUS.NO_CONTENT)
+                    } else {
+                      res.json(parsedResult)
+                    }
                   }
                 },
               }),
-- 
GitLab


From c6617fedc7fe68fb53c9056e94780f022cb73d51 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bitard=20Micha=C3=ABl?= <bitard.michael@gmail.com>
Date: Thu, 3 Apr 2025 14:48:06 +0200
Subject: [PATCH 3/8] try somithing

---
 .../utilisateur-updation-validate.ts          | 94 +++++++++++++------
 1 file changed, 64 insertions(+), 30 deletions(-)

diff --git a/packages/api/src/business/validations/utilisateur-updation-validate.ts b/packages/api/src/business/validations/utilisateur-updation-validate.ts
index ee5144ab8..7d6ef5102 100644
--- a/packages/api/src/business/validations/utilisateur-updation-validate.ts
+++ b/packages/api/src/business/validations/utilisateur-updation-validate.ts
@@ -15,6 +15,9 @@ import { canEditPermission, getAssignableRoles } from 'camino-common/src/permiss
 import { equalStringArrays } from '../../tools/index'
 import { isAdministrationId } from 'camino-common/src/static/administrations'
 import { UtilisateurToEdit } from 'camino-common/src/utilisateur'
+import { CaminoError } from 'camino-common/src/zod-tools'
+import { Effect, Option } from 'effect'
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools'
 
 const userIsCorrect = (utilisateur: UtilisateurToEdit): boolean => {
   if (!isRole(utilisateur.role)) {
@@ -34,34 +37,65 @@ const userIsCorrect = (utilisateur: UtilisateurToEdit): boolean => {
   return false
 }
 
-export const utilisateurUpdationValidate = (user: UserNotNull, utilisateur: UtilisateurToEdit, utilisateurOld: User): void => {
-  if (!userIsCorrect(utilisateur)) {
-    throw new Error('utilisateur incorrect')
-  }
-
-  if (!utilisateurOld) {
-    throw new Error("l'utilisateur n'existe pas")
-  }
-
-  if (!canEditPermission(user, utilisateurOld) || !canEditPermission(user, utilisateur as unknown as UserNotNull)) {
-    throw new Error('droits insuffisants')
-  }
-
-  if (utilisateur.role !== utilisateurOld.role) {
-    if (user.id === utilisateur.id) {
-      throw new Error('impossible de modifier son propre rôle')
-    } else if (!getAssignableRoles(user).includes(utilisateur.role)) {
-      throw new Error('droits insuffisants pour modifier les rôles')
-    }
-  }
-
-  if (!isSuper(user)) {
-    if (isAdministration(utilisateurOld) && utilisateur.administrationId && utilisateur.administrationId !== utilisateurOld.administrationId) {
-      throw new Error('droits insuffisants pour modifier les administrations')
-    }
-
-    if (!isAdministrationAdmin(user) && isEntrepriseOrBureauDEtude(utilisateurOld) && !equalStringArrays(utilisateurOld.entrepriseIds.toSorted(), utilisateur.entrepriseIds.toSorted())) {
-      throw new Error('droits insuffisants pour modifier les entreprises')
-    }
-  }
+const utilisateurIncorrect = 'utilisateur incorrect' as const
+const utilisateurNonExistant = "l'utilisateur n'existe pas" as const
+const droitsInsuffisants = 'droits insuffisants' as const
+const impossibleDeModifierSonRole = 'impossible de modifier son propre rôle' as const
+const impossibleDeModifierLesRoles = 'droits insuffisants pour modifier les rôles' as const
+const droitsInsuffisantsPourModifierAdministration = 'droits insuffisants pour modifier les administrations' as const
+const droitsInsuffisantPourModifierEntreprises = 'droits insuffisants pour modifier les entreprises' as const
+export type UtilisateurUpdationValidateErrors =
+  | typeof utilisateurIncorrect
+  | typeof utilisateurNonExistant
+  | typeof droitsInsuffisants
+  | typeof impossibleDeModifierLesRoles
+  | typeof droitsInsuffisantsPourModifierAdministration
+  | typeof impossibleDeModifierSonRole
+  | typeof droitsInsuffisantPourModifierEntreprises
+export const utilisateurUpdationValidate = (user: UserNotNull, newUtilisateur: UtilisateurToEdit, utilisateurOld: User): Effect.Effect<void, CaminoError<UtilisateurUpdationValidateErrors>> => {
+  return Effect.Do.pipe(
+    Effect.map(() => utilisateurOld),
+    Effect.filterOrFail(
+      () => userIsCorrect(newUtilisateur),
+      () => ({ message: utilisateurIncorrect })
+    ),
+    Effect.filterOrFail(
+      (oldUser): oldUser is UserNotNull => isNotNullNorUndefined(oldUser),
+      () => ({ message: utilisateurNonExistant })
+    ),
+    Effect.filterOrFail(
+      oldUser => canEditPermission(user, oldUser) && canEditPermission(user, newUtilisateur as unknown as UserNotNull),
+      () => ({ message: droitsInsuffisants })
+    ),
+    Effect.tap(oldUser => {
+      if (newUtilisateur.role !== oldUser.role) {
+        return Effect.Do.pipe(
+          Effect.filterOrFail(
+            () => user.id !== newUtilisateur.id,
+            () => ({ message: impossibleDeModifierSonRole })
+          ),
+          Effect.filterOrFail(
+            () => getAssignableRoles(user).includes(newUtilisateur.role),
+            () => ({ message: impossibleDeModifierLesRoles })
+          )
+        )
+      }
+      return Effect.succeed(oldUser)
+    }),
+    Effect.tap(oldUser => {
+      if (!isSuper(user)) {
+        return Effect.Do.pipe(
+          Effect.filterOrFail(
+            () => !isAdministration(oldUser) || newUtilisateur.administrationId === oldUser.administrationId,
+            () => ({ message: droitsInsuffisantsPourModifierAdministration })
+          ),
+          Effect.filterOrFail(
+            () => isAdministrationAdmin(user) || (isEntrepriseOrBureauDEtude(utilisateurOld) && equalStringArrays(utilisateurOld.entrepriseIds.toSorted(), newUtilisateur.entrepriseIds.toSorted())),
+            () => ({ message: droitsInsuffisantPourModifierEntreprises })
+          )
+        )
+      }
+      return Effect.succeed(Option.none)
+    })
+  )
 }
-- 
GitLab


From 9b80d94bb2bcde5efc2997b8171bc7c2952cfc9e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bitard=20Micha=C3=ABl?= <bitard.michael@gmail.com>
Date: Thu, 3 Apr 2025 15:53:15 +0200
Subject: [PATCH 4/8] rework utilisateurs permissions

---
 .../src/api/rest/etapes.test.integration.ts   |   4 +-
 .../api/rest/utilisateurs.test.integration.ts |  19 +-
 packages/api/src/api/rest/utilisateurs.ts     |  93 ++++---
 ...es-etapes-consentement.test.integration.ts |   4 +-
 .../utilisateur-updation-validate.test.ts     | 248 ++++++++++--------
 .../database/queries/utilisateurs.queries.ts  |  39 +--
 packages/api/src/server/rest.ts               |   2 +-
 packages/api/src/tools/fp-tools.ts            |  12 +-
 packages/common/src/rest.ts                   |   2 +-
 packages/ui/src/components/utilisateur.tsx    |  11 +-
 .../utilisateur/permission-edit.stories.tsx   |   8 +-
 .../utilisateur/permission-edit.tsx           |  35 ++-
 .../utilisateur/utilisateur-api-client.ts     |   6 +-
 13 files changed, 276 insertions(+), 207 deletions(-)

diff --git a/packages/api/src/api/rest/etapes.test.integration.ts b/packages/api/src/api/rest/etapes.test.integration.ts
index f68ea39b8..2ada5d99e 100644
--- a/packages/api/src/api/rest/etapes.test.integration.ts
+++ b/packages/api/src/api/rest/etapes.test.integration.ts
@@ -438,9 +438,7 @@ describe('getEtapeAvis', () => {
     await expect(callAndExit(insertEtapeAvisWithLargeObjectId(dbPool, etapeId, { ...avis, description: '' }, etapeAvisIdValidator.parse('avisId'), largeObjectIdValidator.parse(42)))).rejects
       .toThrowErrorMatchingInlineSnapshot(`
       [Error: Impossible d'exécuter la requête dans la base de données
-       extra: new row for relation "etape_avis" violates check constraint "etape_avis_description_required"
-      detail: undefined
-       zod: undefined]
+       extra: new row for relation "etape_avis" violates check constraint "etape_avis_description_required"]
     `)
 
     await callAndExit(insertEtapeAvisWithLargeObjectId(dbPool, etapeId, avis, etapeAvisIdValidator.parse('avisId'), largeObjectIdValidator.parse(42)))
diff --git a/packages/api/src/api/rest/utilisateurs.test.integration.ts b/packages/api/src/api/rest/utilisateurs.test.integration.ts
index 957b821ae..b48ba5cf9 100644
--- a/packages/api/src/api/rest/utilisateurs.test.integration.ts
+++ b/packages/api/src/api/rest/utilisateurs.test.integration.ts
@@ -1,4 +1,4 @@
-import { restNewCall, restNewPostCall, restPostCall, userGenerate } from '../../../tests/_utils/index'
+import { restNewCall, restNewPostCall, userGenerate } from '../../../tests/_utils/index'
 import { dbManager } from '../../../tests/db-manager'
 import { Knex } from 'knex'
 import { expect, test, describe, afterAll, beforeAll, vi, beforeEach } from 'vitest'
@@ -67,9 +67,14 @@ describe('utilisateurModifier', () => {
       entrepriseIds: [],
       administrationId: null,
     }
-    const tested = await restPostCall(dbPool, '/rest/utilisateurs/:id/permission', { id: utilisateurToEdit.id }, undefined, utilisateurToEdit)
+    const tested = await restNewPostCall(dbPool, '/rest/utilisateurs/:id/permission', { id: utilisateurToEdit.id }, undefined, utilisateurToEdit)
 
-    expect(tested.statusCode).toBe(403)
+    expect(tested.body).toMatchInlineSnapshot(`
+      {
+        "message": "Accès interdit",
+        "status": 403,
+      }
+    `)
   })
 
   test("peut modifier le rôle d'un compte utilisateur", async () => {
@@ -81,7 +86,7 @@ describe('utilisateurModifier', () => {
       entrepriseIds: [],
       administrationId: 'aut-97300-01',
     }
-    const tested = await restPostCall(
+    const tested = await restNewPostCall(
       dbPool,
       '/rest/utilisateurs/:id/permission',
       { id: userToEdit.id },
@@ -91,7 +96,11 @@ describe('utilisateurModifier', () => {
       utilisateurToEdit
     )
 
-    expect(tested.statusCode).toBe(204)
+    expect(tested.body).toMatchInlineSnapshot(`
+      {
+        "id": "defaut-user",
+      }
+    `)
   })
 })
 
diff --git a/packages/api/src/api/rest/utilisateurs.ts b/packages/api/src/api/rest/utilisateurs.ts
index e1c25aec9..a8a6120ea 100644
--- a/packages/api/src/api/rest/utilisateurs.ts
+++ b/packages/api/src/api/rest/utilisateurs.ts
@@ -1,13 +1,12 @@
-import { CaminoRequest, CustomResponse } from './express-type'
 import { CaminoApiError } from '../../types'
 import { HTTP_STATUS } from 'camino-common/src/http'
-import { User, UserNotNull, UtilisateurId, utilisateurIdValidator } from 'camino-common/src/roles'
+import { User, UserNotNull, UtilisateurId } from 'camino-common/src/roles'
 import { utilisateursFormatTable } from './format/utilisateurs'
 import { tableConvert } from './_convert'
 import { fileNameCreate } from '../../tools/file-name-create'
-import { QGISTokenRest, qgisTokenValidator, utilisateursSearchParamsValidator, UtilisateursTable, utilisateurToEdit } from 'camino-common/src/utilisateur'
+import { QGISTokenRest, qgisTokenValidator, utilisateursSearchParamsValidator, UtilisateursTable } from 'camino-common/src/utilisateur'
 import { idGenerate } from '../../database/models/_format/id-create'
-import { utilisateurUpdationValidate } from '../../business/validations/utilisateur-updation-validate'
+import { utilisateurUpdationValidate, UtilisateurUpdationValidateErrors } from '../../business/validations/utilisateur-updation-validate'
 import { canDeleteUtilisateur } from 'camino-common/src/permissions/utilisateurs'
 import { Pool } from 'pg'
 import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools'
@@ -16,51 +15,50 @@ import { Effect, Match } from 'effect'
 import { RestNewGetCall, RestNewPostCall } from '../../server/rest'
 import {
   getKeycloakIdByUserId,
-  GetKeycloakIdByUserIdErrors,
-  getUtilisateurById,
-  GetUtilisateurByIdErrors,
+  GetKeycloakIdByUserIdErrors, GetUtilisateurByIdErrors,
   getUtilisateursFilteredAndSorted,
+  GetUtilisateursFilteredAndSortedErrors,
   newGetUtilisateurById,
   softDeleteUtilisateur,
-  updateUtilisateurRole,
+  updateUtilisateurRole
 } from '../../database/queries/utilisateurs.queries'
 import { EffectDbQueryAndValidateErrors } from '../../pg-database'
-import { callAndExit, shortCircuitError, zodParseEffect, ZodUnparseable } from '../../tools/fp-tools'
+import { callAndExit, shortCircuitError, zodParseEffectTyped } from '../../tools/fp-tools'
 import { z } from 'zod'
 import { getEntreprises } from './entreprises.queries'
 import { fetch } from 'undici'
 import { updateQgisToken } from './utilisateurs.queries'
 
-export const updateUtilisateurPermission =
-  (pool: Pool) =>
-  async (req: CaminoRequest, res: CustomResponse<void>): Promise<void> => {
-    const user = req.auth
-
-    if (!req.params.id) {
-      res.sendStatus(HTTP_STATUS.FORBIDDEN)
-    } else {
-      const userId = utilisateurIdValidator.parse(req.params.id)
-      const utilisateurOld = await getUtilisateurById(pool, userId, user)
-
-      if (!user || !utilisateurOld) {
-        res.sendStatus(HTTP_STATUS.FORBIDDEN)
-      } else {
-        try {
-          const utilisateur = utilisateurToEdit.parse(req.body)
-
-          utilisateurUpdationValidate(user, utilisateur, utilisateurOld)
-
-          await updateUtilisateurRole(pool, utilisateur)
-
-          res.sendStatus(HTTP_STATUS.NO_CONTENT)
-        } catch (e) {
-          console.error(e)
-
-          res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR)
-        }
-      }
-    }
-  }
+type UpdateUtilisateurPermissionErrors = EffectDbQueryAndValidateErrors | GetUtilisateurByIdErrors | UtilisateurUpdationValidateErrors
+export const updateUtilisateurPermission: RestNewPostCall<'/rest/utilisateurs/:id/permission'> =
+  (rootPipe): Effect.Effect<{id: UtilisateurId}, CaminoApiError<UpdateUtilisateurPermissionErrors>> =>
+  rootPipe.pipe(
+    Effect.bind('utilisateurOld', ({params, pool, user}) => newGetUtilisateurById(pool, params.id, user)),
+    Effect.tap(({user, body, utilisateurOld}) => utilisateurUpdationValidate(user, body, utilisateurOld)),
+    Effect.tap(({ body, pool}) => updateUtilisateurRole(pool, body)),
+    Effect.map(({params}) => ({id: params.id})),
+    Effect.mapError(caminoError =>
+      Match.value(caminoError.message).pipe(
+        Match.when("L'utilisateur est invalide", () => ({...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR})),
+        Match.whenOr(
+          "droits insuffisants",
+          "Impossible d'exécuter la requête dans la base de données",
+          'Les données en base ne correspondent pas à ce qui est attendu',
+          'utilisateur incorrect' ,
+          "l'utilisateur n'existe pas" ,
+          'droits insuffisants' ,
+          'impossible de modifier son propre rôle' ,
+          'droits insuffisants pour modifier les rôles' ,
+          'droits insuffisants pour modifier les administrations' ,
+          'droits insuffisants pour modifier les entreprises' ,
+          () => ({
+          ...caminoError,
+          status: HTTP_STATUS.BAD_REQUEST,
+        })),
+        Match.exhaustive
+      )
+    )
+  )
 
 export type KeycloakAccessTokenResponse = { access_token: string }
 
@@ -165,7 +163,7 @@ export const moi: RestNewGetCall<'/moi'> = (rootPipe): Effect.Effect<User, Camin
       Match.value(caminoError.message).pipe(
         Match.when('droits insuffisants', () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })),
         Match.when('Les données en base ne correspondent pas à ce qui est attendu', () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })),
-        Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Problème de validation de données', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
+        Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'L\'utilisateur est invalide', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
 
         Match.exhaustive
       )
@@ -173,14 +171,15 @@ export const moi: RestNewGetCall<'/moi'> = (rootPipe): Effect.Effect<User, Camin
   )
 }
 
-export const generateQgisToken: RestNewPostCall<'/rest/utilisateur/generateQgisToken'> = (rootPipe): Effect.Effect<QGISTokenRest, CaminoApiError<EffectDbQueryAndValidateErrors | ZodUnparseable>> =>
+const impossibleDeGenererUnTokenQgis = "impossible de générer un token Qgis" as const
+export const generateQgisToken: RestNewPostCall<'/rest/utilisateur/generateQgisToken'> = (rootPipe): Effect.Effect<QGISTokenRest, CaminoApiError<EffectDbQueryAndValidateErrors | typeof impossibleDeGenererUnTokenQgis>> =>
   rootPipe.pipe(
-    Effect.bind('token', () => zodParseEffect(qgisTokenValidator, idGenerate())),
+    Effect.bind('token', () => zodParseEffectTyped(qgisTokenValidator, idGenerate(), impossibleDeGenererUnTokenQgis)),
     Effect.tap(({ token, pool, user }) => updateQgisToken(pool, user, token)),
     Effect.map(({ token, user }) => ({ token, url: `https://${user.email.replace('@', '%40')}:${token}@${config().API_HOST}/titres_qgis?` })),
     Effect.mapError(caminoError =>
       Match.value(caminoError.message).pipe(
-        Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Problème de validation de données', 'Les données en base ne correspondent pas à ce qui est attendu', () => ({
+        Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', 'impossible de générer un token Qgis', () => ({
           ...caminoError,
           status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
         })),
@@ -216,7 +215,7 @@ export const utilisateurs =
       : null
   }
 
-type GetUtilisateursError = EffectDbQueryAndValidateErrors | ZodUnparseable | "Impossible d'accéder à la liste des utilisateurs" | 'droits insuffisants'
+type GetUtilisateursError = GetUtilisateursFilteredAndSortedErrors
 
 export const getUtilisateurs: RestNewGetCall<'/rest/utilisateurs'> = (rootPipe): Effect.Effect<UtilisateursTable, CaminoApiError<GetUtilisateursError>> => {
   return rootPipe.pipe(
@@ -234,14 +233,14 @@ export const getUtilisateurs: RestNewGetCall<'/rest/utilisateurs'> = (rootPipe):
           status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
         })),
         Match.when('droits insuffisants', () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })),
-        Match.when('Problème de validation de données', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
+        Match.when('L\'utilisateur est invalide', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
         Match.exhaustive
       )
     )
   )
 }
 
-type GetUtilisateurError = EffectDbQueryAndValidateErrors | ZodUnparseable | 'droits insuffisants'
+type GetUtilisateurError = GetUtilisateurByIdErrors | 'droits insuffisants'
 export const getUtilisateur: RestNewGetCall<'/rest/utilisateurs/:id'> = (rootPipe): Effect.Effect<UserNotNull, CaminoApiError<GetUtilisateurError>> => {
   return rootPipe.pipe(
     Effect.flatMap(({ pool, params, user }) => newGetUtilisateurById(pool, params.id, user)),
@@ -252,7 +251,7 @@ export const getUtilisateur: RestNewGetCall<'/rest/utilisateurs/:id'> = (rootPip
           status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
         })),
         Match.when('droits insuffisants', () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })),
-        Match.when('Problème de validation de données', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
+        Match.when('L\'utilisateur est invalide', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
         Match.exhaustive
       )
     )
diff --git a/packages/api/src/business/processes/titres-etapes-consentement.test.integration.ts b/packages/api/src/business/processes/titres-etapes-consentement.test.integration.ts
index 4cf90bbb7..85475834d 100644
--- a/packages/api/src/business/processes/titres-etapes-consentement.test.integration.ts
+++ b/packages/api/src/business/processes/titres-etapes-consentement.test.integration.ts
@@ -34,9 +34,7 @@ describe('etapeConsentementUpdate', () => {
     const etapeId = newEtapeId()
     await expect(callAndExit(etapeConsentementUpdate(dbPool, etapeId))).rejects.toThrowErrorMatchingInlineSnapshot(`
       [Error: Élément non trouvé dans la base de données
-       extra: [object Object]
-      detail: undefined
-       zod: undefined]
+       extra: [object Object]]
     `)
   })
 
diff --git a/packages/api/src/business/validations/utilisateur-updation-validate.test.ts b/packages/api/src/business/validations/utilisateur-updation-validate.test.ts
index 2cfad9d23..508b629c8 100644
--- a/packages/api/src/business/validations/utilisateur-updation-validate.test.ts
+++ b/packages/api/src/business/validations/utilisateur-updation-validate.test.ts
@@ -2,9 +2,10 @@ import { newEntrepriseId } from 'camino-common/src/entreprise'
 import { Role, UserNotNull } from 'camino-common/src/roles'
 import { AdministrationId } from 'camino-common/src/static/administrations'
 import { testBlankUser } from 'camino-common/src/tests-utils'
-import { test, expect } from 'vitest'
+import { test, expect, vi } from 'vitest'
 import { utilisateurUpdationValidate } from './utilisateur-updation-validate'
 import { newUtilisateurId } from '../../database/models/_format/id-create'
+import { callAndExit } from '../../tools/fp-tools'
 
 const users: Record<Role, UserNotNull> = {
   super: { ...testBlankUser, role: 'super' },
@@ -30,124 +31,161 @@ const users: Record<Role, UserNotNull> = {
 
 const fakeAdministrationId = 'fakeAdminId' as AdministrationId
 
-test('utilisateurUpdationValidate privilege escalation forbidden', () => {
-  expect(() => utilisateurUpdationValidate(users.defaut, { ...users.defaut, role: 'super', administrationId: null, entrepriseIds: [] }, users.defaut)).toThrowErrorMatchingInlineSnapshot(
-    `[Error: droits insuffisants]`
-  )
-  expect(() => utilisateurUpdationValidate(users.admin, { ...users.admin, role: 'super', entrepriseIds: [], administrationId: null }, users.admin)).toThrowErrorMatchingInlineSnapshot(
-    `[Error: droits insuffisants]`
-  )
-  expect(() => utilisateurUpdationValidate(users.lecteur, { ...users.lecteur, role: 'super', entrepriseIds: [], administrationId: null }, users.lecteur)).toThrowErrorMatchingInlineSnapshot(
-    `[Error: droits insuffisants]`
-  )
-  expect(() => utilisateurUpdationValidate(users.editeur, { ...users.editeur, role: 'super', entrepriseIds: [], administrationId: null }, users.editeur)).toThrowErrorMatchingInlineSnapshot(
-    `[Error: droits insuffisants]`
-  )
-  expect(() => utilisateurUpdationValidate(users.entreprise, { ...users.entreprise, role: 'super', entrepriseIds: [], administrationId: null }, users.entreprise)).toThrowErrorMatchingInlineSnapshot(
-    `[Error: droits insuffisants]`
-  )
-  expect(() =>
-    utilisateurUpdationValidate(users["bureau d'études"], { ...users["bureau d'études"], role: 'super', entrepriseIds: [], administrationId: null }, users.entreprise)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+console.error = vi.fn()
+test('utilisateurUpdationValidate privilege escalation forbidden', async () => {
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.defaut, { ...users.defaut, role: 'super', administrationId: null, entrepriseIds: [] }, users.defaut))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.admin, { ...users.admin, role: 'super', entrepriseIds: [], administrationId: null }, users.admin))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.lecteur, { ...users.lecteur, role: 'super', entrepriseIds: [], administrationId: null }, users.lecteur))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.editeur, { ...users.editeur, role: 'super', entrepriseIds: [], administrationId: null }, users.editeur))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.entreprise, { ...users.entreprise, role: 'super', entrepriseIds: [], administrationId: null }, users.entreprise))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users["bureau d'études"], { ...users["bureau d'études"], role: 'super', entrepriseIds: [], administrationId: null }, users.entreprise))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
 
-  expect(() =>
-    utilisateurUpdationValidate(users.editeur, { ...users.editeur, role: 'entreprise', administrationId: null, entrepriseIds: [newEntrepriseId('id')] }, users.editeur)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
-  expect(() =>
-    utilisateurUpdationValidate(users.defaut, { ...users.defaut, role: 'entreprise', administrationId: null, entrepriseIds: [newEntrepriseId('id')] }, users.defaut)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.editeur, { ...users.editeur, role: 'entreprise', administrationId: null, entrepriseIds: [newEntrepriseId('id')] }, users.editeur))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.defaut, { ...users.defaut, role: 'entreprise', administrationId: null, entrepriseIds: [newEntrepriseId('id')] }, users.defaut))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
 })
 
-test('utilisateurUpdationValidate incorrect users throw error', () => {
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', administrationId: null, entrepriseIds: [] }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`)
+test('utilisateurUpdationValidate incorrect users throw error', async () => {
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', administrationId: null, entrepriseIds: [] }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`)
 
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', administrationId: 'aut-97300-01', entrepriseIds: [] }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', administrationId: 'aut-97300-01', entrepriseIds: [] }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
 
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', administrationId: null, entrepriseIds: [] }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`)
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', administrationId: 'aut-97300-01', entrepriseIds: [] }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', administrationId: null, entrepriseIds: [] }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`)
+  await expect(
+    callAndExit(
+      utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined)
+    )
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', administrationId: 'aut-97300-01', entrepriseIds: [] }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
 
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: null, entrepriseIds: [] }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: fakeAdministrationId, entrepriseIds: [] }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: null, entrepriseIds: [] }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: fakeAdministrationId, entrepriseIds: [] }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
 
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: null, entrepriseIds: [] }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: null, entrepriseIds: [] }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
-  expect(() =>
-    utilisateurUpdationValidate(
-      users.super,
-      { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: fakeAdministrationId, entrepriseIds: [newEntrepriseId('entrepriseId')] },
-      undefined
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: null, entrepriseIds: [] }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: null, entrepriseIds: [] }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  await expect(
+    callAndExit(
+      utilisateurUpdationValidate(
+        users.super,
+        { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: fakeAdministrationId, entrepriseIds: [newEntrepriseId('entrepriseId')] },
+        undefined
+      )
     )
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`)
 
-  expect(() =>
-    utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', entrepriseIds: [], administrationId: null }, undefined)
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', entrepriseIds: [], administrationId: null }, undefined))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`)
 
-  expect(() =>
-    utilisateurUpdationValidate(
-      users.admin,
-      { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-97300-01', entrepriseIds: [] },
-      { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'admin', administrationId: 'aut-97300-01' }
+  await expect(
+    callAndExit(
+      utilisateurUpdationValidate(
+        users.admin,
+        { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-97300-01', entrepriseIds: [] },
+        { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'admin', administrationId: 'aut-97300-01' }
+      )
     )
-  ).not.toThrowError()
-  expect(() =>
-    utilisateurUpdationValidate(
-      users.admin,
-      { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: 'aut-97300-01', entrepriseIds: [] },
-      { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-97300-01' }
+  ).resolves.toMatchInlineSnapshot(`
+    {
+      "administrationId": "aut-97300-01",
+      "email": "email@gmail.com",
+      "id": "fakeId",
+      "nom": "nom",
+      "prenom": "prenom",
+      "role": "admin",
+      "telephone_fixe": null,
+      "telephone_mobile": null,
+    }
+  `)
+  await expect(
+    callAndExit(
+      utilisateurUpdationValidate(
+        users.admin,
+        { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: 'aut-97300-01', entrepriseIds: [] },
+        { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-97300-01' }
+      )
     )
-  ).not.toThrowError()
-  expect(() =>
-    utilisateurUpdationValidate(
-      users.admin,
-      { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-mrae-guyane-01', entrepriseIds: [] },
-      { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-97300-01' }
+  ).resolves.toMatchInlineSnapshot(`
+    {
+      "administrationId": "aut-97300-01",
+      "email": "email@gmail.com",
+      "id": "fakeId",
+      "nom": "nom",
+      "prenom": "prenom",
+      "role": "editeur",
+      "telephone_fixe": null,
+      "telephone_mobile": null,
+    }
+  `)
+  await expect(
+    callAndExit(
+      utilisateurUpdationValidate(
+        users.admin,
+        { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-mrae-guyane-01', entrepriseIds: [] },
+        { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-97300-01' }
+      )
     )
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
-  expect(() =>
-    utilisateurUpdationValidate(
-      users.admin,
-      { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-97300-01', entrepriseIds: [] },
-      { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-mrae-guyane-01' }
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(
+      utilisateurUpdationValidate(
+        users.admin,
+        { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-97300-01', entrepriseIds: [] },
+        { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-mrae-guyane-01' }
+      )
     )
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
 
-  expect(() => utilisateurUpdationValidate(users.editeur, { ...users.editeur, administrationId: 'dea-reunion-01', entrepriseIds: [] }, { ...users.editeur })).toThrowErrorMatchingInlineSnapshot(
-    `[Error: droits insuffisants]`
-  )
-  expect(() => utilisateurUpdationValidate(users.lecteur, { ...users.lecteur, administrationId: 'dea-reunion-01', entrepriseIds: [] }, { ...users.lecteur })).toThrowErrorMatchingInlineSnapshot(
-    `[Error: droits insuffisants]`
-  )
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.editeur, { ...users.editeur, administrationId: 'dea-reunion-01', entrepriseIds: [] }, { ...users.editeur }))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.lecteur, { ...users.lecteur, administrationId: 'dea-reunion-01', entrepriseIds: [] }, { ...users.lecteur }))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
 
-  expect(() =>
-    utilisateurUpdationValidate(users.entreprise, { ...users.entreprise, administrationId: null, entrepriseIds: [newEntrepriseId('newEntreprise')] }, { ...users.entreprise })
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
-  expect(() =>
-    utilisateurUpdationValidate(users["bureau d'études"], { ...users["bureau d'études"], administrationId: null, entrepriseIds: [newEntrepriseId('newEntreprise')] }, { ...users["bureau d'études"] })
-  ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(utilisateurUpdationValidate(users.entreprise, { ...users.entreprise, administrationId: null, entrepriseIds: [newEntrepriseId('newEntreprise')] }, { ...users.entreprise }))
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
+  await expect(
+    callAndExit(
+      utilisateurUpdationValidate(users["bureau d'études"], { ...users["bureau d'études"], administrationId: null, entrepriseIds: [newEntrepriseId('newEntreprise')] }, { ...users["bureau d'études"] })
+    )
+  ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`)
 })
diff --git a/packages/api/src/database/queries/utilisateurs.queries.ts b/packages/api/src/database/queries/utilisateurs.queries.ts
index c293cdb7d..33d86b318 100644
--- a/packages/api/src/database/queries/utilisateurs.queries.ts
+++ b/packages/api/src/database/queries/utilisateurs.queries.ts
@@ -1,5 +1,5 @@
 import { sql } from '@pgtyped/runtime'
-import { Effect, pipe } from 'effect'
+import { Effect, Option, pipe } from 'effect'
 import { EffectDbQueryAndValidateErrors, Redefine, dbQueryAndValidate, effectDbQueryAndValidate } from '../../pg-database'
 import {
   AdminUserNotNull,
@@ -34,7 +34,7 @@ import {
   IUpdateUtilisateurDbQuery,
   IUpdateUtilisateurRoleDbQuery,
 } from './utilisateurs.queries.types'
-import { ZodUnparseable, callAndExit, zodParseEffect } from '../../tools/fp-tools'
+import { callAndExit, zodParseEffectTyped } from '../../tools/fp-tools'
 import { NonEmptyArray, Nullable, isNotNullNorUndefinedNorEmpty, isNullOrUndefinedOrEmpty } from 'camino-common/src/typescript-tools'
 import { EntrepriseId, entrepriseIdValidator } from 'camino-common/src/entreprise'
 import { CaminoDate } from 'camino-common/src/date'
@@ -53,7 +53,7 @@ const getUtilisateursValidator = z.object({
 })
 export type GetUtilisateur = z.infer<typeof getUtilisateursValidator>
 
-type GetUtilisateursFilteredAndSortedErrors = EffectDbQueryAndValidateErrors | ZodUnparseable | 'droits insuffisants'
+export type GetUtilisateursFilteredAndSortedErrors = EffectDbQueryAndValidateErrors | 'droits insuffisants' | typeof utilisateurInvalid
 export const getUtilisateursFilteredAndSorted = (pool: Pool, user: User, searchParams: UtilisateursSearchParams): Effect.Effect<UserNotNull[], CaminoError<GetUtilisateursFilteredAndSortedErrors>> => {
   return Effect.Do.pipe(
     Effect.filterOrFail(
@@ -69,7 +69,7 @@ export const getUtilisateursFilteredAndSorted = (pool: Pool, user: User, searchP
     Effect.flatMap(utilisateurs => {
       return Effect.forEach(utilisateurs, u => {
         return pipe(
-          zodParseEffect(userNotNullValidator, userDbToUser(u)),
+          zodParseEffectTyped(userNotNullValidator, userDbToUser(u) as UserNotNull, utilisateurInvalid),
           Effect.mapError(error => {
             return { ...error, extra: { email: u.email } }
           })
@@ -167,20 +167,21 @@ const userDbToUser = (
   return { ...user, prenom: user.prenom ?? '', entrepriseIds: user.entreprise_ids ?? [], administrationId: user.administration_id }
 }
 
-export type GetUtilisateurByIdErrors = 'droits insuffisants' | EffectDbQueryAndValidateErrors | ZodUnparseable
+const utilisateurInvalid = "L'utilisateur est invalide" as const
+export type GetUtilisateurByIdErrors = 'droits insuffisants' | EffectDbQueryAndValidateErrors | typeof utilisateurInvalid
 export const newGetUtilisateurById = (pool: Pool, id: UtilisateurId, user: User): Effect.Effect<UserNotNull, CaminoError<GetUtilisateurByIdErrors>> => {
   return pipe(
     effectDbQueryAndValidate(getUtilisateurByIdDb, { id }, pool, getUtilisateursValidator),
     Effect.filterOrFail(
       utilisateurs => isNotNullNorUndefinedNorEmpty(utilisateurs),
-      () => ({ message: 'droits insuffisants' as const })
+      () => ({ message: 'droits insuffisants' as const, detail: 'La liste des utilisateurs retournée par la base est vide' })
     ),
     Effect.flatMap(utilisateurs => {
-      return zodParseEffect(userNotNullValidator, userDbToUser(utilisateurs[0]))
+      return zodParseEffectTyped(userNotNullValidator, userDbToUser(utilisateurs[0]) as UserNotNull, utilisateurInvalid)
     }),
     Effect.filterOrFail(
       utilisateur => canReadUtilisateur(user, utilisateur),
-      () => ({ message: 'droits insuffisants' as const })
+      () => ({ message: 'droits insuffisants' as const, detail: 'Permissions insuffisantes pour accéder aux détails de cet utilisateur' })
     )
   )
 }
@@ -349,17 +350,17 @@ const updateUtilisateurDb = sql<Redefine<IUpdateUtilisateurDbQuery, Pick<UserNot
   `
 
 type UpdateUtilisateurRole = Pick<UserNotNull, 'id' | 'role'> & Nullable<Pick<AdminUserNotNull, 'administrationId'>> & Pick<EntrepriseUserNotNull, 'entrepriseIds'>
-export const updateUtilisateurRole = async (pool: Pool, user: UpdateUtilisateurRole): Promise<void> => {
-  await dbQueryAndValidate(updateUtilisateurRoleDb, user, pool, z.void())
-
-  await dbQueryAndValidate(deleteUtilisateurEntrepriseDb, { utilisateur_id: user.id }, pool, z.void())
-
-  if (isEntrepriseOrBureauDetudeRole(user.role)) {
-    for (const entreprise_id of user.entrepriseIds) {
-      await dbQueryAndValidate(createUtilisateurEntrepriseDb, { utilisateur_id: user.id, entreprise_id }, pool, z.void())
-    }
-  }
-}
+export const updateUtilisateurRole = (pool: Pool, user: UpdateUtilisateurRole): Effect.Effect<void, CaminoError<EffectDbQueryAndValidateErrors>> =>
+  Effect.Do.pipe(
+    Effect.tap(() => effectDbQueryAndValidate(updateUtilisateurRoleDb, user, pool, z.void())),
+    Effect.tap(() => effectDbQueryAndValidate(deleteUtilisateurEntrepriseDb, { utilisateur_id: user.id }, pool, z.void())),
+    Effect.tap(() => {
+      if (isEntrepriseOrBureauDetudeRole(user.role)) {
+        return Effect.forEach(user.entrepriseIds, entreprise_id => effectDbQueryAndValidate(createUtilisateurEntrepriseDb, { utilisateur_id: user.id, entreprise_id }, pool, z.void()))
+      }
+      return Effect.succeed(Option.none)
+    })
+  )
 
 const updateUtilisateurRoleDb = sql<Redefine<IUpdateUtilisateurRoleDbQuery, Pick<UserNotNull, 'id' | 'role'> & Nullable<Pick<AdminUserNotNull, 'administrationId'>>, void>>`
   update utilisateurs set role = $role!, administration_id = $administrationId! where id = $id!
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index d21947faa..c981b4ea8 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -211,7 +211,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k
   '/rest/demarches/:demarcheId/miseEnConcurrence': { newGetCall: getDemarchesEnConcurrence, ...CaminoRestRoutes['/rest/demarches/:demarcheId/miseEnConcurrence'] },
   '/rest/demarches/:demarcheId/resultatMiseEnConcurrence': { newGetCall: getResultatEnConcurrence, ...CaminoRestRoutes['/rest/demarches/:demarcheId/resultatMiseEnConcurrence'] },
   '/rest/utilisateur/generateQgisToken': { newPostCall: generateQgisToken, ...CaminoRestRoutes['/rest/utilisateur/generateQgisToken'] },
-  '/rest/utilisateurs/:id/permission': { postCall: updateUtilisateurPermission, ...CaminoRestRoutes['/rest/utilisateurs/:id/permission'] },
+  '/rest/utilisateurs/:id/permission': { newPostCall: updateUtilisateurPermission, ...CaminoRestRoutes['/rest/utilisateurs/:id/permission'] },
   '/rest/utilisateurs/:id/delete': { newGetCall: deleteUtilisateur, ...CaminoRestRoutes['/rest/utilisateurs/:id/delete'] },
   '/rest/utilisateurs/:id': { newGetCall: getUtilisateur, ...CaminoRestRoutes['/rest/utilisateurs/:id'] },
   '/rest/utilisateurs': { newGetCall: getUtilisateurs, ...CaminoRestRoutes['/rest/utilisateurs'] },
diff --git a/packages/api/src/tools/fp-tools.ts b/packages/api/src/tools/fp-tools.ts
index 73202de02..5ced5c460 100644
--- a/packages/api/src/tools/fp-tools.ts
+++ b/packages/api/src/tools/fp-tools.ts
@@ -52,7 +52,17 @@ export const callAndExit = async <A>(toCall: Effect.Effect<A, CaminoError<string
   } else {
     if (Cause.isFailType(pipeline.cause)) {
       console.error(pipeline.cause.error)
-      throw new Error(`${pipeline.cause.error.message}\n extra: ${pipeline.cause.error.extra}\ndetail: ${pipeline.cause.error.detail}\n zod: ${pipeline.cause.error.zodErrorReadableMessage}`)
+      let errorMessage = pipeline.cause.error.message
+      if (isNotNullNorUndefined(pipeline.cause.error.extra)) {
+        errorMessage += `\n extra: ${pipeline.cause.error.extra}`
+      }
+      if (isNotNullNorUndefined(pipeline.cause.error.detail)) {
+        errorMessage += `\n detail: ${pipeline.cause.error.detail}`
+      }
+      if (isNotNullNorUndefined(pipeline.cause.error.zodErrorReadableMessage)) {
+        errorMessage += `\n zod: ${pipeline.cause.error.zodErrorReadableMessage}`
+      }
+      throw new Error(errorMessage)
     } else {
       throw new Error(`Unexpected error ${pipeline.cause}`)
     }
diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts
index e2e3bc2e4..25a204457 100644
--- a/packages/common/src/rest.ts
+++ b/packages/common/src/rest.ts
@@ -166,7 +166,7 @@ export const CaminoRestRoutes = {
   '/rest/utilisateurs/:id': { params: utilisateurIdParamsValidator, newGet: { output: userNotNullValidator } },
   // On passe par un http get plutot qu'un http delete car nous terminons par une redirection vers la deconnexion de oauth2, qui se traduit mal sur certains navigateurs et essaie de faire un delete sur une route get
   '/rest/utilisateurs/:id/delete': { params: utilisateurIdParamsValidator, newGet: { output: z.object({ id: utilisateurIdValidator }) } },
-  '/rest/utilisateurs/:id/permission': { params: utilisateurIdParamsValidator, post: { input: utilisateurToEdit, output: z.void() } },
+  '/rest/utilisateurs/:id/permission': { params: utilisateurIdParamsValidator, newPost: { input: utilisateurToEdit, output: z.object({id: utilisateurIdValidator}) } },
   '/rest/utilisateurs': { params: noParamsValidator, newGet: { output: utilisateursTableValidator, searchParams: utilisateursSearchParamsValidator } },
   '/rest/statistiques/minerauxMetauxMetropole': { params: noParamsValidator, get: { output: statistiquesMinerauxMetauxMetropoleValidator } },
   '/rest/statistiques/guyane': { params: noParamsValidator, get: { output: statistiquesGuyaneDataValidator } },
diff --git a/packages/ui/src/components/utilisateur.tsx b/packages/ui/src/components/utilisateur.tsx
index 2f591617a..99a837b2f 100644
--- a/packages/ui/src/components/utilisateur.tsx
+++ b/packages/ui/src/components/utilisateur.tsx
@@ -40,9 +40,6 @@ export const Utilisateur = defineComponent({
         return value
       }
     }
-    const updateUtilisateur = async (utilisateur: UtilisateurToEdit) => {
-      await utilisateurApiClient.updateUtilisateur(utilisateur)
-    }
     const passwordUpdate = () => {
       window.location.replace('/apiUrl/changerMotDePasse')
     }
@@ -62,7 +59,7 @@ export const Utilisateur = defineComponent({
         {utilisateurId.value ? (
           <PureUtilisateur
             passwordUpdate={passwordUpdate}
-            apiClient={{ ...utilisateurApiClient, updateUtilisateur, removeUtilisateur: deleteUtilisateur }}
+            apiClient={{ ...utilisateurApiClient, removeUtilisateur: deleteUtilisateur }}
             utilisateurId={utilisateurId.value}
             user={user}
             entreprises={entreprises.value}
@@ -115,8 +112,12 @@ export const PureUtilisateur = defineComponent<Props>(props => {
   }
 
   const updateUtilisateur = async (utilisateur: UtilisateurToEdit) => {
-    await props.apiClient.updateUtilisateur(utilisateur)
+    const result = await props.apiClient.updateUtilisateur(utilisateur)
+    if ('message' in result) {
+      return result
+    }
     await get()
+    return result
   }
 
   return () => (
diff --git a/packages/ui/src/components/utilisateur/permission-edit.stories.tsx b/packages/ui/src/components/utilisateur/permission-edit.stories.tsx
index 5196442b9..ae84f7d78 100644
--- a/packages/ui/src/components/utilisateur/permission-edit.stories.tsx
+++ b/packages/ui/src/components/utilisateur/permission-edit.stories.tsx
@@ -23,7 +23,7 @@ export const Default: StoryFn = () => (
         new Promise(resolve =>
           setTimeout(() => {
             update(user)
-            resolve()
+            resolve({ id: user.id })
           }, 1000)
         ),
     }}
@@ -39,7 +39,7 @@ export const Administration: StoryFn = () => (
         new Promise(resolve =>
           setTimeout(() => {
             update(user)
-            resolve()
+            resolve({ id: user.id })
           }, 1000)
         ),
     }}
@@ -63,7 +63,7 @@ export const Entreprise: StoryFn = () => (
         new Promise(resolve =>
           setTimeout(() => {
             update(user)
-            resolve()
+            resolve({ id: user.id })
           }, 1000)
         ),
     }}
@@ -80,7 +80,7 @@ export const UserAdminCanEditDefautIntoLecteur: StoryFn = () => (
         new Promise(resolve =>
           setTimeout(() => {
             update(user)
-            resolve()
+            resolve({ id: user.id })
           }, 1000)
         ),
     }}
diff --git a/packages/ui/src/components/utilisateur/permission-edit.tsx b/packages/ui/src/components/utilisateur/permission-edit.tsx
index 1b9114bf1..564034641 100644
--- a/packages/ui/src/components/utilisateur/permission-edit.tsx
+++ b/packages/ui/src/components/utilisateur/permission-edit.tsx
@@ -1,4 +1,4 @@
-import { isEntrepriseOrBureauDetudeRole, Role, User, UserNotNull, isAdministration, isSuper, isEntrepriseOrBureauDEtude, isAdministrationRole } from 'camino-common/src/roles'
+import { isEntrepriseOrBureauDetudeRole, Role, User, UserNotNull, isAdministration, isSuper, isEntrepriseOrBureauDEtude, isAdministrationRole, UtilisateurId } from 'camino-common/src/roles'
 import { computed, defineComponent, ref } from 'vue'
 import { AdministrationId, Administrations, sortedAdministrations } from 'camino-common/src/static/administrations'
 import { Entreprise, EntrepriseId } from 'camino-common/src/entreprise'
@@ -12,6 +12,10 @@ import { DsfrSelect } from '../_ui/dsfr-select'
 import { DsfrButton, DsfrButtonIcon } from '../_ui/dsfr-button'
 import { LabelWithValue } from '../_ui/label-with-value'
 import { DsfrTag } from '../_ui/tag'
+import { CaminoError } from 'camino-common/src/zod-tools'
+import { AsyncData } from '@/api/client-rest'
+import { LoadingElement } from '../_ui/functional-loader'
+import { fr } from '@codegouvfr/react-dsfr'
 
 interface Props {
   user: User
@@ -24,8 +28,12 @@ export const PermissionDisplay = defineComponent<Props>(props => {
   const mode = ref<'read' | 'edit'>('read')
 
   const updateUtilisateur = async (utilisateur: UtilisateurToEdit) => {
-    await props.apiClient.updateUtilisateur(utilisateur)
+    const value = await props.apiClient.updateUtilisateur(utilisateur)
+    if ('message' in value) {
+      return value
+    }
     mode.value = 'read'
+    return value
   }
 
   return () => (
@@ -48,7 +56,7 @@ export const PermissionDisplay = defineComponent<Props>(props => {
             <LabelWithValue
               title={`Entreprise${props.utilisateur.entrepriseIds.length > 0 ? 's' : ''}`}
               item={
-                <ul class="fr-tags-group">
+                <ul class={fr.cx("fr-tags-group")}>
                   {isEntrepriseOrBureauDEtude(props.utilisateur) &&
                     props.utilisateur.entrepriseIds.map(entrepriseId => {
                       const e = props.entreprises.find(({ id }) => id === entrepriseId)
@@ -89,11 +97,12 @@ type PermissionEditProps = {
   user: User
   utilisateur: UserNotNull
   entreprises: Entreprise[]
-  updateUtilisateur: (utilisateur: UtilisateurToEdit) => Promise<void>
+  updateUtilisateur: (utilisateur: UtilisateurToEdit) => Promise<{ id: UtilisateurId } | CaminoError<string>>
   cancelEdition: () => void
 }
 
 const PermissionEdit = defineComponent<PermissionEditProps>(props => {
+  const asyncData = ref<AsyncData<{ id: UtilisateurId }>>({ status: 'LOADED', value: { id: props.utilisateur.id } })
   const updatingUtilisateur = ref<UtilisateurToEdit>({
     id: props.utilisateur.id,
     role: props.utilisateur.role,
@@ -127,6 +136,7 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => {
 
   const save = async () => {
     if (complete.value) {
+      asyncData.value = { status: 'LOADING' }
       if (!isAdministrationRole(updatingUtilisateur.value.role)) {
         updatingUtilisateur.value.administrationId = null
       }
@@ -135,7 +145,12 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => {
         updatingUtilisateur.value.entrepriseIds = []
       }
 
-      await props.updateUtilisateur(updatingUtilisateur.value)
+      const result = await props.updateUtilisateur(updatingUtilisateur.value)
+      if ('message' in result) {
+        asyncData.value = { status: 'NEW_ERROR', error: result }
+      } else {
+        asyncData.value = { status: 'LOADED', value: result }
+      }
     }
   }
 
@@ -153,10 +168,10 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => {
           <LabelWithValue
             title="Rôles"
             item={
-              <ul class="fr-tags-group">
+              <ul class={fr.cx("fr-tags-group")}>
                 {assignableRoles.map(role => (
                   <li>
-                    <DsfrButton class="fr-tag" onClick={() => roleToggle(role)} aria-pressed={updatingUtilisateur.value.role === role} title={capitalize(role)} />
+                    <DsfrButton class={fr.cx("fr-tag")} onClick={() => roleToggle(role)} aria-pressed={updatingUtilisateur.value.role === role} title={capitalize(role)} />
                   </li>
                 ))}
               </ul>
@@ -200,9 +215,9 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => {
           <LabelWithValue
             title=""
             item={
-              <div>
-                <DsfrButton title="Annuler" buttonType="secondary" onClick={props.cancelEdition} />
-                <DsfrButton class="fr-ml-2w" title="Enregistrer" buttonType="primary" onClick={save} disabled={!complete.value} />
+              <div style={{display: 'flex', alignItems: 'center', gap: '1rem'}}>
+                <DsfrButton title="Annuler" buttonType="secondary" onClick={props.cancelEdition} disabled={asyncData.value.status === 'LOADING'} />
+                <DsfrButton title="Enregistrer" buttonType="primary" onClick={save} disabled={!complete.value || asyncData.value.status === 'LOADING'} />{asyncData.value.status !== 'LOADED' ? <LoadingElement data={asyncData.value} renderItem={(_) => null} /> : null}
               </div>
             }
           />
diff --git a/packages/ui/src/components/utilisateur/utilisateur-api-client.ts b/packages/ui/src/components/utilisateur/utilisateur-api-client.ts
index 80a305985..801778850 100644
--- a/packages/ui/src/components/utilisateur/utilisateur-api-client.ts
+++ b/packages/ui/src/components/utilisateur/utilisateur-api-client.ts
@@ -1,13 +1,13 @@
 import { QGISTokenRest, UtilisateurToEdit, UtilisateursSearchParamsInput, UtilisateursTable } from 'camino-common/src/utilisateur'
 
-import { newGetWithJson, newPostWithJson, postWithJson } from '../../api/client-rest'
+import { newGetWithJson, newPostWithJson } from '../../api/client-rest'
 import { UserNotNull, UtilisateurId } from 'camino-common/src/roles'
 import { CaminoError } from 'camino-common/src/zod-tools'
 
 export interface UtilisateurApiClient {
   getUtilisateur: (userId: UtilisateurId) => Promise<CaminoError<string> | UserNotNull>
   removeUtilisateur: (userId: UtilisateurId) => Promise<{ id: UtilisateurId } | CaminoError<string>>
-  updateUtilisateur: (user: UtilisateurToEdit) => Promise<void>
+  updateUtilisateur: (user: UtilisateurToEdit) => Promise<{id: UtilisateurId} | CaminoError<string>>
   getQGISToken: () => Promise<CaminoError<string> | QGISTokenRest>
   getUtilisateurs: (params: UtilisateursSearchParamsInput) => Promise<CaminoError<string> | UtilisateursTable>
 }
@@ -20,6 +20,6 @@ export const utilisateurApiClient: UtilisateurApiClient = {
     return newGetWithJson('/rest/utilisateurs/:id', { id: userId })
   },
   removeUtilisateur: async (userId: UtilisateurId) => newGetWithJson('/rest/utilisateurs/:id/delete', { id: userId }),
-  updateUtilisateur: async (utilisateur: UtilisateurToEdit) => postWithJson('/rest/utilisateurs/:id/permission', { id: utilisateur.id }, utilisateur),
+  updateUtilisateur: async (utilisateur: UtilisateurToEdit) => newPostWithJson('/rest/utilisateurs/:id/permission', { id: utilisateur.id }, utilisateur),
   getQGISToken: async () => newPostWithJson('/rest/utilisateur/generateQgisToken', {}, {}),
 }
-- 
GitLab


From 7131854591f69119bb1bc943b349df5250ba0b34 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bitard=20Micha=C3=ABl?= <bitard.michael@gmail.com>
Date: Thu, 3 Apr 2025 15:56:28 +0200
Subject: [PATCH 5/8] lint

---
 packages/api/src/api/rest/utilisateurs.ts     | 53 ++++++++++---------
 packages/common/src/rest.ts                   |  2 +-
 .../utilisateur/permission-edit.tsx           | 11 ++--
 .../utilisateur/utilisateur-api-client.ts     |  2 +-
 4 files changed, 36 insertions(+), 32 deletions(-)

diff --git a/packages/api/src/api/rest/utilisateurs.ts b/packages/api/src/api/rest/utilisateurs.ts
index a8a6120ea..e0b8d5e36 100644
--- a/packages/api/src/api/rest/utilisateurs.ts
+++ b/packages/api/src/api/rest/utilisateurs.ts
@@ -15,12 +15,13 @@ import { Effect, Match } from 'effect'
 import { RestNewGetCall, RestNewPostCall } from '../../server/rest'
 import {
   getKeycloakIdByUserId,
-  GetKeycloakIdByUserIdErrors, GetUtilisateurByIdErrors,
+  GetKeycloakIdByUserIdErrors,
+  GetUtilisateurByIdErrors,
   getUtilisateursFilteredAndSorted,
   GetUtilisateursFilteredAndSortedErrors,
   newGetUtilisateurById,
   softDeleteUtilisateur,
-  updateUtilisateurRole
+  updateUtilisateurRole,
 } from '../../database/queries/utilisateurs.queries'
 import { EffectDbQueryAndValidateErrors } from '../../pg-database'
 import { callAndExit, shortCircuitError, zodParseEffectTyped } from '../../tools/fp-tools'
@@ -30,31 +31,31 @@ import { fetch } from 'undici'
 import { updateQgisToken } from './utilisateurs.queries'
 
 type UpdateUtilisateurPermissionErrors = EffectDbQueryAndValidateErrors | GetUtilisateurByIdErrors | UtilisateurUpdationValidateErrors
-export const updateUtilisateurPermission: RestNewPostCall<'/rest/utilisateurs/:id/permission'> =
-  (rootPipe): Effect.Effect<{id: UtilisateurId}, CaminoApiError<UpdateUtilisateurPermissionErrors>> =>
+export const updateUtilisateurPermission: RestNewPostCall<'/rest/utilisateurs/:id/permission'> = (rootPipe): Effect.Effect<{ id: UtilisateurId }, CaminoApiError<UpdateUtilisateurPermissionErrors>> =>
   rootPipe.pipe(
-    Effect.bind('utilisateurOld', ({params, pool, user}) => newGetUtilisateurById(pool, params.id, user)),
-    Effect.tap(({user, body, utilisateurOld}) => utilisateurUpdationValidate(user, body, utilisateurOld)),
-    Effect.tap(({ body, pool}) => updateUtilisateurRole(pool, body)),
-    Effect.map(({params}) => ({id: params.id})),
+    Effect.bind('utilisateurOld', ({ params, pool, user }) => newGetUtilisateurById(pool, params.id, user)),
+    Effect.tap(({ user, body, utilisateurOld }) => utilisateurUpdationValidate(user, body, utilisateurOld)),
+    Effect.tap(({ body, pool }) => updateUtilisateurRole(pool, body)),
+    Effect.map(({ params }) => ({ id: params.id })),
     Effect.mapError(caminoError =>
       Match.value(caminoError.message).pipe(
-        Match.when("L'utilisateur est invalide", () => ({...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR})),
+        Match.when("L'utilisateur est invalide", () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })),
         Match.whenOr(
-          "droits insuffisants",
+          'droits insuffisants',
           "Impossible d'exécuter la requête dans la base de données",
           'Les données en base ne correspondent pas à ce qui est attendu',
-          'utilisateur incorrect' ,
-          "l'utilisateur n'existe pas" ,
-          'droits insuffisants' ,
-          'impossible de modifier son propre rôle' ,
-          'droits insuffisants pour modifier les rôles' ,
-          'droits insuffisants pour modifier les administrations' ,
-          'droits insuffisants pour modifier les entreprises' ,
+          'utilisateur incorrect',
+          "l'utilisateur n'existe pas",
+          'droits insuffisants',
+          'impossible de modifier son propre rôle',
+          'droits insuffisants pour modifier les rôles',
+          'droits insuffisants pour modifier les administrations',
+          'droits insuffisants pour modifier les entreprises',
           () => ({
-          ...caminoError,
-          status: HTTP_STATUS.BAD_REQUEST,
-        })),
+            ...caminoError,
+            status: HTTP_STATUS.BAD_REQUEST,
+          })
+        ),
         Match.exhaustive
       )
     )
@@ -163,7 +164,7 @@ export const moi: RestNewGetCall<'/moi'> = (rootPipe): Effect.Effect<User, Camin
       Match.value(caminoError.message).pipe(
         Match.when('droits insuffisants', () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })),
         Match.when('Les données en base ne correspondent pas à ce qui est attendu', () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })),
-        Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'L\'utilisateur est invalide', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
+        Match.whenOr("Impossible d'exécuter la requête dans la base de données", "L'utilisateur est invalide", () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
 
         Match.exhaustive
       )
@@ -171,8 +172,10 @@ export const moi: RestNewGetCall<'/moi'> = (rootPipe): Effect.Effect<User, Camin
   )
 }
 
-const impossibleDeGenererUnTokenQgis = "impossible de générer un token Qgis" as const
-export const generateQgisToken: RestNewPostCall<'/rest/utilisateur/generateQgisToken'> = (rootPipe): Effect.Effect<QGISTokenRest, CaminoApiError<EffectDbQueryAndValidateErrors | typeof impossibleDeGenererUnTokenQgis>> =>
+const impossibleDeGenererUnTokenQgis = 'impossible de générer un token Qgis' as const
+export const generateQgisToken: RestNewPostCall<'/rest/utilisateur/generateQgisToken'> = (
+  rootPipe
+): Effect.Effect<QGISTokenRest, CaminoApiError<EffectDbQueryAndValidateErrors | typeof impossibleDeGenererUnTokenQgis>> =>
   rootPipe.pipe(
     Effect.bind('token', () => zodParseEffectTyped(qgisTokenValidator, idGenerate(), impossibleDeGenererUnTokenQgis)),
     Effect.tap(({ token, pool, user }) => updateQgisToken(pool, user, token)),
@@ -233,7 +236,7 @@ export const getUtilisateurs: RestNewGetCall<'/rest/utilisateurs'> = (rootPipe):
           status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
         })),
         Match.when('droits insuffisants', () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })),
-        Match.when('L\'utilisateur est invalide', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
+        Match.when("L'utilisateur est invalide", () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
         Match.exhaustive
       )
     )
@@ -251,7 +254,7 @@ export const getUtilisateur: RestNewGetCall<'/rest/utilisateurs/:id'> = (rootPip
           status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
         })),
         Match.when('droits insuffisants', () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })),
-        Match.when('L\'utilisateur est invalide', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
+        Match.when("L'utilisateur est invalide", () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })),
         Match.exhaustive
       )
     )
diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts
index 25a204457..95ed7cc15 100644
--- a/packages/common/src/rest.ts
+++ b/packages/common/src/rest.ts
@@ -166,7 +166,7 @@ export const CaminoRestRoutes = {
   '/rest/utilisateurs/:id': { params: utilisateurIdParamsValidator, newGet: { output: userNotNullValidator } },
   // On passe par un http get plutot qu'un http delete car nous terminons par une redirection vers la deconnexion de oauth2, qui se traduit mal sur certains navigateurs et essaie de faire un delete sur une route get
   '/rest/utilisateurs/:id/delete': { params: utilisateurIdParamsValidator, newGet: { output: z.object({ id: utilisateurIdValidator }) } },
-  '/rest/utilisateurs/:id/permission': { params: utilisateurIdParamsValidator, newPost: { input: utilisateurToEdit, output: z.object({id: utilisateurIdValidator}) } },
+  '/rest/utilisateurs/:id/permission': { params: utilisateurIdParamsValidator, newPost: { input: utilisateurToEdit, output: z.object({ id: utilisateurIdValidator }) } },
   '/rest/utilisateurs': { params: noParamsValidator, newGet: { output: utilisateursTableValidator, searchParams: utilisateursSearchParamsValidator } },
   '/rest/statistiques/minerauxMetauxMetropole': { params: noParamsValidator, get: { output: statistiquesMinerauxMetauxMetropoleValidator } },
   '/rest/statistiques/guyane': { params: noParamsValidator, get: { output: statistiquesGuyaneDataValidator } },
diff --git a/packages/ui/src/components/utilisateur/permission-edit.tsx b/packages/ui/src/components/utilisateur/permission-edit.tsx
index 564034641..f9f4667e1 100644
--- a/packages/ui/src/components/utilisateur/permission-edit.tsx
+++ b/packages/ui/src/components/utilisateur/permission-edit.tsx
@@ -56,7 +56,7 @@ export const PermissionDisplay = defineComponent<Props>(props => {
             <LabelWithValue
               title={`Entreprise${props.utilisateur.entrepriseIds.length > 0 ? 's' : ''}`}
               item={
-                <ul class={fr.cx("fr-tags-group")}>
+                <ul class={fr.cx('fr-tags-group')}>
                   {isEntrepriseOrBureauDEtude(props.utilisateur) &&
                     props.utilisateur.entrepriseIds.map(entrepriseId => {
                       const e = props.entreprises.find(({ id }) => id === entrepriseId)
@@ -168,10 +168,10 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => {
           <LabelWithValue
             title="Rôles"
             item={
-              <ul class={fr.cx("fr-tags-group")}>
+              <ul class={fr.cx('fr-tags-group')}>
                 {assignableRoles.map(role => (
                   <li>
-                    <DsfrButton class={fr.cx("fr-tag")} onClick={() => roleToggle(role)} aria-pressed={updatingUtilisateur.value.role === role} title={capitalize(role)} />
+                    <DsfrButton class={fr.cx('fr-tag')} onClick={() => roleToggle(role)} aria-pressed={updatingUtilisateur.value.role === role} title={capitalize(role)} />
                   </li>
                 ))}
               </ul>
@@ -215,9 +215,10 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => {
           <LabelWithValue
             title=""
             item={
-              <div style={{display: 'flex', alignItems: 'center', gap: '1rem'}}>
+              <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
                 <DsfrButton title="Annuler" buttonType="secondary" onClick={props.cancelEdition} disabled={asyncData.value.status === 'LOADING'} />
-                <DsfrButton title="Enregistrer" buttonType="primary" onClick={save} disabled={!complete.value || asyncData.value.status === 'LOADING'} />{asyncData.value.status !== 'LOADED' ? <LoadingElement data={asyncData.value} renderItem={(_) => null} /> : null}
+                <DsfrButton title="Enregistrer" buttonType="primary" onClick={save} disabled={!complete.value || asyncData.value.status === 'LOADING'} />
+                {asyncData.value.status !== 'LOADED' ? <LoadingElement data={asyncData.value} renderItem={_ => null} /> : null}
               </div>
             }
           />
diff --git a/packages/ui/src/components/utilisateur/utilisateur-api-client.ts b/packages/ui/src/components/utilisateur/utilisateur-api-client.ts
index 801778850..9600aad97 100644
--- a/packages/ui/src/components/utilisateur/utilisateur-api-client.ts
+++ b/packages/ui/src/components/utilisateur/utilisateur-api-client.ts
@@ -7,7 +7,7 @@ import { CaminoError } from 'camino-common/src/zod-tools'
 export interface UtilisateurApiClient {
   getUtilisateur: (userId: UtilisateurId) => Promise<CaminoError<string> | UserNotNull>
   removeUtilisateur: (userId: UtilisateurId) => Promise<{ id: UtilisateurId } | CaminoError<string>>
-  updateUtilisateur: (user: UtilisateurToEdit) => Promise<{id: UtilisateurId} | CaminoError<string>>
+  updateUtilisateur: (user: UtilisateurToEdit) => Promise<{ id: UtilisateurId } | CaminoError<string>>
   getQGISToken: () => Promise<CaminoError<string> | QGISTokenRest>
   getUtilisateurs: (params: UtilisateursSearchParamsInput) => Promise<CaminoError<string> | UtilisateursTable>
 }
-- 
GitLab


From 175283087c4b03f50885d8e48d4cf3ee524722a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bitard=20Micha=C3=ABl?= <bitard.michael@gmail.com>
Date: Thu, 3 Apr 2025 17:49:21 +0200
Subject: [PATCH 6/8] delete last putCall

---
 .../api/graphql/resolvers/titres-activites.ts |   2 +-
 .../api/src/api/rest/activites.queries.ts     |  34 ++--
 .../api/rest/activites.test.integration.ts    | 150 ++++++++++++++++
 packages/api/src/api/rest/activites.ts        | 163 ++++++++++--------
 packages/api/src/server/rest.ts               |   9 +-
 packages/common/src/rest.ts                   |   6 +-
 packages/ui/src/api/client-rest.ts            |   9 -
 .../components/activite-edition.stories.tsx   |   4 +-
 .../ui/src/components/activite-edition.tsx    |  27 ++-
 .../activite/activite-api-client.ts           |   7 +-
 .../ui/src/components/utilisateur.stories.tsx |   2 +-
 11 files changed, 286 insertions(+), 127 deletions(-)
 create mode 100644 packages/api/src/api/rest/activites.test.integration.ts

diff --git a/packages/api/src/api/graphql/resolvers/titres-activites.ts b/packages/api/src/api/graphql/resolvers/titres-activites.ts
index a0edd0fd9..b09adfc65 100644
--- a/packages/api/src/api/graphql/resolvers/titres-activites.ts
+++ b/packages/api/src/api/graphql/resolvers/titres-activites.ts
@@ -168,7 +168,7 @@ export const activiteDeposer = async ({ id }: { id: ActiviteId }, { user, pool }
 
     const activite = await callAndExit(getActiviteById(id, pool, user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires))
 
-    const activitesDocuments = await getActiviteDocumentsByActiviteId(id, pool)
+    const activitesDocuments = await callAndExit(getActiviteDocumentsByActiviteId(id, pool))
     const sectionsWithValue = getSectionsWithValue(activite.sections, activite.contenu)
     if (!(await isActiviteDeposable(user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, { ...activite, sections_with_value: sectionsWithValue }, activitesDocuments))) {
       throw new Error('droits insuffisants')
diff --git a/packages/api/src/api/rest/activites.queries.ts b/packages/api/src/api/rest/activites.queries.ts
index c3b29e96b..44eca5555 100644
--- a/packages/api/src/api/rest/activites.queries.ts
+++ b/packages/api/src/api/rest/activites.queries.ts
@@ -53,7 +53,7 @@ export const titreTypeIdByActiviteId = (activiteId: ActiviteIdOrSlug, pool: Pool
   )
 
 const miseAJourActiviteInterdite = `Interdiction d'éditer une activité` as const
-type UpdateActiviteQueryErrors = EffectDbQueryAndValidateErrors | typeof miseAJourActiviteInterdite
+export type UpdateActiviteQueryErrors = EffectDbQueryAndValidateErrors | typeof miseAJourActiviteInterdite
 export const updateActiviteQuery = (
   pool: Pool,
   user: User,
@@ -119,7 +119,7 @@ const dbActiviteValidator = activiteValidator
 
 const activiteInterdite = `Lecture de l'activité impossible` as const
 const activiteIntrouvable = `Pas d'activité trouvée` as const
-type GetActiviteByIdErrors = EffectDbQueryAndValidateErrors | typeof activiteInterdite | typeof activiteIntrouvable
+export type GetActiviteByIdErrors = EffectDbQueryAndValidateErrors | typeof activiteInterdite | typeof activiteIntrouvable
 export type DbActivite = z.infer<typeof dbActiviteValidator>
 export const getActiviteById = (
   activiteId: ActiviteIdOrSlug,
@@ -204,8 +204,8 @@ delete from activites_documents
 where activite_id = $ activiteId !
 `
 
-export const getActiviteDocumentsByActiviteId = async (activiteId: ActiviteId, pool: Pool): Promise<ActiviteDocument[]> => {
-  return dbQueryAndValidate(
+export const getActiviteDocumentsByActiviteId = (activiteId: ActiviteId, pool: Pool): Effect.Effect<ActiviteDocument[], CaminoError<EffectDbQueryAndValidateErrors>> =>
+  effectDbQueryAndValidate(
     getActiviteDocumentsInternal,
     {
       activiteId,
@@ -213,7 +213,6 @@ export const getActiviteDocumentsByActiviteId = async (activiteId: ActiviteId, p
     pool,
     activiteDocumentValidator
   )
-}
 
 export const administrationsLocalesByActiviteId = async (activiteId: ActiviteIdOrSlug, pool: Pool): Promise<AdministrationId[]> => {
   const admins = await dbQueryAndValidate(getAdministrationsLocalesByActiviteId, { activiteId }, pool, administrationsLocalesValidator)
@@ -285,26 +284,30 @@ where
     d.activite_id = $ activiteId !
 `
 
-export const deleteActiviteDocument = async (
+const droitsInsuffisanstPourSupprimerLeDocument = "droits insuffisants pour supprimer un document d'activité" as const
+export type DeleteActiviteDocumentErrors = EffectDbQueryAndValidateErrors | typeof droitsInsuffisanstPourSupprimerLeDocument
+export const deleteActiviteDocument = (
   id: ActiviteDocumentId,
   activiteDocumentTypeId: ActiviteDocumentTypeId,
   activiteTypeId: ActivitesTypesId,
   activiteStatutId: ActivitesStatutId,
   pool: Pool
-): Promise<void[]> => {
-  if (!canDeleteActiviteDocument(activiteDocumentTypeId, activiteTypeId, activiteStatutId)) {
-    throw new Error('droits insuffisants')
-  }
-
-  return dbQueryAndValidate(deleteActiviteDocumentQuery, { id }, pool, z.void())
-}
+): Effect.Effect<true, CaminoError<DeleteActiviteDocumentErrors>> =>
+  Effect.Do.pipe(
+    Effect.filterOrFail(
+      () => canDeleteActiviteDocument(activiteDocumentTypeId, activiteTypeId, activiteStatutId),
+      () => ({ message: droitsInsuffisanstPourSupprimerLeDocument })
+    ),
+    Effect.flatMap(() => effectDbQueryAndValidate(deleteActiviteDocumentQuery, { id }, pool, z.void())),
+    Effect.map(() => true as const)
+  )
 
 const deleteActiviteDocumentQuery = sql<Redefine<IDeleteActiviteDocumentQueryQuery, { id: ActiviteDocumentId }, void>>`
 delete from activites_documents
 where id = $ id !
 `
 
-export const insertActiviteDocument = async (
+export const insertActiviteDocument = (
   pool: Pool,
   params: {
     id: ActiviteDocumentId
@@ -314,7 +317,8 @@ export const insertActiviteDocument = async (
     description: string
     largeobject_id: number
   }
-): Promise<{ id: ActiviteDocumentId }[]> => dbQueryAndValidate(insertActiviteDocumentInternal, params, pool, z.object({ id: activiteDocumentIdValidator }))
+): Effect.Effect<{ id: ActiviteDocumentId }[], CaminoError<EffectDbQueryAndValidateErrors>> =>
+  effectDbQueryAndValidate(insertActiviteDocumentInternal, params, pool, z.object({ id: activiteDocumentIdValidator }))
 
 const insertActiviteDocumentInternal = sql<
   Redefine<
diff --git a/packages/api/src/api/rest/activites.test.integration.ts b/packages/api/src/api/rest/activites.test.integration.ts
new file mode 100644
index 000000000..cdd8a4c92
--- /dev/null
+++ b/packages/api/src/api/rest/activites.test.integration.ts
@@ -0,0 +1,150 @@
+/* eslint-disable sql/no-unsafe-query */
+import { restCall, restNewPutCall } from '../../../tests/_utils/index'
+import { dbManager } from '../../../tests/db-manager'
+import { test, describe, afterAll, beforeAll, vi, expect } from 'vitest'
+import type { Pool } from 'pg'
+import { toCaminoDate } from 'camino-common/src/date'
+import { insertTitreGraph, ITitreInsert } from '../../../tests/integration-test-helper'
+import { ETAPE_IS_NOT_BROUILLON } from 'camino-common/src/etape'
+import { newTitreId, newDemarcheId, newEtapeId, idGenerate } from '../../database/models/_format/id-create'
+import { TITRES_TYPES_IDS } from 'camino-common/src/static/titresTypes'
+import { DEMARCHES_TYPES_IDS } from 'camino-common/src/static/demarchesTypes'
+import { DemarchesStatutsIds } from 'camino-common/src/static/demarchesStatuts'
+import { TitresStatutIds } from 'camino-common/src/static/titresStatuts'
+import { Knex } from 'knex'
+import { ACTIVITES_TYPES_IDS } from 'camino-common/src/static/activitesTypes'
+import { ACTIVITES_STATUTS_IDS } from 'camino-common/src/static/activitesStatuts'
+import { ETAPES_TYPES } from 'camino-common/src/static/etapesTypes'
+import { ETAPES_STATUTS } from 'camino-common/src/static/etapesStatuts'
+import { Activite, activiteDocumentIdValidator, activiteIdValidator } from 'camino-common/src/activite'
+import { userSuper } from '../../database/user-super'
+import { DOCUMENTS_TYPES_IDS } from 'camino-common/src/static/documentsTypes'
+import { copyFileSync, mkdirSync } from 'node:fs'
+import { tempDocumentNameValidator } from 'camino-common/src/document'
+import { SectionWithValue } from 'camino-common/src/sections'
+
+const dir = `${process.cwd()}/files/tmp/`
+
+console.info = vi.fn()
+console.error = vi.fn()
+let dbPool: Pool
+let knex: Knex
+beforeAll(async () => {
+  const { knex: knexInstance, pool } = await dbManager.populateDb()
+  dbPool = pool
+  knex = knexInstance
+})
+
+afterAll(async () => {
+  await dbManager.closeKnex()
+})
+
+describe('updateActivite', () => {
+  test('met à jour une activité', async () => {
+    const titreId = newTitreId('titre-id')
+    const titre: ITitreInsert = {
+      id: titreId,
+      nom: 'mon titre',
+      typeId: TITRES_TYPES_IDS.AUTORISATION_DE_RECHERCHE_METAUX,
+      titreStatutId: TitresStatutIds.Valide,
+      publicLecture: true,
+      propsTitreEtapesIds: { points: 'titre-id-demarche-id-dpu' },
+      demarches: [
+        {
+          id: newDemarcheId('titre-id-demarche-id'),
+          titreId: titreId,
+          typeId: DEMARCHES_TYPES_IDS.Octroi,
+          statutId: DemarchesStatutsIds.Accepte,
+          publicLecture: true,
+          etapes: [
+            {
+              id: newEtapeId('titre-id-demarche-id-dpu'),
+              typeId: ETAPES_TYPES.publicationDeDecisionAuJORF,
+              ordre: 0,
+              titreDemarcheId: newDemarcheId('titre-id-demarche-id'),
+              statutId: ETAPES_STATUTS.ACCEPTE,
+              date: toCaminoDate('2020-02-02'),
+              administrationsLocales: ['dea-guyane-01'],
+              isBrouillon: ETAPE_IS_NOT_BROUILLON,
+            },
+          ],
+        },
+      ],
+    }
+
+    await insertTitreGraph(titre)
+
+    const activiteId = activiteIdValidator.parse('activite1Id')
+    await knex.raw(
+      `INSERT INTO titres_activites (  id, titre_id, utilisateur_id, "date", date_saisie, contenu, type_id, activite_statut_id, annee, periode_id, sections, suppression, slug) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,?,?,?,?)`,
+      [
+        activiteId,
+        titreId,
+        null,
+        toCaminoDate('2021-01-01'),
+        null,
+        null,
+        ACTIVITES_TYPES_IDS["rapport trimestriel d'exploitation d'or en Guyane"],
+        ACTIVITES_STATUTS_IDS.ABSENT,
+        2023,
+        1,
+        [JSON.stringify({ id: 'id', elements: [{ id: 'element', type: 'number', optionnel: true }] })],
+        false,
+        'slug',
+      ]
+    )
+
+    const activite1DocumentId = activiteDocumentIdValidator.parse('activiteDocumentId1')
+    const activite2DocumentId = activiteDocumentIdValidator.parse('activiteDocumentId2')
+    await knex.raw('INSERT INTO activites_documents(id,activite_document_type_id,"date",activite_id,description,largeobject_id) VALUES (?, ?,?,?,?,?), (?, ?,?,?,?,?)', [
+      activite1DocumentId,
+      DOCUMENTS_TYPES_IDS.rapportAnnuelDExploitation,
+      toCaminoDate('2021-01-01'),
+      activiteId,
+      '',
+      54300,
+      activite2DocumentId,
+      DOCUMENTS_TYPES_IDS.rapportAnnuelDExploitation,
+      toCaminoDate('2023-01-01'),
+      activiteId,
+      '',
+      54300,
+    ])
+
+    let tested = await restNewPutCall(dbPool, '/rest/activites/:activiteId', { activiteId: activiteId }, undefined, { sectionsWithValue: [], activiteDocumentIds: [], newTempDocuments: [] })
+
+    expect(tested.body).toMatchInlineSnapshot(`
+      {
+        "message": "Accès interdit",
+        "status": 403,
+      }
+    `)
+    const fileName = `existing_temp_file_${idGenerate()}`
+    mkdirSync(dir, { recursive: true })
+    copyFileSync(`./src/tools/small.pdf`, `${dir}/${fileName}`)
+
+    const sectionWithValue: SectionWithValue[] = [{ id: 'id', elements: [{ id: 'element', type: 'number', value: 12, optionnel: true }] }]
+    tested = await restNewPutCall(dbPool, '/rest/activites/:activiteId', { activiteId: activiteId }, userSuper, {
+      sectionsWithValue: sectionWithValue,
+      activiteDocumentIds: [activite1DocumentId],
+      newTempDocuments: [
+        { activite_document_type_id: DOCUMENTS_TYPES_IDS.rapportEnvironnementalDExploration, description: 'Une jolie description', tempDocumentName: tempDocumentNameValidator.parse(fileName) },
+      ],
+    })
+
+    expect(tested.body).toMatchInlineSnapshot(`
+      {
+        "id": "activite1Id",
+      }
+    `)
+
+    tested = await restCall(dbPool, '/rest/activites/:activiteId', { activiteId }, userSuper)
+    const activite: Activite = tested.body
+    expect(activite.activite_statut_id).toStrictEqual(ACTIVITES_STATUTS_IDS.EN_CONSTRUCTION)
+    expect(activite.sections_with_value).toStrictEqual(sectionWithValue)
+    const newDocumentIds = activite.activite_documents.map(document => document.id)
+    expect(newDocumentIds).not.toContain(activite2DocumentId)
+    expect(newDocumentIds).toContain(activite1DocumentId)
+    expect(activite.activite_documents).toHaveLength(2)
+  })
+})
diff --git a/packages/api/src/api/rest/activites.ts b/packages/api/src/api/rest/activites.ts
index 7010e6dbc..95fcc6284 100644
--- a/packages/api/src/api/rest/activites.ts
+++ b/packages/api/src/api/rest/activites.ts
@@ -1,13 +1,14 @@
 import { CaminoRequest, CustomResponse } from './express-type'
 import { HTTP_STATUS } from 'camino-common/src/http'
 import { Pool } from 'pg'
-import { Activite, activiteDocumentIdValidator, activiteEditionValidator, activiteIdOrSlugValidator, activiteIdValidator } from 'camino-common/src/activite'
+import { Activite, activiteDocumentIdValidator, ActiviteId, activiteIdOrSlugValidator, activiteIdValidator } from 'camino-common/src/activite'
 import {
   Contenu,
   administrationsLocalesByActiviteId,
   deleteActiviteDocument,
   entreprisesTitulairesOuAmoditairesByActiviteId,
   getActiviteById,
+  GetActiviteByIdErrors,
   getActiviteDocumentsByActiviteId,
   getLargeobjectIdByActiviteDocumentId,
   insertActiviteDocument,
@@ -15,9 +16,11 @@ import {
   updateActiviteQuery,
   DbActivite,
   activiteDeleteQuery,
+  UpdateActiviteQueryErrors,
+  DeleteActiviteDocumentErrors,
 } from './activites.queries'
 import { NewDownload } from './fichiers'
-import { SimplePromiseFn, isNonEmptyArray, isNullOrUndefined, memoize } from 'camino-common/src/typescript-tools'
+import { SimplePromiseFn, isNonEmptyArray, memoize } from 'camino-common/src/typescript-tools'
 import { canEditActivite, isActiviteDeposable } from 'camino-common/src/permissions/activites'
 import { SectionWithValue } from 'camino-common/src/sections'
 import { Section, getSectionsWithValue } from 'camino-common/src/static/titresTypes_demarchesTypes_etapesTypes/sections'
@@ -29,8 +32,11 @@ import { TitreTypeId } from 'camino-common/src/static/titresTypes'
 import { AdministrationId } from 'camino-common/src/static/administrations'
 import { EntrepriseId } from 'camino-common/src/entreprise'
 import { getCurrent } from 'camino-common/src/date'
-import { createLargeObject } from '../../database/largeobjects'
+import { createLargeObject, CreateLargeObjectError } from '../../database/largeobjects'
 import { callAndExit } from '../../tools/fp-tools'
+import { RestNewPutCall } from '../../server/rest'
+import { CaminoApiError } from '../../types'
+import { Effect, Match, Option } from 'effect'
 
 const extractContenuFromSectionWithValue = (sections: Section[], sectionsWithValue: SectionWithValue[]): Contenu => {
   const contenu: Contenu = {}
@@ -58,74 +64,91 @@ const extractContenuFromSectionWithValue = (sections: Section[], sectionsWithVal
   return contenu
 }
 
-export const updateActivite =
-  (pool: Pool) =>
-  async (req: CaminoRequest, res: CustomResponse<void>): Promise<void> => {
-    const activiteIdParsed = activiteIdOrSlugValidator.safeParse(req.params.activiteId)
-    const user = req.auth
-
-    if (!activiteIdParsed.success) {
-      res.sendStatus(HTTP_STATUS.BAD_REQUEST)
-    } else if (isNullOrUndefined(user)) {
-      res.sendStatus(HTTP_STATUS.BAD_REQUEST)
-    } else {
-      try {
-        const titreTypeId = memoize(() => callAndExit(titreTypeIdByActiviteId(activiteIdParsed.data, pool)))
-        const administrationsLocales = memoize(() => administrationsLocalesByActiviteId(activiteIdParsed.data, pool))
-        const entreprisesTitulairesOuAmodiataires = memoize(() => entreprisesTitulairesOuAmoditairesByActiviteId(activiteIdParsed.data, pool))
-
-        const result = await callAndExit(getActiviteById(activiteIdParsed.data, pool, user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires))
-
-        if (!(await canEditActivite(user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, result.activite_statut_id))) {
-          res.sendStatus(HTTP_STATUS.FORBIDDEN)
-        } else {
-          const parsed = activiteEditionValidator.safeParse(req.body)
-
-          if (!parsed.success) {
-            res.sendStatus(HTTP_STATUS.BAD_REQUEST)
-          } else {
-            const contenu = extractContenuFromSectionWithValue(result.sections, parsed.data.sectionsWithValue)
-            await callAndExit(updateActiviteQuery(pool, user, result.id, contenu, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires))
-
-            const activiteDocumentsToCreate = parsed.data.newTempDocuments
-            const alreadyExistingDocumentIds = parsed.data.activiteDocumentIds
-            const oldActiviteDocuments = await getActiviteDocumentsByActiviteId(result.id, pool)
-
-            if (isNonEmptyArray(oldActiviteDocuments)) {
-              // supprime les anciens documents ou ceux qui n'ont pas de fichier
-              for (const oldActiviteDocument of oldActiviteDocuments) {
-                const documentId = alreadyExistingDocumentIds.find(id => id === oldActiviteDocument.id)
-
-                if (!documentId) {
-                  await deleteActiviteDocument(oldActiviteDocument.id, oldActiviteDocument.activite_document_type_id, result.type_id, ACTIVITES_STATUTS_IDS.EN_CONSTRUCTION, pool)
-                }
-              }
-            }
-
-            for (const document of activiteDocumentsToCreate) {
-              const loid = await callAndExit(createLargeObject(pool, document.tempDocumentName))
-
-              const date = getCurrent()
-
-              await insertActiviteDocument(pool, {
-                id: newActiviteDocumentId(date, document.activite_document_type_id),
-                activite_document_type_id: document.activite_document_type_id,
-                description: document.description ?? '',
-                date,
-                largeobject_id: loid,
-                activite_id: result.id,
-              })
-            }
-
-            res.sendStatus(HTTP_STATUS.NO_CONTENT)
+const canEditActiviteError = "Impossible de vérifier si on peut éditer l'activité" as const
+const editionDeLActiviteImpossible = "Droit insuffisants pour éditer l'activité" as const
+type UpdateActiviteErrors =
+  | GetActiviteByIdErrors
+  | typeof canEditActiviteError
+  | typeof editionDeLActiviteImpossible
+  | UpdateActiviteQueryErrors
+  | DeleteActiviteDocumentErrors
+  | CreateLargeObjectError
+export const updateActivite: RestNewPutCall<'/rest/activites/:activiteId'> = (rootPipe): Effect.Effect<{ id: ActiviteId }, CaminoApiError<UpdateActiviteErrors>> =>
+  rootPipe.pipe(
+    Effect.let('titreTypeId', ({ params, pool }) => memoize(() => callAndExit(titreTypeIdByActiviteId(params.activiteId, pool)))),
+    Effect.let('administrationsLocales', ({ params, pool }) => memoize(() => administrationsLocalesByActiviteId(params.activiteId, pool))),
+    Effect.let('entreprisesTitulairesOuAmodiataires', ({ params, pool }) => memoize(() => entreprisesTitulairesOuAmoditairesByActiviteId(params.activiteId, pool))),
+    Effect.bind('result', ({ params, pool, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, user }) =>
+      getActiviteById(params.activiteId, pool, user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires)
+    ),
+    Effect.tap(({ user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, result }) =>
+      Effect.tryPromise({
+        try: () => canEditActivite(user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, result.activite_statut_id),
+        catch: e => ({ message: canEditActiviteError, extra: e }),
+      }).pipe(
+        Effect.filterOrFail(
+          canRead => canRead,
+          () => ({ message: editionDeLActiviteImpossible })
+        )
+      )
+    ),
+    Effect.let('contenu', ({ result, body }) => extractContenuFromSectionWithValue(result.sections, body.sectionsWithValue)),
+    Effect.tap(({ pool, user, result, contenu, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires }) =>
+      updateActiviteQuery(pool, user, result.id, contenu, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires)
+    ),
+    Effect.bind('oldActiviteDocuments', ({ pool, result }) => getActiviteDocumentsByActiviteId(result.id, pool)),
+    Effect.tap(({ body, oldActiviteDocuments, pool, result }) => {
+      const alreadyExistingDocumentIds = body.activiteDocumentIds
+
+      if (isNonEmptyArray(oldActiviteDocuments)) {
+        return Effect.forEach(oldActiviteDocuments, oldActiviteDocument => {
+          const documentId = alreadyExistingDocumentIds.find(id => id === oldActiviteDocument.id)
+          if (!documentId) {
+            return deleteActiviteDocument(oldActiviteDocument.id, oldActiviteDocument.activite_document_type_id, result.type_id, ACTIVITES_STATUTS_IDS.EN_CONSTRUCTION, pool)
           }
-        }
-      } catch (e: any) {
-        console.error(e)
-        res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR)
+          return Effect.succeed(true)
+        })
       }
-    }
-  }
+      return Effect.succeed(Option.none)
+    }),
+    Effect.tap(({ body, pool, result }) =>
+      Effect.forEach(body.newTempDocuments, document => {
+        return Effect.Do.pipe(
+          Effect.flatMap(() => createLargeObject(pool, document.tempDocumentName)),
+          Effect.flatMap(loid => {
+            const date = getCurrent()
+            return insertActiviteDocument(pool, {
+              id: newActiviteDocumentId(date, document.activite_document_type_id),
+              activite_document_type_id: document.activite_document_type_id,
+              description: document.description ?? '',
+              date,
+              largeobject_id: loid,
+              activite_id: result.id,
+            })
+          })
+        )
+      })
+    ),
+    Effect.map(({ result }) => ({ id: result.id })),
+    Effect.mapError(caminoError =>
+      Match.value(caminoError.message).pipe(
+        Match.when("Pas d'activité trouvée", () => ({ ...caminoError, status: HTTP_STATUS.NOT_FOUND })),
+        Match.whenOr("Droit insuffisants pour éditer l'activité", "Interdiction d'éditer une activité", "droits insuffisants pour supprimer un document d'activité", () => ({
+          ...caminoError,
+          status: HTTP_STATUS.FORBIDDEN,
+        })),
+        Match.whenOr(
+          "Impossible d'exécuter la requête dans la base de données",
+          "Impossible de vérifier si on peut éditer l'activité",
+          "Lecture de l'activité impossible",
+          'Les données en base ne correspondent pas à ce qui est attendu',
+          "impossible d'insérer un fichier en base",
+          () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })
+        ),
+        Match.exhaustive
+      )
+    )
+  )
 
 const formatActivite = async (
   dbActivite: DbActivite,
@@ -137,7 +160,7 @@ const formatActivite = async (
 ): Promise<Activite> => {
   const sectionsWithValue: SectionWithValue[] = getSectionsWithValue(dbActivite.sections, dbActivite.contenu)
 
-  const activiteDocuments = await getActiviteDocumentsByActiviteId(dbActivite.id, pool)
+  const activiteDocuments = await callAndExit(getActiviteDocumentsByActiviteId(dbActivite.id, pool))
   const deposable = await isActiviteDeposable(
     user,
     titreTypeId,
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index c981b4ea8..31a1979cf 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -28,7 +28,6 @@ import {
   contentTypes,
   GetRestRoutes,
   PostRestRoutes,
-  PutRestRoutes,
   DeleteRestRoutes,
   isCaminoRestRoute,
   DownloadRestRoutes,
@@ -141,7 +140,6 @@ export type RestNewDeleteCall<Route extends NewDeleteRestRoutes> = (
 ) => Effect.Effect<Option.Option<never>, CaminoApiError<string>>
 
 type RestPostCall<Route extends PostRestRoutes> = (pool: Pool) => (req: CaminoRequest, res: CustomResponse<z.infer<CaminoRestRoutesType[Route]['post']['output']>>) => Promise<void>
-type RestPutCall<Route extends PutRestRoutes> = (pool: Pool) => (req: CaminoRequest, res: CustomResponse<z.infer<CaminoRestRoutesType[Route]['put']['output']>>) => Promise<void>
 type RestDeleteCall = (pool: Pool) => (req: CaminoRequest, res: CustomResponse<void | Error>) => Promise<void>
 type RestDownloadCall = (pool: Pool) => IRestResolver
 
@@ -150,7 +148,6 @@ type Transform<Route> = (Route extends GetRestRoutes ? { getCall: RestGetCall<Ro
   (Route extends PostRestRoutes ? { postCall: RestPostCall<Route> } : {}) &
   (Route extends NewPostRestRoutes ? { newPostCall: RestNewPostCall<Route> } : {}) &
   (Route extends NewPutRestRoutes ? { newPutCall: RestNewPutCall<Route> } : {}) &
-  (Route extends PutRestRoutes ? { putCall: RestPutCall<Route> } : {}) &
   (Route extends DeleteRestRoutes ? { deleteCall: RestDeleteCall } : {}) &
   (Route extends NewDeleteRestRoutes ? { newDeleteCall: RestNewDeleteCall<Route> } : {}) &
   (Route extends NewDownloadRestRoutes ? { newDownloadCall: NewDownload } : {}) &
@@ -241,7 +238,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k
   '/rest/etapes/:etapeId/entrepriseDocuments': { newGetCall: getEtapeEntrepriseDocuments, ...CaminoRestRoutes['/rest/etapes/:etapeId/entrepriseDocuments'] },
   '/rest/etapes/:etapeId/etapeDocuments': { newGetCall: getEtapeDocuments, ...CaminoRestRoutes['/rest/etapes/:etapeId/etapeDocuments'] },
   '/rest/etapes/:etapeId/etapeAvis': { newGetCall: getEtapeAvis, ...CaminoRestRoutes['/rest/etapes/:etapeId/etapeAvis'] },
-  '/rest/activites/:activiteId': { getCall: getActivite, putCall: updateActivite, deleteCall: deleteActivite, ...CaminoRestRoutes['/rest/activites/:activiteId'] },
+  '/rest/activites/:activiteId': { getCall: getActivite, newPutCall: updateActivite, deleteCall: deleteActivite, ...CaminoRestRoutes['/rest/activites/:activiteId'] },
   '/rest/communes': { newGetCall: getCommunes, ...CaminoRestRoutes['/rest/communes'] },
   '/rest/geojson/import/:geoSystemeId': { newPostCall: geojsonImport, ...CaminoRestRoutes['/rest/geojson/import/:geoSystemeId'] },
   '/rest/geojson_points/import/:geoSystemeId': { newPostCall: geojsonImportPoints, ...CaminoRestRoutes['/rest/geojson_points/import/:geoSystemeId'] },
@@ -477,10 +474,6 @@ export const restWithPool = (dbPool: Pool): Router => {
           }
         })
       }
-      if ('putCall' in maRoute) {
-        console.info(`PUT ${route}`)
-        rest.put(route, restCatcherWithMutation('put', maRoute.putCall(dbPool), dbPool)) // eslint-disable-line @typescript-eslint/no-misused-promises
-      }
 
       if ('deleteCall' in maRoute) {
         console.info(`delete ${route}`)
diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts
index 95ed7cc15..150d1806f 100644
--- a/packages/common/src/rest.ts
+++ b/packages/common/src/rest.ts
@@ -53,7 +53,7 @@ import { fiscaliteValidator } from './validators/fiscalite'
 import { caminoConfigValidator } from './static/config'
 import { communeIdValidator, communeValidator } from './static/communes'
 import { Expect, isFalse, isNotNullNorUndefined, isTrue } from './typescript-tools'
-import { activiteDocumentIdValidator, activiteEditionValidator, activiteIdOrSlugValidator, activiteValidator } from './activite'
+import { activiteDocumentIdValidator, activiteEditionValidator, activiteIdOrSlugValidator, activiteIdValidator, activiteValidator } from './activite'
 import { geoSystemeIdValidator } from './static/geoSystemes'
 import {
   geojsonImportBodyValidator,
@@ -74,7 +74,6 @@ type CaminoRoute<T extends string> = { params: ZodObjectParsUrlParams<T> } & {
   newGet?: { output: ZodType; searchParams?: ZodType }
   post?: { input: ZodType; output: ZodType }
   newPost?: { input: ZodType; output: ZodType }
-  put?: { input: ZodType; output: ZodType }
   newPut?: { input: ZodType; output: ZodType }
   newDelete?: true
   delete?: true
@@ -237,7 +236,7 @@ export const CaminoRestRoutes = {
   '/rest/activites/:activiteId': {
     params: z.object({ activiteId: activiteIdOrSlugValidator }),
     get: { output: activiteValidator },
-    put: { input: activiteEditionValidator, output: z.void() },
+    newPut: { input: activiteEditionValidator, output: z.object({ id: activiteIdValidator }) },
     delete: true,
   },
   '/rest/communes': { params: noParamsValidator, newGet: { output: z.array(communeValidator), searchParams: z.object({ ids: z.array(communeIdValidator).nonempty() }) } },
@@ -313,7 +312,6 @@ export type DeleteRestRoutes = CaminoRestRouteList<typeof IDS, 'delete'>[number]
 export type NewDeleteRestRoutes = CaminoRestRouteList<typeof IDS, 'newDelete'>[number]
 export type DownloadRestRoutes = CaminoRestRouteList<typeof IDS, 'download'>[number]
 export type NewDownloadRestRoutes = CaminoRestRouteList<typeof IDS, 'newDownload'>[number]
-export type PutRestRoutes = CaminoRestRouteList<typeof IDS, 'put'>[number]
 
 export type CaminoRestParams<Route extends CaminoRestRoute> = z.infer<(typeof CaminoRestRoutes)[Route]['params']>
 
diff --git a/packages/ui/src/api/client-rest.ts b/packages/ui/src/api/client-rest.ts
index b5300bfec..1128d1874 100644
--- a/packages/ui/src/api/client-rest.ts
+++ b/packages/ui/src/api/client-rest.ts
@@ -9,7 +9,6 @@ import {
   getRestRoute,
   GetRestRoutes,
   PostRestRoutes,
-  PutRestRoutes,
   NewPostRestRoutes,
   NewGetRestRoutes,
   NewPutRestRoutes,
@@ -231,14 +230,6 @@ export const newPostWithJson = async <T extends NewPostRestRoutes>(
     return { ...errorMessage, extra: e }
   }
 }
-/**
- * @deprecated use newPutWithJson
- **/
-export const putWithJson = async <T extends PutRestRoutes>(
-  path: T,
-  params: CaminoRestParams<T>,
-  body: z.infer<(typeof CaminoRestRoutes)[T]['put']['input']>
-): Promise<z.infer<(typeof CaminoRestRoutes)[T]['put']['output']>> => callFetch(path, params, 'put', {}, body)
 
 export const newPutWithJson = async <T extends NewPutRestRoutes>(
   path: T,
diff --git a/packages/ui/src/components/activite-edition.stories.tsx b/packages/ui/src/components/activite-edition.stories.tsx
index c43a118df..cf12d3c65 100644
--- a/packages/ui/src/components/activite-edition.stories.tsx
+++ b/packages/ui/src/components/activite-edition.stories.tsx
@@ -169,8 +169,8 @@ const activite: Activite = {
 }
 
 const apiClient: Props['apiClient'] = {
-  updateActivite(_activiteId, _sectionsWithValue, _activiteDocumentIds, _newTempDocuments): Promise<void> {
-    return Promise.resolve(undefined)
+  updateActivite(activiteId, _sectionsWithValue, _activiteDocumentIds, _newTempDocuments) {
+    return Promise.resolve({ id: activiteId })
   },
   getActivite: activiteId => {
     getActiviteAction(activiteId)
diff --git a/packages/ui/src/components/activite-edition.tsx b/packages/ui/src/components/activite-edition.tsx
index de6cc2f37..e9a65908d 100644
--- a/packages/ui/src/components/activite-edition.tsx
+++ b/packages/ui/src/components/activite-edition.tsx
@@ -101,23 +101,22 @@ export const PureActiviteEdition = defineComponent<Props>(props => {
 
   const save = async (goBack: boolean) => {
     if (data.value.status === 'LOADED') {
-      try {
-        await props.apiClient.updateActivite(
-          data.value.value.id,
-          data.value.value.type_id,
-          sectionsComplete.value.sectionsWithValue,
-          documentsComplete.value.activiteDocumentIds,
-          documentsComplete.value.tempsDocuments
-        )
+      const result = await props.apiClient.updateActivite(
+        data.value.value.id,
+        data.value.value.type_id,
+        sectionsComplete.value.sectionsWithValue,
+        documentsComplete.value.activiteDocumentIds,
+        documentsComplete.value.tempsDocuments
+      )
+      if ('message' in result) {
+        data.value = {
+          status: 'NEW_ERROR',
+          error: result,
+        }
+      } else {
         if (goBack) {
           props.goBack(data.value.value.id)
         }
-      } catch (e: any) {
-        console.error('error', e)
-        data.value = {
-          status: 'ERROR',
-          message: e.message ?? "Une erreur s'est produite",
-        }
       }
     }
   }
diff --git a/packages/ui/src/components/activite/activite-api-client.ts b/packages/ui/src/components/activite/activite-api-client.ts
index ca9de4ddf..32f8daa1c 100644
--- a/packages/ui/src/components/activite/activite-api-client.ts
+++ b/packages/ui/src/components/activite/activite-api-client.ts
@@ -4,9 +4,10 @@ import { CaminoAnnee } from 'camino-common/src/date'
 import { ActivitesStatutId } from 'camino-common/src/static/activitesStatuts'
 import { ActivitesTypesId } from 'camino-common/src/static/activitesTypes'
 import gql from 'graphql-tag'
-import { deleteWithJson, getWithJson, putWithJson } from '../../api/client-rest'
+import { deleteWithJson, getWithJson, newPutWithJson } from '../../api/client-rest'
 import { SectionWithValue } from 'camino-common/src/sections'
 import { EntrepriseId } from 'camino-common/src/entreprise'
+import { CaminoError } from 'camino-common/src/zod-tools'
 
 export interface UiGraphqlActivite {
   id: string
@@ -33,7 +34,7 @@ export interface ActiviteApiClient {
     sectionsWithValue: SectionWithValue[],
     activiteDocumentIds: ActiviteDocumentId[],
     newTempDocuments: TempActiviteDocument[]
-  ) => Promise<void>
+  ) => Promise<{ id: ActiviteId } | CaminoError<string>>
 }
 
 type GetActivitesParams = {
@@ -125,7 +126,7 @@ export const activiteApiClient: ActiviteApiClient = {
     activiteDocumentIds: ActiviteDocumentId[],
     newTempDocuments: TempActiviteDocument[]
   ) => {
-    return putWithJson(
+    return newPutWithJson(
       '/rest/activites/:activiteId',
       {
         activiteId,
diff --git a/packages/ui/src/components/utilisateur.stories.tsx b/packages/ui/src/components/utilisateur.stories.tsx
index 1110f24da..d78efd8f0 100644
--- a/packages/ui/src/components/utilisateur.stories.tsx
+++ b/packages/ui/src/components/utilisateur.stories.tsx
@@ -36,7 +36,7 @@ const apiClientMock: Props['apiClient'] = {
   updateUtilisateur: params => {
     updateUtilisateur(params)
 
-    return Promise.resolve()
+    return Promise.resolve({ id: params.id })
   },
   getQGISToken: () => new Promise(resolve => setTimeout(() => resolve({ token: qgisTokenValidator.parse('token123'), url: 'https://google.fr' }), 1000)),
 }
-- 
GitLab


From 7734e662a63d131fe6c9aa7c6b6beade19623234 Mon Sep 17 00:00:00 2001
From: Anis Safine Laget <anis.safine@beta.gouv.fr>
Date: Mon, 7 Apr 2025 15:31:58 +0200
Subject: [PATCH 7/8] tableau de bord activites supprimables

---
 .../api/src/api/rest/activites.queries.ts     |  25 +++
 .../src/api/rest/activites.queries.types.ts   |  20 ++
 packages/api/src/api/rest/activites.ts        |  33 ++-
 packages/api/src/server/rest.ts               |   3 +-
 packages/common/src/activite.ts               |  12 ++
 packages/common/src/rest.ts                   |   6 +-
 packages/ui/src/components/dashboard.tsx      |  78 ++++---
 .../dashboard/dashboard-api-client.ts         |   3 +
 .../pure-super-dashboard.stories.tsx          | 140 +++++++++++-
 ...rd.stories_snapshots_LoadingActivites.html |  29 +++
 ...rd.stories_snapshots_LoadingBrouillon.html |  29 +++
 ...ories_snapshots_TableauPleinActivites.html |  69 ++++++
 ...ories_snapshots_TableauPleinBrouillon.html |  76 +++++++
 ...tories_snapshots_TableauVideActivites.html |  25 +++
 ...tories_snapshots_TableauVideBrouillon.html |  25 +++
 ....stories_snapshots_WithErrorActivites.html |  31 +++
 ....stories_snapshots_WithErrorBrouillon.html |  31 +++
 .../dashboard/pure-super-dashboard.tsx        | 204 +++++++++++++++---
 18 files changed, 772 insertions(+), 67 deletions(-)
 create mode 100644 packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingActivites.html
 create mode 100644 packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingBrouillon.html
 create mode 100644 packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinActivites.html
 create mode 100644 packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinBrouillon.html
 create mode 100644 packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideActivites.html
 create mode 100644 packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideBrouillon.html
 create mode 100644 packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorActivites.html
 create mode 100644 packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorBrouillon.html

diff --git a/packages/api/src/api/rest/activites.queries.ts b/packages/api/src/api/rest/activites.queries.ts
index 44eca5555..6bd50456a 100644
--- a/packages/api/src/api/rest/activites.queries.ts
+++ b/packages/api/src/api/rest/activites.queries.ts
@@ -11,15 +11,18 @@ import {
   IInsertActiviteDocumentInternalQuery,
   IUpdateActiviteDbQuery,
   IActiviteDeleteDbQuery,
+  IGetActivitesSuperDbQuery,
 } from './activites.queries.types'
 import {
   ActiviteDocument,
   ActiviteDocumentId,
   ActiviteId,
   ActiviteIdOrSlug,
+  ActiviteSuper,
   activiteDocumentIdValidator,
   activiteDocumentValidator,
   activiteIdValidator,
+  activiteSuperValidator,
   activiteValidator,
 } from 'camino-common/src/activite'
 import { Pool } from 'pg'
@@ -379,3 +382,25 @@ where
     d.id = $ activiteDocumentId !
 LIMIT 1
 `
+
+export const getActivitesSuper = (pool: Pool): Effect.Effect<ActiviteSuper[], CaminoError<EffectDbQueryAndValidateErrors>> => Effect.Do.pipe(
+  Effect.flatMap(() => effectDbQueryAndValidate(getActivitesSuperDb, {}, pool, activiteSuperValidator))
+)
+
+
+const getActivitesSuperDb = sql<
+  Redefine<IGetActivitesSuperDbQuery, {}, z.infer<typeof activiteSuperValidator>>
+>`
+select
+    t.nom as titre_nom,
+    t.type_id as titre_type_id,
+    ta.id,
+    ta.annee,
+    ta.type_id,
+    ta.periode_id,
+    ta.activite_statut_id
+from titres_activites ta
+left join titres t on ta.titre_id = t.id
+where ta.suppression is true
+order by t.nom asc, ta.annee asc, ta.periode_id asc
+`
\ No newline at end of file
diff --git a/packages/api/src/api/rest/activites.queries.types.ts b/packages/api/src/api/rest/activites.queries.types.ts
index d55d52363..5e6a94c7d 100644
--- a/packages/api/src/api/rest/activites.queries.types.ts
+++ b/packages/api/src/api/rest/activites.queries.types.ts
@@ -197,3 +197,23 @@ export interface IGetLargeobjectIdByActiviteDocumentIdInternalQuery {
   result: IGetLargeobjectIdByActiviteDocumentIdInternalResult;
 }
 
+/** 'GetActivitesSuperDb' parameters type */
+export type IGetActivitesSuperDbParams = void;
+
+/** 'GetActivitesSuperDb' return type */
+export interface IGetActivitesSuperDbResult {
+  activite_statut_id: string;
+  annee: number;
+  id: string;
+  periode_id: number;
+  titre_nom: string;
+  titre_type_id: string;
+  type_id: string;
+}
+
+/** 'GetActivitesSuperDb' query type */
+export interface IGetActivitesSuperDbQuery {
+  params: IGetActivitesSuperDbParams;
+  result: IGetActivitesSuperDbResult;
+}
+
diff --git a/packages/api/src/api/rest/activites.ts b/packages/api/src/api/rest/activites.ts
index 95fcc6284..0152a51f3 100644
--- a/packages/api/src/api/rest/activites.ts
+++ b/packages/api/src/api/rest/activites.ts
@@ -1,7 +1,7 @@
 import { CaminoRequest, CustomResponse } from './express-type'
 import { HTTP_STATUS } from 'camino-common/src/http'
 import { Pool } from 'pg'
-import { Activite, activiteDocumentIdValidator, ActiviteId, activiteIdOrSlugValidator, activiteIdValidator } from 'camino-common/src/activite'
+import { Activite, activiteDocumentIdValidator, ActiviteId, activiteIdOrSlugValidator, activiteIdValidator, ActiviteSuper } from 'camino-common/src/activite'
 import {
   Contenu,
   administrationsLocalesByActiviteId,
@@ -18,6 +18,7 @@ import {
   activiteDeleteQuery,
   UpdateActiviteQueryErrors,
   DeleteActiviteDocumentErrors,
+  getActivitesSuper,
 } from './activites.queries'
 import { NewDownload } from './fichiers'
 import { SimplePromiseFn, isNonEmptyArray, memoize } from 'camino-common/src/typescript-tools'
@@ -27,16 +28,17 @@ import { Section, getSectionsWithValue } from 'camino-common/src/static/titresTy
 import { newActiviteDocumentId } from '../../database/models/_format/id-create'
 import { ACTIVITES_STATUTS_IDS } from 'camino-common/src/static/activitesStatuts'
 import { Unites } from 'camino-common/src/static/unites'
-import { User } from 'camino-common/src/roles'
+import { isSuper, User } from 'camino-common/src/roles'
 import { TitreTypeId } from 'camino-common/src/static/titresTypes'
 import { AdministrationId } from 'camino-common/src/static/administrations'
 import { EntrepriseId } from 'camino-common/src/entreprise'
 import { getCurrent } from 'camino-common/src/date'
 import { createLargeObject, CreateLargeObjectError } from '../../database/largeobjects'
 import { callAndExit } from '../../tools/fp-tools'
-import { RestNewPutCall } from '../../server/rest'
+import { RestNewGetCall, RestNewPutCall } from '../../server/rest'
 import { CaminoApiError } from '../../types'
 import { Effect, Match, Option } from 'effect'
+import { EffectDbQueryAndValidateErrors } from '../../pg-database'
 
 const extractContenuFromSectionWithValue = (sections: Section[], sectionsWithValue: SectionWithValue[]): Contenu => {
   const contenu: Contenu = {}
@@ -243,3 +245,28 @@ export const activiteDocumentDownload: NewDownload = async (params, user, pool)
 
   return { loid: activiteDocumentLargeObjectId, fileName: activiteDocumentId }
 }
+
+const permissionsInsuffisantes = 'Permissions insuffisantes' as const
+type GetActivitesSuperErrors = EffectDbQueryAndValidateErrors | typeof permissionsInsuffisantes
+
+export const getActivitesForTDBSuper: RestNewGetCall<'/rest/activitesSuper'> = (rootPipe): Effect.Effect<ActiviteSuper[], CaminoApiError<GetActivitesSuperErrors>> => rootPipe.pipe(
+  Effect.filterOrFail(
+    ({ user }) => isSuper(user),
+    () => ({ message: permissionsInsuffisantes })
+  ),
+  Effect.flatMap(({ pool }) => getActivitesSuper(pool)),
+  Effect.mapError(caminoError =>
+    Match.value(caminoError.message).pipe(
+      Match.whenOr("Permissions insuffisantes", () => ({
+        ...caminoError,
+        status: HTTP_STATUS.FORBIDDEN,
+      })),
+      Match.whenOr(
+        "Impossible d'exécuter la requête dans la base de données",
+        'Les données en base ne correspondent pas à ce qui est attendu',
+        () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })
+      ),
+      Match.exhaustive
+    )
+  )
+)
\ No newline at end of file
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index 31a1979cf..9d82737a4 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -45,7 +45,7 @@ import { createEtape, deleteEtape, deposeEtape, getEtape, getEtapeAvis, getEtape
 import { ZodType, z } from 'zod'
 import { getCommunes } from '../api/rest/communes'
 import { SendFileOptions } from 'express-serve-static-core'
-import { activiteDocumentDownload, getActivite, updateActivite, deleteActivite } from '../api/rest/activites'
+import { activiteDocumentDownload, getActivite, updateActivite, deleteActivite, getActivitesForTDBSuper } from '../api/rest/activites'
 import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools'
 import { getDemarcheByIdOrSlugApi, demarcheSupprimer, demarcheCreer, getDemarchesEnConcurrence, getResultatEnConcurrence } from '../api/rest/demarches'
 import { geojsonImport, geojsonImportPoints, geojsonImportForages, getPerimetreInfosByDemarche, getPerimetreInfosByEtape } from '../api/rest/perimetre'
@@ -239,6 +239,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k
   '/rest/etapes/:etapeId/etapeDocuments': { newGetCall: getEtapeDocuments, ...CaminoRestRoutes['/rest/etapes/:etapeId/etapeDocuments'] },
   '/rest/etapes/:etapeId/etapeAvis': { newGetCall: getEtapeAvis, ...CaminoRestRoutes['/rest/etapes/:etapeId/etapeAvis'] },
   '/rest/activites/:activiteId': { getCall: getActivite, newPutCall: updateActivite, deleteCall: deleteActivite, ...CaminoRestRoutes['/rest/activites/:activiteId'] },
+  '/rest/activitesSuper': { newGetCall: getActivitesForTDBSuper, ...CaminoRestRoutes['/rest/activitesSuper'] },
   '/rest/communes': { newGetCall: getCommunes, ...CaminoRestRoutes['/rest/communes'] },
   '/rest/geojson/import/:geoSystemeId': { newPostCall: geojsonImport, ...CaminoRestRoutes['/rest/geojson/import/:geoSystemeId'] },
   '/rest/geojson_points/import/:geoSystemeId': { newPostCall: geojsonImportPoints, ...CaminoRestRoutes['/rest/geojson_points/import/:geoSystemeId'] },
diff --git a/packages/common/src/activite.ts b/packages/common/src/activite.ts
index 624090445..659aeb234 100644
--- a/packages/common/src/activite.ts
+++ b/packages/common/src/activite.ts
@@ -5,6 +5,7 @@ import { sectionWithValueValidator } from './sections'
 import { activiteStatutIdValidator } from './static/activitesStatuts'
 import { activiteTypeIdValidator } from './static/activitesTypes'
 import { tempDocumentNameValidator } from './document'
+import { titreTypeIdValidator } from './static/titresTypes'
 
 export const activiteSlugValidator = z.string().brand<'ActiviteSlug'>()
 
@@ -54,3 +55,14 @@ export const activiteEditionValidator = z.object({
   activiteDocumentIds: z.array(activiteDocumentIdValidator),
   newTempDocuments: z.array(tempActiviteDocumentValidator),
 })
+
+export const activiteSuperValidator = z.object({
+  titre_nom: z.string(),
+  titre_type_id: titreTypeIdValidator,
+  id: activiteIdValidator,
+  annee: caminoAnneeValidator,
+  type_id: activiteTypeIdValidator,
+  periode_id: z.number(),
+  activite_statut_id: activiteStatutIdValidator
+})
+export type ActiviteSuper = z.infer<typeof activiteSuperValidator>
\ No newline at end of file
diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts
index 150d1806f..dec1f6360 100644
--- a/packages/common/src/rest.ts
+++ b/packages/common/src/rest.ts
@@ -53,7 +53,7 @@ import { fiscaliteValidator } from './validators/fiscalite'
 import { caminoConfigValidator } from './static/config'
 import { communeIdValidator, communeValidator } from './static/communes'
 import { Expect, isFalse, isNotNullNorUndefined, isTrue } from './typescript-tools'
-import { activiteDocumentIdValidator, activiteEditionValidator, activiteIdOrSlugValidator, activiteIdValidator, activiteValidator } from './activite'
+import { activiteDocumentIdValidator, activiteEditionValidator, activiteIdOrSlugValidator, activiteIdValidator, activiteSuperValidator, activiteValidator } from './activite'
 import { geoSystemeIdValidator } from './static/geoSystemes'
 import {
   geojsonImportBodyValidator,
@@ -125,6 +125,7 @@ const IDS = [
   '/rest/etapes/:etapeId/depot',
   '/rest/etapes',
   '/rest/activites/:activiteId',
+  '/rest/activitesSuper',
   '/rest/geojson/import/:geoSystemeId',
   '/rest/geojson_points/import/:geoSystemeId',
   '/rest/geojson_forages/import/:geoSystemeId',
@@ -239,6 +240,9 @@ export const CaminoRestRoutes = {
     newPut: { input: activiteEditionValidator, output: z.object({ id: activiteIdValidator }) },
     delete: true,
   },
+  '/rest/activitesSuper': {
+    newGet: { output: z.array(activiteSuperValidator) }, params: noParamsValidator
+  },
   '/rest/communes': { params: noParamsValidator, newGet: { output: z.array(communeValidator), searchParams: z.object({ ids: z.array(communeIdValidator).nonempty() }) } },
   '/rest/geojson/import/:geoSystemeId': {
     params: geoSystemIdParamsValidator,
diff --git a/packages/ui/src/components/dashboard.tsx b/packages/ui/src/components/dashboard.tsx
index 727b6bbef..68dbbf1e9 100644
--- a/packages/ui/src/components/dashboard.tsx
+++ b/packages/ui/src/components/dashboard.tsx
@@ -1,9 +1,11 @@
-import { FunctionalComponent, defineAsyncComponent, defineComponent, inject, onMounted, ref } from 'vue'
+import { defineAsyncComponent, defineComponent, inject, onMounted, ref } from 'vue'
 import { useRouter } from 'vue-router'
 import { dashboardApiClient } from './dashboard/dashboard-api-client'
-import { User, isAdministration, isEntrepriseOrBureauDEtude, isSuper } from 'camino-common/src/roles'
+import { AdminUserNotNull, EntrepriseUserNotNull, User, isAdministration, isEntrepriseOrBureauDEtude, isSuper } from 'camino-common/src/roles'
 import { entreprisesKey, userKey } from '@/moi'
 import { Entreprise } from 'camino-common/src/entreprise'
+import { CaminoRouteLocation } from '@/router/routes'
+import { CaminoRouter } from '@/typings/vue-router'
 
 export const Dashboard = defineComponent({
   setup() {
@@ -18,37 +20,61 @@ export const Dashboard = defineComponent({
       }
     })
 
-    return () => <PureDashboard user={user} entreprises={entreprises.value} />
+    return () => <PureDashboard user={user} entreprises={entreprises.value} router={router} route={router.currentRoute.value} />
   },
 })
 
-const PureDashboard: FunctionalComponent<{ user: User; entreprises: Entreprise[] }> = props => {
-  if (isEntrepriseOrBureauDEtude(props.user)) {
-    const PureEntrepriseDashboard = defineAsyncComponent(async () => {
-      const { PureEntrepriseDashboard } = await import('@/components/dashboard/pure-entreprise-dashboard')
+type PureDashboardProps = { user: User; entreprises: Entreprise[]; route: CaminoRouteLocation; router: CaminoRouter }
+const PureDashboard = defineComponent((props: PureDashboardProps) => {
+  return () => (
+    <>
+      {isEntrepriseOrBureauDEtude(props.user) ? <PureEntrepriseDashboard user={props.user} entreprises={props.entreprises} /> : null}
+      {isAdministration(props.user) ? <PureAdministrationDashboard user={props.user} entreprises={props.entreprises} /> : null}
+      {isSuper(props.user) ? <PureSuperDashboard {...props} /> : null}
+    </>
+  )
+})
 
-      return PureEntrepriseDashboard
-    })
-    const entrepriseIds = props.user.entrepriseIds ?? []
+const PureEntrepriseDashboard = defineComponent((props: Pick<PureDashboardProps, 'entreprises'> & { user: EntrepriseUserNotNull }) => {
+  const PureEntrepriseDashboardComponent = defineAsyncComponent(async () => {
+    const { PureEntrepriseDashboard } = await import('@/components/dashboard/pure-entreprise-dashboard')
 
-    return <PureEntrepriseDashboard apiClient={dashboardApiClient} user={props.user} entrepriseIds={entrepriseIds} allEntreprises={props.entreprises} />
-  } else if (isAdministration(props.user)) {
-    const PureAdministrationDashboard = defineAsyncComponent(async () => {
-      const { PureAdministrationDashboard } = await import('@/components/dashboard/pure-administration-dashboard')
+    return PureEntrepriseDashboard
+  })
+  const entrepriseIds = props.user.entrepriseIds ?? []
 
-      return PureAdministrationDashboard
-    })
 
-    return <PureAdministrationDashboard apiClient={dashboardApiClient} user={props.user} entreprises={props.entreprises} />
-  } else if (isSuper(props.user)) {
-    const PureSuperDashboard = defineAsyncComponent(async () => {
-      const { PureSuperDashboard } = await import('@/components/dashboard/pure-super-dashboard')
+  return () => <PureEntrepriseDashboardComponent apiClient={dashboardApiClient} user={props.user} entrepriseIds={entrepriseIds} allEntreprises={props.entreprises} />
+})
 
-      return PureSuperDashboard
-    })
+const PureSuperDashboard = defineComponent((props: PureDashboardProps) => {
+  const PureSuperDashboardComponent = defineAsyncComponent(async () => {
+    const { PureSuperDashboard } = await import('@/components/dashboard/pure-super-dashboard')
+
+    return PureSuperDashboard
+  })
+
+  return () => <PureSuperDashboardComponent apiClient={dashboardApiClient} user={props.user} route={props.route} router={props.router} />
+})
+
+const PureAdministrationDashboard = defineComponent((props: Pick<PureDashboardProps, 'entreprises'> & { user: AdminUserNotNull }) => {
+  const PureAdministrationDashboardComponent = defineAsyncComponent(async () => {
+    const { PureAdministrationDashboard } = await import('@/components/dashboard/pure-administration-dashboard')
+
+    return PureAdministrationDashboard
+  })
+
+  return () => <PureAdministrationDashboardComponent apiClient={dashboardApiClient} user={props.user} entreprises={props.entreprises} />
+})
+
+// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833
+PureDashboard.props = ['user', 'entreprises', 'route', 'router']
+
+// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833
+PureSuperDashboard.props = ['user', 'entreprises', 'route', 'router']
 
-    return <PureSuperDashboard apiClient={dashboardApiClient} user={props.user} />
-  }
+// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833
+PureAdministrationDashboard.props = ['user', 'entreprises', 'route', 'router']
 
-  return null
-}
+// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833
+PureEntrepriseDashboard.props = ['user', 'entreprises', 'route', 'router']
diff --git a/packages/ui/src/components/dashboard/dashboard-api-client.ts b/packages/ui/src/components/dashboard/dashboard-api-client.ts
index e94ba5b7e..deebe9b65 100644
--- a/packages/ui/src/components/dashboard/dashboard-api-client.ts
+++ b/packages/ui/src/components/dashboard/dashboard-api-client.ts
@@ -1,5 +1,6 @@
 import { apiGraphQLFetch } from '@/api/_client'
 import { getWithJson, newGetWithJson } from '@/api/client-rest'
+import { ActiviteSuper } from 'camino-common/src/activite'
 import { EntrepriseId, TitreEntreprise } from 'camino-common/src/entreprise'
 import { StatistiquesDGTM } from 'camino-common/src/statistiques'
 import { CommonTitreAdministration, SuperTitre } from 'camino-common/src/titres'
@@ -11,6 +12,7 @@ export interface DashboardApiClient {
   getDgtmStats: () => Promise<StatistiquesDGTM>
   getEntreprisesTitres: (entreprisesIds: EntrepriseId[]) => Promise<TitreEntreprise[]>
   getTitresAvecEtapeEnBrouillon: () => Promise<SuperTitre[] | CaminoError<string>>
+  getActivitesSuper: () => Promise<ActiviteSuper[] | CaminoError<string>>
 }
 const titres = apiGraphQLFetch(gql`
   query Titres(
@@ -80,4 +82,5 @@ export const dashboardApiClient: DashboardApiClient = {
     return (await titres({ entreprisesIds })).elements
   },
   getTitresAvecEtapeEnBrouillon: async (): Promise<SuperTitre[] | CaminoError<string>> => newGetWithJson('/rest/titresSuper', {}),
+  getActivitesSuper: () => newGetWithJson('/rest/activitesSuper', {}),
 }
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx b/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx
index b665f212f..75cee02de 100644
--- a/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx
@@ -4,8 +4,14 @@ import { titreSlugValidator } from 'camino-common/src/validators/titres'
 import { PureSuperDashboard } from './pure-super-dashboard'
 import { SuperTitre } from 'camino-common/src/titres'
 import { demarcheSlugValidator } from 'camino-common/src/demarche'
-import { caminoDateValidator } from 'camino-common/src/date'
+import { caminoDateValidator, toCaminoAnnee } from 'camino-common/src/date'
 import { etapeSlugValidator } from 'camino-common/src/etape'
+import { activiteIdValidator, ActiviteSuper } from 'camino-common/src/activite'
+import { CaminoRouteLocation } from '@/router/routes'
+import { CaminoRouter } from '@/typings/vue-router'
+import { ACTIVITES_TYPES_IDS } from 'camino-common/src/static/activitesTypes'
+import { ACTIVITES_STATUTS_IDS } from 'camino-common/src/static/activitesStatuts'
+import { TITRES_TYPES_IDS } from 'camino-common/src/static/titresTypes'
 
 const meta: Meta = {
   title: 'Components/Dashboard/Super',
@@ -14,6 +20,26 @@ const meta: Meta = {
 }
 export default meta
 
+const activites: ActiviteSuper[] = [
+  {
+    activite_statut_id: ACTIVITES_STATUTS_IDS.DEPOSE,
+    annee: toCaminoAnnee(2025),
+    id: activiteIdValidator.parse('id1'),
+    periode_id: 1,
+    titre_nom: 'Nom du titre',
+    titre_type_id: TITRES_TYPES_IDS.AUTORISATION_D_EXPLOITATION_METAUX,
+    type_id: ACTIVITES_TYPES_IDS['rapport trimestriel d\'exploitation d\'or en Guyane']
+  },
+  {
+    activite_statut_id: ACTIVITES_STATUTS_IDS.CLOTURE,
+    annee: toCaminoAnnee(2024),
+    id: activiteIdValidator.parse('id2'),
+    periode_id: 3,
+    titre_nom: 'Nom du titre 2',
+    titre_type_id: TITRES_TYPES_IDS.PERMIS_D_EXPLOITATION_METAUX,
+    type_id: ACTIVITES_TYPES_IDS["rapport d'intensité d'exploration"]
+  }
+]
 const titres: SuperTitre[] = [
   {
     titre_nom: 'Aachen',
@@ -39,10 +65,112 @@ const titres: SuperTitre[] = [
   },
 ]
 
-export const TableauVide: StoryFn = () => <PureSuperDashboard user={{ role: 'super', ...testBlankUser }} apiClient={{ getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]) }} />
-export const TableauPlein: StoryFn = () => <PureSuperDashboard user={{ role: 'super', ...testBlankUser }} apiClient={{ getTitresAvecEtapeEnBrouillon: () => Promise.resolve(titres) }} />
-export const Loading: StoryFn = () => <PureSuperDashboard user={{ role: 'super', ...testBlankUser }} apiClient={{ getTitresAvecEtapeEnBrouillon: () => new Promise<SuperTitre[]>(_resolve => {}) }} />
+const router: Pick<CaminoRouter, 'push'> = {
+  push: _ => {
+    return Promise.resolve()
+  },
+}
+
+const routeBrouillon: CaminoRouteLocation = {
+  name: 'dashboard',
+  params: {},
+  query: {vueId: 'brouillons'}
+}
+
+const routeActivite: CaminoRouteLocation = {
+  name: 'dashboard',
+  params: {},
+  query: {vueId: 'activites'}
+}
+
+export const TableauVideBrouillon: StoryFn = () => (
+  <PureSuperDashboard
+    user={{ role: 'super', ...testBlankUser }}
+    apiClient={{
+      getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]),
+      getActivitesSuper: () => Promise.resolve([]),
+    }}
+    route={routeBrouillon}
+    router={router}
+  />
+)
+export const TableauVideActivites: StoryFn = () => (
+  <PureSuperDashboard
+    user={{ role: 'super', ...testBlankUser }}
+    apiClient={{
+      getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]),
+      getActivitesSuper: () => Promise.resolve([]),
+    }}
+    route={routeActivite}
+    router={router}
+  />
+)
+
+export const TableauPleinBrouillon: StoryFn = () => (
+  <PureSuperDashboard
+    user={{ role: 'super', ...testBlankUser }}
+    apiClient={{
+      getTitresAvecEtapeEnBrouillon: () => Promise.resolve(titres),
+      getActivitesSuper: () => Promise.resolve(activites),
+    }}
+    route={routeBrouillon}
+    router={router}
+  />
+)
+export const TableauPleinActivites: StoryFn = () => (
+  <PureSuperDashboard
+    user={{ role: 'super', ...testBlankUser }}
+    apiClient={{
+      getTitresAvecEtapeEnBrouillon: () => Promise.resolve(titres),
+      getActivitesSuper: () => Promise.resolve(activites),
+    }}
+    route={routeActivite}
+    router={router}
+  />
+)
+export const LoadingBrouillon: StoryFn = () => (
+  <PureSuperDashboard
+    user={{ role: 'super', ...testBlankUser }}
+    apiClient={{
+      getTitresAvecEtapeEnBrouillon: () => new Promise<SuperTitre[]>(_resolve => {}),
+      getActivitesSuper: () => Promise.resolve([]),
+    }}
+    route={routeBrouillon}
+    router={router}
+  />
+)
+export const LoadingActivites: StoryFn = () => (
+  <PureSuperDashboard
+    user={{ role: 'super', ...testBlankUser }}
+    apiClient={{
+      getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]),
+      getActivitesSuper: () => new Promise<ActiviteSuper[]>(_resolve => {}),
+    }}
+    route={routeActivite}
+    router={router}
+  />
+)
+
+export const WithErrorBrouillon: StoryFn = () => (
+  <PureSuperDashboard
+    user={{ role: 'super', ...testBlankUser }}
+    apiClient={{
+      getTitresAvecEtapeEnBrouillon: () => Promise.resolve({ message: 'Une erreur' }),
+      getActivitesSuper: () => Promise.resolve([]),
+    }}
+    route={routeBrouillon}
+    router={router}
+  />
+)
 
-export const WithError: StoryFn = () => (
-  <PureSuperDashboard user={{ role: 'super', ...testBlankUser }} apiClient={{ getTitresAvecEtapeEnBrouillon: () => Promise.resolve({ message: 'Une erreur' }) }} />
+export const WithErrorActivites: StoryFn = () => (
+  <PureSuperDashboard
+    user={{ role: 'super', ...testBlankUser }}
+    apiClient={{
+      getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]),
+      getActivitesSuper: () => Promise.resolve({ message: 'Une erreur' }),
+    }}
+    route={routeActivite}
+    router={router}
+  />
 )
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingActivites.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingActivites.html
new file mode 100644
index 000000000..12c8a3946
--- /dev/null
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingActivites.html
@@ -0,0 +1,29 @@
+<div>
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-6">
+      <h1>Tableau de bord</h1>
+    </div>
+    <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+      <!---->
+      <!---->
+    </div>
+  </div>
+  <div>
+    <div class="fr-tabs" style="--tabs-height: 0px;">
+      <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable">
+        <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Brouillons" aria-selected="false" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li>
+        <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Activités" aria-selected="true" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li>
+      </ul>
+      <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-start" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0">
+        <!---->
+      </div>
+      <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0">
+        <div class="_top-level_3306d0" style="display: flex; justify-content: center;">
+          <!---->
+          <!---->
+          <div class="_spinner_3306d0"></div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingBrouillon.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingBrouillon.html
new file mode 100644
index 000000000..d6b4bac9b
--- /dev/null
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingBrouillon.html
@@ -0,0 +1,29 @@
+<div>
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-6">
+      <h1>Tableau de bord</h1>
+    </div>
+    <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+      <!---->
+      <!---->
+    </div>
+  </div>
+  <div>
+    <div class="fr-tabs" style="--tabs-height: 0px;">
+      <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable">
+        <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Brouillons" aria-selected="true" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li>
+        <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Activités" aria-selected="false" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li>
+      </ul>
+      <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0">
+        <div class="_top-level_3306d0" style="display: flex; justify-content: center;">
+          <!---->
+          <!---->
+          <div class="_spinner_3306d0"></div>
+        </div>
+      </div>
+      <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-end" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0">
+        <!---->
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinActivites.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinActivites.html
new file mode 100644
index 000000000..ae367c77b
--- /dev/null
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinActivites.html
@@ -0,0 +1,69 @@
+<div>
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-6">
+      <h1>Tableau de bord</h1>
+    </div>
+    <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+      <!---->
+      <!---->
+    </div>
+  </div>
+  <div>
+    <div class="fr-tabs" style="--tabs-height: 0px;">
+      <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable">
+        <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Brouillons" aria-selected="false" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li>
+        <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Activités" aria-selected="true" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li>
+      </ul>
+      <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-start" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0">
+        <!---->
+      </div>
+      <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0">
+        <div>
+          <div class="fr-table fr-table--no-scroll" style="overflow: auto;">
+            <div class="fr-table__wrapper" style="width: auto;">
+              <div class="fr-table__container">
+                <div class="fr-table__content">
+                  <table style="display: table; width: 100%;">
+                    <caption>Activités supprimables</caption>
+                    <thead>
+                      <tr>
+                        <th scope="col">Nom</th>
+                        <th scope="col">Type</th>
+                        <th scope="col">Année</th>
+                        <th scope="col">Période</th>
+                        <th scope="col">Type de rapport</th>
+                        <th scope="col">Statut</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr class="fr-label--error">
+                        <td><a href="/mocked-href" title="Nom du titre" aria-label="Nom du titre">Nom du titre</a></td>
+                        <td><span class="small bold">Autorisation d'exploitation</span></td>
+                        <td><span class="">2025</span></td>
+                        <td><span class="">1er trimestre</span></td>
+                        <td><span class="">rapport trimestriel d'exploitation d'or en Guyane</span></td>
+                        <td>
+                          <p style="z-index: unset; margin-bottom: 0px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;" class="fr-badge fr-badge--md fr-badge--green-bourgeon" title="déposé" aria-label="déposé">déposé</p>
+                        </td>
+                      </tr>
+                      <tr>
+                        <td><a href="/mocked-href" title="Nom du titre 2" aria-label="Nom du titre 2">Nom du titre 2</a></td>
+                        <td><span class="small bold">Permis d'exploitation</span></td>
+                        <td><span class="">2024</span></td>
+                        <td><span class=""></span></td>
+                        <td><span class="">rapport d'intensité d'exploration</span></td>
+                        <td>
+                          <p style="z-index: unset; margin-bottom: 0px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;" class="fr-badge fr-badge--md fr-badge--beige-gris-galet" title="cloturé" aria-label="cloturé">cloturé</p>
+                        </td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinBrouillon.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinBrouillon.html
new file mode 100644
index 000000000..91ad57211
--- /dev/null
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinBrouillon.html
@@ -0,0 +1,76 @@
+<div>
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-6">
+      <h1>Tableau de bord</h1>
+    </div>
+    <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+      <!---->
+      <!---->
+    </div>
+  </div>
+  <div>
+    <div class="fr-tabs" style="--tabs-height: 0px;">
+      <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable">
+        <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Brouillons" aria-selected="true" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li>
+        <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Activités" aria-selected="false" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li>
+      </ul>
+      <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0">
+        <div>
+          <div class="fr-table fr-table--no-scroll" style="overflow: auto;">
+            <div class="fr-table__wrapper" style="width: auto;">
+              <div class="fr-table__container">
+                <div class="fr-table__content">
+                  <table style="display: table; width: 100%;">
+                    <caption>Titres avec une étape en brouillon</caption>
+                    <thead>
+                      <tr>
+                        <th scope="col">Nom</th>
+                        <th scope="col">Type de démarche</th>
+                        <th scope="col">Type de titre</th>
+                        <th scope="col">-</th>
+                        <th scope="col">Statut du titre</th>
+                        <th scope="col">Étape en brouillon</th>
+                        <th scope="col">Date de l'étape</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr>
+                        <td><a href="/mocked-href" title="Aachen" aria-label="Aachen">Aachen</a></td>
+                        <td><span class="">Octroi</span></td>
+                        <td><span class="small bold">Concession</span></td>
+                        <td>
+                          <p class="fr-tag fr-tag--md mono" title="Domaine minéraux et métaux" aria-label="Domaine minéraux et métaux" style="min-width: 2rem; background-color: var(--background-contrast-blue-ecume); color: black;">M</p>
+                        </td>
+                        <td>
+                          <p style="z-index: unset; margin-bottom: 0px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;" class="fr-badge fr-badge--md fr-badge--beige-gris-galet" title="échu" aria-label="échu">échu</p>
+                        </td>
+                        <td><span class="">Demande</span></td>
+                        <td><span class="">1810-01-01</span></td>
+                      </tr>
+                      <tr>
+                        <td><a href="/mocked-href" title="Amadis 5" aria-label="Amadis 5">Amadis 5</a></td>
+                        <td><span class="">Octroi</span></td>
+                        <td><span class="small bold">Permis d'exploitation</span></td>
+                        <td>
+                          <p class="fr-tag fr-tag--md mono" title="Domaine géothermie" aria-label="Domaine géothermie" style="min-width: 2rem; background-color: var(--background-contrast-pink-tuile); color: black;">G</p>
+                        </td>
+                        <td>
+                          <p style="z-index: unset; margin-bottom: 0px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;" class="fr-badge fr-badge--md fr-badge--green-bourgeon" title="valide" aria-label="valide">valide</p>
+                        </td>
+                        <td><span class="">Avis des services et commissions consultatives</span></td>
+                        <td><span class="">2022-01-01</span></td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-end" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0">
+        <!---->
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideActivites.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideActivites.html
new file mode 100644
index 000000000..69ad48dfc
--- /dev/null
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideActivites.html
@@ -0,0 +1,25 @@
+<div>
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-6">
+      <h1>Tableau de bord</h1>
+    </div>
+    <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+      <!---->
+      <!---->
+    </div>
+  </div>
+  <div>
+    <div class="fr-tabs" style="--tabs-height: 0px;">
+      <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable">
+        <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Brouillons" aria-selected="false" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li>
+        <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Activités" aria-selected="true" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li>
+      </ul>
+      <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-start" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0">
+        <!---->
+      </div>
+      <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0">
+        <p>Aucune activité supprimable</p>
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideBrouillon.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideBrouillon.html
new file mode 100644
index 000000000..3eaa5ac02
--- /dev/null
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideBrouillon.html
@@ -0,0 +1,25 @@
+<div>
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-6">
+      <h1>Tableau de bord</h1>
+    </div>
+    <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+      <!---->
+      <!---->
+    </div>
+  </div>
+  <div>
+    <div class="fr-tabs" style="--tabs-height: 0px;">
+      <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable">
+        <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Brouillons" aria-selected="true" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li>
+        <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Activités" aria-selected="false" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li>
+      </ul>
+      <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0">
+        <p>Aucune étape en brouillon</p>
+      </div>
+      <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-end" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0">
+        <!---->
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorActivites.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorActivites.html
new file mode 100644
index 000000000..38c87d3a0
--- /dev/null
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorActivites.html
@@ -0,0 +1,31 @@
+<div>
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-6">
+      <h1>Tableau de bord</h1>
+    </div>
+    <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+      <!---->
+      <!---->
+    </div>
+  </div>
+  <div>
+    <div class="fr-tabs" style="--tabs-height: 0px;">
+      <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable">
+        <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Brouillons" aria-selected="false" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li>
+        <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Activités" aria-selected="true" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li>
+      </ul>
+      <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-start" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0">
+        <!---->
+      </div>
+      <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0">
+        <div class="" style="display: flex; justify-content: center;">
+          <!---->
+          <div class="fr-alert fr-alert--error fr-alert--sm" role="alert">
+            <p>Une erreur</p>
+          </div>
+          <!---->
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorBrouillon.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorBrouillon.html
new file mode 100644
index 000000000..21af986a8
--- /dev/null
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorBrouillon.html
@@ -0,0 +1,31 @@
+<div>
+  <div class="fr-grid-row">
+    <div class="fr-col-12 fr-col-md-6">
+      <h1>Tableau de bord</h1>
+    </div>
+    <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;">
+      <!---->
+      <!---->
+    </div>
+  </div>
+  <div>
+    <div class="fr-tabs" style="--tabs-height: 0px;">
+      <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable">
+        <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Brouillons" aria-selected="true" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li>
+        <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Activités" aria-selected="false" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li>
+      </ul>
+      <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0">
+        <div class="" style="display: flex; justify-content: center;">
+          <!---->
+          <div class="fr-alert fr-alert--error fr-alert--sm" role="alert">
+            <p>Une erreur</p>
+          </div>
+          <!---->
+        </div>
+      </div>
+      <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-end" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0">
+        <!---->
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.tsx b/packages/ui/src/components/dashboard/pure-super-dashboard.tsx
index 993d95bb0..ec83dd855 100644
--- a/packages/ui/src/components/dashboard/pure-super-dashboard.tsx
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.tsx
@@ -1,6 +1,6 @@
 import { defineComponent, onMounted, ref } from 'vue'
 import { Column, TableSimple } from '../_ui/table-simple'
-import { nomColumn, statutCell, typeCell, domaineColumn, domaineCell } from '@/components/titres/table-utils'
+import { nomColumn, statutCell, typeCell, domaineColumn, domaineCell, typeColumn } from '@/components/titres/table-utils'
 import { SuperTitre } from 'camino-common/src/titres'
 import { LoadingElement } from '@/components/_ui/functional-loader'
 import { AsyncData } from '@/api/client-rest'
@@ -8,18 +8,31 @@ import { ComponentColumnData, JSXElementColumnData, TableRow, TextColumnData } f
 import { DashboardApiClient } from './dashboard-api-client'
 import { User } from 'camino-common/src/roles'
 import { PageContentHeader } from '../_common/page-header-content'
-import { isNotNullNorUndefinedNorEmpty } from 'camino-common/src/typescript-tools'
-import { CaminoRouterLink } from '@/router/camino-router-link'
+import { exhaustiveCheck, isNotNullNorUndefinedNorEmpty } from 'camino-common/src/typescript-tools'
+import { CaminoRouterLink, routerQueryToString } from '@/router/camino-router-link'
 import { DemarchesTypes } from 'camino-common/src/static/demarchesTypes'
 import { capitalize } from 'camino-common/src/strings'
 import { EtapesTypes } from 'camino-common/src/static/etapesTypes'
 import { getDomaineId } from 'camino-common/src/static/titresTypes'
+import { computed } from 'vue'
+import { Tab, Tabs } from '../_ui/tabs'
+import { CaminoRouteLocation } from '@/router/routes'
+import { CaminoRouter } from '@/typings/vue-router'
+import { ActiviteSuper } from 'camino-common/src/activite'
+import { activitesColonneIdAnnee } from '../activites'
+import { getPeriode } from 'camino-common/src/static/frequence'
+import { ActivitesTypes } from 'camino-common/src/static/activitesTypes'
+import { ActiviteStatut } from '../_common/activite-statut'
+import { fr } from '@codegouvfr/react-dsfr'
+import { ACTIVITES_STATUTS_IDS } from 'camino-common/src/static/activitesStatuts'
 
 interface Props {
-  apiClient: Pick<DashboardApiClient, 'getTitresAvecEtapeEnBrouillon'>
+  apiClient: Pick<DashboardApiClient, 'getTitresAvecEtapeEnBrouillon' | 'getActivitesSuper'>
   user: User
+  route: CaminoRouteLocation
+  router: Pick<CaminoRouter, 'push'>
 }
-const columns = [
+const brouillonsColumns = [
   nomColumn,
   { id: 'demarche_type', contentTitle: 'Type de démarche' },
   { id: 'titre_type', contentTitle: 'Type de titre' },
@@ -28,17 +41,70 @@ const columns = [
   { id: 'etape_brouillon', contentTitle: 'Étape en brouillon' },
   { id: 'etape_date', contentTitle: "Date de l'étape" },
 ] as const satisfies Column[]
-type ColumnId = (typeof columns)[number]['id']
+type BrouillonColumnId = (typeof brouillonsColumns)[number]['id']
+
+const activitesColumns = [
+  nomColumn,
+  typeColumn,
+  { id: activitesColonneIdAnnee, contentTitle: 'Année' },
+  { id: 'periode', contentTitle: 'Période' },
+  { id: 'activite_type', contentTitle: 'Type de rapport' },
+  { id: 'statut', contentTitle: 'Statut' },
+] as const satisfies Column[]
+type ActiviteColumnId = (typeof activitesColumns)[number]['id']
+
+const tabs = ['brouillons', 'activites'] as const
+type TabId = (typeof tabs)[number]
 
 export const PureSuperDashboard = defineComponent<Props>(props => {
-  const data = ref<AsyncData<TableRow<ColumnId>[]>>({ status: 'LOADING' })
+  const brouillonsData = ref<AsyncData<TableRow<BrouillonColumnId>[]>>({ status: 'LOADING' })
+  const activitesData = ref<AsyncData<TableRow<ActiviteColumnId>[]>>({ status: 'LOADING' })
+
+  type BrouillonColumns = (typeof brouillonsColumns)[number]['id']
 
-  type Columns = (typeof columns)[number]['id']
+  const tabId = computed<TabId>(() => routerQueryToString(props.route.query.vueId, 'brouillons') as TabId)
 
-  const titresLignesBuild = (titres: SuperTitre[]): TableRow<Columns>[] => {
+  const vues = [
+    {
+      id: 'brouillons',
+      icon: 'fr-icon-draft-line',
+      title: 'Brouillons',
+      renderContent: () => (
+        <LoadingElement
+          data={brouillonsData.value}
+          renderItem={item => {
+            if (isNotNullNorUndefinedNorEmpty(item)) {
+              return <TableSimple caption={{ value: 'Titres avec une étape en brouillon', visible: true }} columns={brouillonsColumns} rows={item} />
+            }
+
+            return <p>Aucune étape en brouillon</p>
+          }}
+        />
+      ),
+    },
+    {
+      id: 'activites',
+      icon: 'fr-icon-delete-bin-line',
+      title: 'Activités',
+      renderContent: () => (
+        <LoadingElement
+          data={activitesData.value}
+          renderItem={item => {
+            if (isNotNullNorUndefinedNorEmpty(item)) {
+              return <TableSimple caption={{ value: 'Activités supprimables', visible: true }} columns={activitesColumns} rows={item} />
+            }
+
+            return <p>Aucune activité supprimable</p>
+          }}
+        />
+      ),
+    },
+  ] as const satisfies readonly Tab<TabId>[]
+
+  const titresLignesBuild = (titres: SuperTitre[]): TableRow<BrouillonColumns>[] => {
     return titres.map(titre => {
       const columns: {
-        [key in Columns]: JSXElementColumnData | ComponentColumnData | TextColumnData
+        [key in BrouillonColumns]: JSXElementColumnData | ComponentColumnData | TextColumnData
       } = {
         nom: {
           type: 'jsx',
@@ -62,38 +128,116 @@ export const PureSuperDashboard = defineComponent<Props>(props => {
 
       return {
         id: titre.titre_slug,
-        link: { name: 'titre', params: { id: titre.titre_slug } },
+        link: null,
+        columns,
+      }
+    })
+  }
+  const activitesLignesBuild = (activites: ActiviteSuper[]): TableRow<ActiviteColumnId>[] => {
+    return activites.map(activite => {
+      const columns: {
+        [key in ActiviteColumnId]: JSXElementColumnData | ComponentColumnData | TextColumnData
+      } = {
+        nom: {
+          type: 'jsx',
+          jsxElement: (
+            <CaminoRouterLink to={{ name: 'activite', params: { activiteId: activite.id } }} isDisabled={false} title={activite.titre_nom}>
+              {capitalize(activite.titre_nom)}
+            </CaminoRouterLink>
+          ),
+          value: activite.titre_nom,
+        },
+        type: typeCell(activite.titre_type_id),
+        periode: {
+          type: 'text',
+          value: getPeriode(ActivitesTypes[activite.type_id].frequenceId, activite.periode_id),
+        },
+        annee: {
+          type: 'text',
+          value: activite.annee,
+        },
+        activite_type: {
+          type: 'text',
+          value: ActivitesTypes[activite.type_id].nom,
+        },
+        statut: {
+          type: 'jsx',
+          jsxElement: <ActiviteStatut activiteStatutId={activite.activite_statut_id} />,
+          value: activite.activite_statut_id,
+        },
+      }
+
+      return {
+        class: activite.activite_statut_id === ACTIVITES_STATUTS_IDS.DEPOSE ? [fr.cx('fr-label--error')] : undefined,
+        id: activite.id,
+        link: null,
         columns,
       }
     })
   }
 
-  onMounted(async () => {
-    const titres = await props.apiClient.getTitresAvecEtapeEnBrouillon()
-    if ('message' in titres) {
-      data.value = {
-        status: 'NEW_ERROR',
-        error: titres,
+  const loadBrouillons = async () => {
+    if (brouillonsData.value.status !== 'LOADED') {
+      const titres = await props.apiClient.getTitresAvecEtapeEnBrouillon()
+      if ('message' in titres) {
+        brouillonsData.value = {
+          status: 'NEW_ERROR',
+          error: titres,
+        }
+      } else {
+        brouillonsData.value = {
+          status: 'LOADED',
+          value: titresLignesBuild(titres),
+        }
       }
-    } else {
-      data.value = {
-        status: 'LOADED',
-        value: titresLignesBuild(titres),
+    }
+  }
+  const loadActivites = async () => {
+    if (activitesData.value.status !== 'LOADED') {
+      const activites = await props.apiClient.getActivitesSuper()
+      if ('message' in activites) {
+        activitesData.value = {
+          status: 'NEW_ERROR',
+          error: activites,
+        }
+      } else {
+        activitesData.value = {
+          status: 'LOADED',
+          value: activitesLignesBuild(activites),
+        }
       }
     }
+  }
+
+  const reload = async (tabId: TabId) => {
+    switch (tabId) {
+      case 'brouillons':
+        await loadBrouillons()
+        break
+      case 'activites':
+        await loadActivites()
+        break
+      default:
+        exhaustiveCheck(tabId)
+    }
+  }
+  onMounted(async () => {
+    await reload(tabId.value)
   })
 
   return () => (
     <div>
       <PageContentHeader nom="Tableau de bord" download={null} renderButton={null} />
-      <LoadingElement
-        data={data.value}
-        renderItem={item => {
-          if (isNotNullNorUndefinedNorEmpty(item)) {
-            return <TableSimple caption={{ value: 'Titres avec une étape en brouillon', visible: true }} columns={columns} rows={item} />
-          }
-
-          return <p>Aucune étape en brouillon</p>
+
+      <Tabs
+        id="tdb_super_vues"
+        initTab={tabId.value}
+        tabs={vues}
+        tabsTitle={'Affichage des titres contenant un brouillon, ou des activités supprimable'}
+        tabClicked={async newTabId => {
+          const query: CaminoRouteLocation['query'] = { ...props.route.query, vueId: newTabId }
+          await props.router.push({ name: props.route.name ?? undefined, query, params: props.route.params })
+          await reload(newTabId)
         }}
       />
     </div>
@@ -101,4 +245,4 @@ export const PureSuperDashboard = defineComponent<Props>(props => {
 })
 
 // @ts-ignore waiting for https://github.com/vuejs/core/issues/7833
-PureSuperDashboard.props = ['apiClient', 'user']
+PureSuperDashboard.props = ['apiClient', 'user', 'route', 'router']
-- 
GitLab


From 427737b5b3655c0dbe10586483d410376cf81c80 Mon Sep 17 00:00:00 2001
From: Anis Safine Laget <anis.safine@beta.gouv.fr>
Date: Mon, 7 Apr 2025 15:41:41 +0200
Subject: [PATCH 8/8] linting

---
 .../api/src/api/rest/activites.queries.ts     | 12 ++----
 packages/api/src/api/rest/activites.ts        | 38 +++++++++----------
 packages/common/src/activite.ts               |  4 +-
 packages/common/src/rest.ts                   |  3 +-
 packages/ui/src/components/dashboard.tsx      |  1 -
 .../pure-super-dashboard.stories.tsx          | 10 ++---
 6 files changed, 32 insertions(+), 36 deletions(-)

diff --git a/packages/api/src/api/rest/activites.queries.ts b/packages/api/src/api/rest/activites.queries.ts
index 6bd50456a..d03e594cf 100644
--- a/packages/api/src/api/rest/activites.queries.ts
+++ b/packages/api/src/api/rest/activites.queries.ts
@@ -383,14 +383,10 @@ where
 LIMIT 1
 `
 
-export const getActivitesSuper = (pool: Pool): Effect.Effect<ActiviteSuper[], CaminoError<EffectDbQueryAndValidateErrors>> => Effect.Do.pipe(
-  Effect.flatMap(() => effectDbQueryAndValidate(getActivitesSuperDb, {}, pool, activiteSuperValidator))
-)
+export const getActivitesSuper = (pool: Pool): Effect.Effect<ActiviteSuper[], CaminoError<EffectDbQueryAndValidateErrors>> =>
+  Effect.Do.pipe(Effect.flatMap(() => effectDbQueryAndValidate(getActivitesSuperDb, {}, pool, activiteSuperValidator)))
 
-
-const getActivitesSuperDb = sql<
-  Redefine<IGetActivitesSuperDbQuery, {}, z.infer<typeof activiteSuperValidator>>
->`
+const getActivitesSuperDb = sql<Redefine<IGetActivitesSuperDbQuery, {}, z.infer<typeof activiteSuperValidator>>>`
 select
     t.nom as titre_nom,
     t.type_id as titre_type_id,
@@ -403,4 +399,4 @@ from titres_activites ta
 left join titres t on ta.titre_id = t.id
 where ta.suppression is true
 order by t.nom asc, ta.annee asc, ta.periode_id asc
-`
\ No newline at end of file
+`
diff --git a/packages/api/src/api/rest/activites.ts b/packages/api/src/api/rest/activites.ts
index 0152a51f3..b84b5b613 100644
--- a/packages/api/src/api/rest/activites.ts
+++ b/packages/api/src/api/rest/activites.ts
@@ -249,24 +249,24 @@ export const activiteDocumentDownload: NewDownload = async (params, user, pool)
 const permissionsInsuffisantes = 'Permissions insuffisantes' as const
 type GetActivitesSuperErrors = EffectDbQueryAndValidateErrors | typeof permissionsInsuffisantes
 
-export const getActivitesForTDBSuper: RestNewGetCall<'/rest/activitesSuper'> = (rootPipe): Effect.Effect<ActiviteSuper[], CaminoApiError<GetActivitesSuperErrors>> => rootPipe.pipe(
-  Effect.filterOrFail(
-    ({ user }) => isSuper(user),
-    () => ({ message: permissionsInsuffisantes })
-  ),
-  Effect.flatMap(({ pool }) => getActivitesSuper(pool)),
-  Effect.mapError(caminoError =>
-    Match.value(caminoError.message).pipe(
-      Match.whenOr("Permissions insuffisantes", () => ({
-        ...caminoError,
-        status: HTTP_STATUS.FORBIDDEN,
-      })),
-      Match.whenOr(
-        "Impossible d'exécuter la requête dans la base de données",
-        'Les données en base ne correspondent pas à ce qui est attendu',
-        () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })
-      ),
-      Match.exhaustive
+export const getActivitesForTDBSuper: RestNewGetCall<'/rest/activitesSuper'> = (rootPipe): Effect.Effect<ActiviteSuper[], CaminoApiError<GetActivitesSuperErrors>> =>
+  rootPipe.pipe(
+    Effect.filterOrFail(
+      ({ user }) => isSuper(user),
+      () => ({ message: permissionsInsuffisantes })
+    ),
+    Effect.flatMap(({ pool }) => getActivitesSuper(pool)),
+    Effect.mapError(caminoError =>
+      Match.value(caminoError.message).pipe(
+        Match.whenOr('Permissions insuffisantes', () => ({
+          ...caminoError,
+          status: HTTP_STATUS.FORBIDDEN,
+        })),
+        Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', () => ({
+          ...caminoError,
+          status: HTTP_STATUS.INTERNAL_SERVER_ERROR,
+        })),
+        Match.exhaustive
+      )
     )
   )
-)
\ No newline at end of file
diff --git a/packages/common/src/activite.ts b/packages/common/src/activite.ts
index 659aeb234..475cde543 100644
--- a/packages/common/src/activite.ts
+++ b/packages/common/src/activite.ts
@@ -63,6 +63,6 @@ export const activiteSuperValidator = z.object({
   annee: caminoAnneeValidator,
   type_id: activiteTypeIdValidator,
   periode_id: z.number(),
-  activite_statut_id: activiteStatutIdValidator
+  activite_statut_id: activiteStatutIdValidator,
 })
-export type ActiviteSuper = z.infer<typeof activiteSuperValidator>
\ No newline at end of file
+export type ActiviteSuper = z.infer<typeof activiteSuperValidator>
diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts
index dec1f6360..78f248921 100644
--- a/packages/common/src/rest.ts
+++ b/packages/common/src/rest.ts
@@ -241,7 +241,8 @@ export const CaminoRestRoutes = {
     delete: true,
   },
   '/rest/activitesSuper': {
-    newGet: { output: z.array(activiteSuperValidator) }, params: noParamsValidator
+    newGet: { output: z.array(activiteSuperValidator) },
+    params: noParamsValidator,
   },
   '/rest/communes': { params: noParamsValidator, newGet: { output: z.array(communeValidator), searchParams: z.object({ ids: z.array(communeIdValidator).nonempty() }) } },
   '/rest/geojson/import/:geoSystemeId': {
diff --git a/packages/ui/src/components/dashboard.tsx b/packages/ui/src/components/dashboard.tsx
index 68dbbf1e9..1c3871328 100644
--- a/packages/ui/src/components/dashboard.tsx
+++ b/packages/ui/src/components/dashboard.tsx
@@ -43,7 +43,6 @@ const PureEntrepriseDashboard = defineComponent((props: Pick<PureDashboardProps,
   })
   const entrepriseIds = props.user.entrepriseIds ?? []
 
-
   return () => <PureEntrepriseDashboardComponent apiClient={dashboardApiClient} user={props.user} entrepriseIds={entrepriseIds} allEntreprises={props.entreprises} />
 })
 
diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx b/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx
index 75cee02de..a3b7dfb34 100644
--- a/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx
+++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx
@@ -28,7 +28,7 @@ const activites: ActiviteSuper[] = [
     periode_id: 1,
     titre_nom: 'Nom du titre',
     titre_type_id: TITRES_TYPES_IDS.AUTORISATION_D_EXPLOITATION_METAUX,
-    type_id: ACTIVITES_TYPES_IDS['rapport trimestriel d\'exploitation d\'or en Guyane']
+    type_id: ACTIVITES_TYPES_IDS["rapport trimestriel d'exploitation d'or en Guyane"],
   },
   {
     activite_statut_id: ACTIVITES_STATUTS_IDS.CLOTURE,
@@ -37,8 +37,8 @@ const activites: ActiviteSuper[] = [
     periode_id: 3,
     titre_nom: 'Nom du titre 2',
     titre_type_id: TITRES_TYPES_IDS.PERMIS_D_EXPLOITATION_METAUX,
-    type_id: ACTIVITES_TYPES_IDS["rapport d'intensité d'exploration"]
-  }
+    type_id: ACTIVITES_TYPES_IDS["rapport d'intensité d'exploration"],
+  },
 ]
 const titres: SuperTitre[] = [
   {
@@ -74,13 +74,13 @@ const router: Pick<CaminoRouter, 'push'> = {
 const routeBrouillon: CaminoRouteLocation = {
   name: 'dashboard',
   params: {},
-  query: {vueId: 'brouillons'}
+  query: { vueId: 'brouillons' },
 }
 
 const routeActivite: CaminoRouteLocation = {
   name: 'dashboard',
   params: {},
-  query: {vueId: 'activites'}
+  query: { vueId: 'activites' },
 }
 
 export const TableauVideBrouillon: StoryFn = () => (
-- 
GitLab