From ff1d0e7be23bca693976056076b46b8ae6c84bad Mon Sep 17 00:00:00 2001 From: Anis Safine Laget <anis.safine@beta.gouv.fr> Date: Tue, 1 Apr 2025 16:37:15 +0200 Subject: [PATCH 1/6] =?UTF-8?q?chore(api):=20passage=20=C3=A0=20effect=20d?= =?UTF-8?q?e=20getEtape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/api/rest/etapes.ts | 113 ++++++++++++------ packages/api/src/server/rest.ts | 2 +- packages/common/src/rest.ts | 2 +- packages/ui/src/components/etape-edition.tsx | 7 +- .../src/components/etape/etape-api-client.ts | 14 ++- 5 files changed, 95 insertions(+), 43 deletions(-) diff --git a/packages/api/src/api/rest/etapes.ts b/packages/api/src/api/rest/etapes.ts index 46487ac62..d163adf4a 100644 --- a/packages/api/src/api/rest/etapes.ts +++ b/packages/api/src/api/rest/etapes.ts @@ -64,7 +64,7 @@ import { KM2 } from 'camino-common/src/number' import { FeatureMultiPolygon, FeatureCollectionPoints } from 'camino-common/src/perimetre' import { canHaveForages } from 'camino-common/src/permissions/titres' import { SecteursMaritimes, getSecteurMaritime } from 'camino-common/src/static/facades' -import { callAndExit, shortCircuitError, zodParseEffect } from '../../tools/fp-tools' +import { shortCircuitError, zodParseEffect } from '../../tools/fp-tools' import { RestNewGetCall, RestNewPostCall, RestNewPutCall } from '../../server/rest' import { Effect, Match } from 'effect' import { EffectDbQueryAndValidateErrors } from '../../pg-database' @@ -251,42 +251,85 @@ export const deleteEtape = } } } -export const getEtape = - (_pool: Pool) => - async (req: CaminoRequest, res: CustomResponse<FlattenEtape>): Promise<void> => { - const user = req.auth - - const etapeId = etapeIdOrSlugValidator.safeParse(req.params.etapeIdOrSlug) - if (!etapeId.success) { - res.sendStatus(HTTP_STATUS.BAD_REQUEST) - } else if (isNullOrUndefined(user)) { - res.sendStatus(HTTP_STATUS.FORBIDDEN) - } else { - try { - const titreEtape = await titreEtapeGet(etapeId.data, { fields: { demarche: { titre: { pointsEtape: { id: {} } } } }, fetchHeritage: true }, user) - if (isNullOrUndefined(titreEtape)) { - res.sendStatus(HTTP_STATUS.NOT_FOUND) - } else if (isNullOrUndefined(titreEtape.titulaireIds) || isNullOrUndefined(titreEtape.demarche?.titre) || titreEtape.demarche.titre.administrationsLocales === undefined) { - console.error('la démarche n’est pas chargée complètement') - res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR) - } else if ( - !canEditEtape(user, titreEtape.typeId, titreEtape.isBrouillon, titreEtape.titulaireIds ?? [], titreEtape.demarche.titre.administrationsLocales ?? [], titreEtape.demarche.typeId, { - typeId: titreEtape.demarche.titre.typeId, - titreStatutId: titreEtape.demarche.titre.titreStatutId, +const etapeFetchError = `Une erreur s'est produite lors de la récupération de l'étape` as const +const etapeIntrouvableError = `L'étape n'existe pas` as const +const etapeIncompletError = `L'étape n'a pas été chargée de manière complète` as const +const droitsManquantsError = `Les droits d'accès à cette étape ne sont pas suffisants` as const +type GetEtapeErrors = EffectDbQueryAndValidateErrors | TitreEtapeToFlattenEtapeErrors | typeof etapeFetchError | typeof etapeIntrouvableError | typeof etapeIncompletError | typeof droitsManquantsError +export const getEtape: RestNewGetCall<'/rest/etapes/:etapeIdOrSlug'> = (rootPipe): Effect.Effect<FlattenEtape, CaminoApiError<GetEtapeErrors>> => + rootPipe.pipe( + Effect.bind('etape', ({ user, params }) => + Effect.Do.pipe( + () => + Effect.tryPromise({ + try: () => titreEtapeGet(params.etapeIdOrSlug, { fields: { demarche: { titre: { pointsEtape: { id: {} } } } }, fetchHeritage: true }, user), + catch: error => ({ message: etapeFetchError, extra: error }), + }), + Effect.filterOrFail( + (etape): etape is ITitreEtape => isNotNullNorUndefined(etape), + () => ({ message: etapeIntrouvableError }) + ), + Effect.filterOrFail( + etape => isNotNullNorUndefined(etape.titulaireIds), + () => ({ message: etapeIncompletError, detail: 'titulaireIds est manquant' }) + ) + ) + ), + Effect.bind('demarche', ({ etape }) => + Effect.Do.pipe( + Effect.map(() => etape.demarche), + Effect.filterOrFail( + (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche), + () => ({ message: etapeIncompletError, detail: 'demarche est manquant' }) + ) + ) + ), + Effect.bind('titre', ({ demarche }) => + Effect.Do.pipe( + Effect.map(() => demarche.titre), + Effect.filterOrFail( + (titre): titre is ITitre => isNotNullNorUndefined(titre) && titre.administrationsLocales !== undefined, + () => ({ message: etapeIncompletError, detail: 'titre est manquant' }) + ) + ) + ), + Effect.filterOrFail( + ({ titre, demarche, etape, user }) => + canEditEtape(user, etape.typeId, etape.isBrouillon, etape.titulaireIds ?? [], titre.administrationsLocales ?? [], demarche.typeId, { + typeId: titre.typeId, + titreStatutId: titre.titreStatutId, + }), + () => ({ message: droitsManquantsError }) + ), + Effect.flatMap(({ etape }) => iTitreEtapeToFlattenEtape(etape)), + Effect.mapError(caminoError => + Match.value(caminoError.message).pipe( + Match.when("L'étape n'existe pas", () => ({ + ...caminoError, + status: HTTP_STATUS.NOT_FOUND, + })), + Match.when("Les droits d'accès à cette étape ne sont pas suffisants", () => ({ + ...caminoError, + status: HTTP_STATUS.FORBIDDEN, + })), + Match.whenOr( + "L'étape n'a pas été chargée de manière complète", + 'Problème de validation de données', + "Une erreur s'est produite lors de la récupération de l'étape", + "pas d'héritage chargé", + 'pas de démarche chargée', + 'pas de démarche ou de titre chargé', + 'pas de slug', + () => ({ + ...caminoError, + status: HTTP_STATUS.INTERNAL_SERVER_ERROR, }) - ) { - res.sendStatus(HTTP_STATUS.FORBIDDEN) - } else { - res.json(await callAndExit(iTitreEtapeToFlattenEtape(titreEtape))) - } - } catch (e) { - console.error(e) - - res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR) - } - } - } + ), + Match.exhaustive + ) + ) + ) const documentDEntrepriseIncorrects = "document d'entreprise incorrects" as const type ValidateAndGetEntrepriseDocumentsErrors = typeof documentDEntrepriseIncorrects | EffectDbQueryAndValidateErrors diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts index 891111944..457c1ed47 100644 --- a/packages/api/src/server/rest.ts +++ b/packages/api/src/server/rest.ts @@ -232,7 +232,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k }, '/rest/demarches/:demarcheId/geojson': { newGetCall: getPerimetreInfosByDemarche, ...CaminoRestRoutes['/rest/demarches/:demarcheId/geojson'] }, '/rest/etapes/:etapeId/geojson': { newGetCall: getPerimetreInfosByEtape, ...CaminoRestRoutes['/rest/etapes/:etapeId/geojson'] }, - '/rest/etapes/:etapeIdOrSlug': { deleteCall: deleteEtape, getCall: getEtape, ...CaminoRestRoutes['/rest/etapes/:etapeIdOrSlug'] }, + '/rest/etapes/:etapeIdOrSlug': { deleteCall: deleteEtape, newGetCall: getEtape, ...CaminoRestRoutes['/rest/etapes/:etapeIdOrSlug'] }, '/rest/etapes': { newPostCall: createEtape, newPutCall: updateEtape, ...CaminoRestRoutes['/rest/etapes'] }, '/rest/etapes/:etapeId/depot': { newPutCall: deposeEtape, ...CaminoRestRoutes['/rest/etapes/:etapeId/depot'] }, '/rest/etapes/:etapeId/entrepriseDocuments': { newGetCall: getEtapeEntrepriseDocuments, ...CaminoRestRoutes['/rest/etapes/:etapeId/entrepriseDocuments'] }, diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts index c5e809b96..a7d5dbab8 100644 --- a/packages/common/src/rest.ts +++ b/packages/common/src/rest.ts @@ -221,7 +221,7 @@ export const CaminoRestRoutes = { '/rest/etapes/:etapeId/etapeDocuments': { params: etapeIdParamsValidator, newGet: { output: getEtapeDocumentsByEtapeIdValidator } }, '/rest/etapes/:etapeId/etapeAvis': { params: etapeIdParamsValidator, newGet: { output: getEtapeAvisByEtapeIdValidator } }, '/rest/etapes/:etapeId/entrepriseDocuments': { params: etapeIdParamsValidator, newGet: { output: z.array(etapeEntrepriseDocumentValidator) } }, - '/rest/etapes/:etapeIdOrSlug': { params: z.object({ etapeIdOrSlug: etapeIdOrSlugValidator }), delete: true, get: { output: flattenEtapeValidator } }, + '/rest/etapes/:etapeIdOrSlug': { params: z.object({ etapeIdOrSlug: etapeIdOrSlugValidator }), delete: true, newGet: { output: flattenEtapeValidator } }, '/rest/etapes/:etapeId/depot': { params: etapeIdParamsValidator, newPut: { input: z.object({}), output: z.object({ id: etapeIdValidator }) } }, '/rest/etapes': { params: noParamsValidator, diff --git a/packages/ui/src/components/etape-edition.tsx b/packages/ui/src/components/etape-edition.tsx index 1888b4ded..65b9cedd6 100644 --- a/packages/ui/src/components/etape-edition.tsx +++ b/packages/ui/src/components/etape-edition.tsx @@ -91,10 +91,11 @@ export const PureEtapeEdition = defineComponent<Props>(props => { onMounted(async () => { try { if (isNotNullNorUndefined(props.etapeIdOrSlug)) { - const { etape, demarche } = await props.apiClient.getEtape(props.etapeIdOrSlug) - if ('message' in demarche) { - setAsyncData({ status: 'NEW_ERROR', error: demarche }) + const result = await props.apiClient.getEtape(props.etapeIdOrSlug) + if ('message' in result) { + setAsyncData({ status: 'NEW_ERROR', error: result }) } else { + const { etape, demarche } = result const perimetre = await props.apiClient.getPerimetreInfosByEtapeId(etape.id) if ('message' in perimetre) { setAsyncData({ status: 'NEW_ERROR', error: perimetre }) diff --git a/packages/ui/src/components/etape/etape-api-client.ts b/packages/ui/src/components/etape/etape-api-client.ts index 37235b203..ae97078e3 100644 --- a/packages/ui/src/components/etape/etape-api-client.ts +++ b/packages/ui/src/components/etape/etape-api-client.ts @@ -1,5 +1,5 @@ import { apiGraphQLFetch } from '@/api/_client' -import { deleteWithJson, getWithJson, newGetWithJson, newPostWithJson, newPutWithJson } from '@/api/client-rest' +import { deleteWithJson, newGetWithJson, newPostWithJson, newPutWithJson } from '@/api/client-rest' import { CaminoDate, caminoDateValidator } from 'camino-common/src/date' import { DemarcheId } from 'camino-common/src/demarche' import { entrepriseIdValidator } from 'camino-common/src/entreprise' @@ -85,7 +85,7 @@ export interface EtapeApiClient { getEtapeDocumentsByEtapeId: (etapeId: EtapeId) => Promise<GetEtapeDocumentsByEtapeId | CaminoError<string>> getEtapeHeritagePotentiel: (etape: Pick<CoreEtapeCreationOrModification, 'id' | 'date' | 'typeId'>, titreDemarcheId: DemarcheId) => Promise<GetEtapeHeritagePotentiel> getEtapeAvisByEtapeId: (etapeId: EtapeId) => Promise<GetEtapeAvisByEtapeId | CaminoError<string>> - getEtape: (etapeIdOrSlug: EtapeIdOrSlug) => Promise<{ etape: FlattenEtape; demarche: GetDemarcheByIdOrSlug | CaminoError<string> }> + getEtape: (etapeIdOrSlug: EtapeIdOrSlug) => Promise<{ etape: FlattenEtape; demarche: GetDemarcheByIdOrSlug } | CaminoError<string>> etapeCreer: (etape: RestEtapeCreation) => Promise<CaminoError<string> | { id: EtapeId }> etapeModifier: (etape: RestEtapeModification) => Promise<CaminoError<string> | { id: EtapeId }> } @@ -102,8 +102,16 @@ export const etapeApiClient: EtapeApiClient = { getEtapeAvisByEtapeId: async etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeAvis', { etapeId }), getEtape: async etapeIdOrSlug => { - const etape = await getWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug }) + const etape = await newGetWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug }) + if ('message' in etape) { + return etape + } + const demarche = await newGetWithJson('/rest/demarches/:demarcheIdOrSlug', { demarcheIdOrSlug: etape.titreDemarcheId }) + if ('message' in demarche) { + return demarche + } + return { etape, demarche } }, getEtapeHeritagePotentiel: async (etape, titreDemarcheId) => { -- GitLab From a7cc3a37b7bfc0de99a1e8eff67025c4c7800ed9 Mon Sep 17 00:00:00 2001 From: Anis Safine Laget <anis.safine@beta.gouv.fr> Date: Tue, 1 Apr 2025 17:45:28 +0200 Subject: [PATCH 2/6] wip --- packages/api/src/api/rest/etapes.ts | 192 +++++++++++++++++----------- packages/api/src/server/rest.ts | 15 ++- packages/common/src/rest.ts | 2 +- 3 files changed, 130 insertions(+), 79 deletions(-) diff --git a/packages/api/src/api/rest/etapes.ts b/packages/api/src/api/rest/etapes.ts index d163adf4a..18a64f14f 100644 --- a/packages/api/src/api/rest/etapes.ts +++ b/packages/api/src/api/rest/etapes.ts @@ -65,8 +65,8 @@ import { FeatureMultiPolygon, FeatureCollectionPoints } from 'camino-common/src/ import { canHaveForages } from 'camino-common/src/permissions/titres' import { SecteursMaritimes, getSecteurMaritime } from 'camino-common/src/static/facades' import { shortCircuitError, zodParseEffect } from '../../tools/fp-tools' -import { RestNewGetCall, RestNewPostCall, RestNewPutCall } from '../../server/rest' -import { Effect, Match } from 'effect' +import { RestNewDeleteCall, RestNewGetCall, RestNewPostCall, RestNewPutCall } from '../../server/rest' +import { Effect, Match, Option } from 'effect' import { EffectDbQueryAndValidateErrors } from '../../pg-database' import { CaminoError } from 'camino-common/src/zod-tools' import { machineFind } from '../../business/rules-demarches/machines' @@ -179,83 +179,127 @@ export const getEtapeAvis: RestNewGetCall<'/rest/etapes/:etapeId/etapeAvis'> = ( ) ) -export const deleteEtape = - (pool: Pool) => - async (req: CaminoRequest, res: CustomResponse<void>): Promise<void> => { - const user = req.auth - - const etapeId = etapeIdOrSlugValidator.safeParse(req.params.etapeIdOrSlug) - if (!etapeId.success) { - res.sendStatus(HTTP_STATUS.BAD_REQUEST) - } else if (isNullOrUndefined(user)) { - res.sendStatus(HTTP_STATUS.NOT_FOUND) - } else { - try { - const titreEtape = await titreEtapeGet( - etapeId.data, +const etapeFetchError = `Une erreur s'est produite lors de la récupération de l'étape` as const +const etapeIntrouvableError = `L'étape n'existe pas` as const +const etapeIncompletError = `L'étape n'a pas été chargée de manière complète` as const +const droitsManquantsError = `Les droits d'accès à cette étape ne sont pas suffisants` as const +const fixMeError = 'FIXME' as const +type DeleteEtapeErrors = EffectDbQueryAndValidateErrors | typeof etapeFetchError | typeof etapeIntrouvableError | typeof etapeIncompletError | typeof droitsManquantsError | typeof fixMeError +export const deleteEtape: RestNewDeleteCall<'/rest/etapes/:etapeIdOrSlug'> = + (rootPipe): Effect.Effect<Option.Option<never>, CaminoApiError<DeleteEtapeErrors>> => + rootPipe.pipe( + Effect.bind('etape', ({ params, user }) => + Effect.Do.pipe( + () => Effect.tryPromise({ + try: () => titreEtapeGet( + params.etapeIdOrSlug, + { + fields: { + demarche: { titre: { pointsEtape: { id: {} } } }, + }, + }, + user + ), + catch: (error) => ({ message: etapeFetchError, extra: error }) + }), + Effect.filterOrFail( + (etape): etape is ITitreEtape => isNotNullNorUndefined(etape), + () => ({ message: etapeIntrouvableError }) + ) + ) + ), + Effect.bind('demarche', ({ etape }) => + Effect.Do.pipe( + Effect.map(() => etape.demarche), + Effect.filterOrFail( + (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche), + () => ({ message: etapeIncompletError, detail: 'demarche est manquant' }) + ) + ) + ), + Effect.bind('titre', ({ demarche }) => + Effect.Do.pipe( + Effect.map(() => demarche.titre), + Effect.filterOrFail( + (titre): titre is ITitre => isNotNullNorUndefined(titre) && titre.administrationsLocales !== undefined, + () => ({ message: etapeIncompletError, detail: 'titre est manquant ou incomplet' }) + ) + ) + ), + Effect.filterOrFail( + ({ titre, demarche, etape, user }) => + canDeleteEtape( + user, + etape.typeId, + etape.isBrouillon, + etape.titulaireIds ?? [], + titre.administrationsLocales ?? [], + demarche.typeId, + { + typeId: titre.typeId, + titreStatutId: titre.titreStatutId, + }, + ), + () => ({ message: droitsManquantsError }) + ), + Effect.bind('fullDemarche', ({ etape }) => Effect.Do.pipe( + () => Effect.tryPromise({ + try: () => titreDemarcheGet( + etape.titreDemarcheId, { fields: { - demarche: { titre: { pointsEtape: { id: {} } } }, + titre: { + demarches: { etapes: { id: {} } }, + }, + etapes: { id: {} }, }, }, - user - ) - - if (isNullOrUndefined(titreEtape)) { - res.sendStatus(HTTP_STATUS.NOT_FOUND) - } else { - if (!titreEtape.demarche || !titreEtape.demarche.titre || titreEtape.demarche.titre.administrationsLocales === undefined) { - throw new Error('la démarche n’est pas chargée complètement') - } - - if ( - !canDeleteEtape(user, titreEtape.typeId, titreEtape.isBrouillon, titreEtape.titulaireIds ?? [], titreEtape.demarche.titre.administrationsLocales ?? [], titreEtape.demarche.typeId, { - typeId: titreEtape.demarche.titre.typeId, - titreStatutId: titreEtape.demarche.titre.titreStatutId, - }) - ) { - res.sendStatus(HTTP_STATUS.FORBIDDEN) - } else { - const titreDemarche = await titreDemarcheGet( - titreEtape.titreDemarcheId, - { - fields: { - titre: { - demarches: { etapes: { id: {} } }, - }, - etapes: { id: {} }, - }, - }, - userSuper - ) - - if (!titreDemarche) throw new Error("la démarche n'existe pas") - - if (!titreDemarche.titre) throw new Error("le titre n'existe pas") - - const { valid, errors: rulesErrors } = titreDemarcheUpdatedEtatValidate(titreDemarche.typeId, titreDemarche.titre, titreEtape, titreDemarche.id, titreDemarche.etapes!, true) - - if (!valid) { - throw new Error(rulesErrors.join(', ')) - } - await titreEtapeUpdate(titreEtape.id, { archive: true }, user, titreDemarche.titreId) - - await titreEtapeUpdateTask(pool, null, titreEtape.titreDemarcheId, user) - - res.sendStatus(HTTP_STATUS.NO_CONTENT) - } - } - } catch (e) { - res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR) - console.error(e) + userSuper + ), + catch: (error) => ({ message: fixMeError, extra: error }) + }), + Effect.filterOrFail( + (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche), + () => ({ message: fixMeError }) + ), + )), + Effect.bind('fullTitre', ({ fullDemarche }) => Effect.Do.pipe( + Effect.map(() => fullDemarche.titre), + Effect.filterOrFail( + (titre): titre is ITitre => isNotNullNorUndefined(titre), + () => ({ message: fixMeError, detail: 'titre est manquant ou incomplet' }) + ) + )), + Effect.tap(({ fullDemarche, fullTitre, etape }) => { + const { valid, errors: rulesErrors } = titreDemarcheUpdatedEtatValidate(fullDemarche.typeId, fullTitre, etape, fullDemarche.id, fullDemarche.etapes!, true) + if (!valid) { + return Effect.fail({ message: fixMeError, detail: rulesErrors.join(', ') }) } - } - } -const etapeFetchError = `Une erreur s'est produite lors de la récupération de l'étape` as const -const etapeIntrouvableError = `L'étape n'existe pas` as const -const etapeIncompletError = `L'étape n'a pas été chargée de manière complète` as const -const droitsManquantsError = `Les droits d'accès à cette étape ne sont pas suffisants` as const + return Effect.succeed(true) + }), + Effect.tap(({ etape, fullDemarche, user }) => Effect.tryPromise({ + try: () => titreEtapeUpdate(etape.id, { archive: true }, user, fullDemarche.titreId) + })), + Effect.tap(({ pool, etape, user }) => Effect.tryPromise({ + try: () => titreEtapeUpdateTask(pool, null, etape.titreDemarcheId, user) + })), + Effect.map(() => Option.none()), + Effect.mapError(caminoError => + Match.value(caminoError.message).pipe( + Match.whenOr( + "L'étape n'existe pas", + '', + () => ({ + ...caminoError, + status: HTTP_STATUS.INTERNAL_SERVER_ERROR, + }) + ), + Match.exhaustive + ) + ) + ) + type GetEtapeErrors = EffectDbQueryAndValidateErrors | TitreEtapeToFlattenEtapeErrors | typeof etapeFetchError | typeof etapeIntrouvableError | typeof etapeIncompletError | typeof droitsManquantsError export const getEtape: RestNewGetCall<'/rest/etapes/:etapeIdOrSlug'> = (rootPipe): Effect.Effect<FlattenEtape, CaminoApiError<GetEtapeErrors>> => rootPipe.pipe( @@ -290,7 +334,7 @@ export const getEtape: RestNewGetCall<'/rest/etapes/:etapeIdOrSlug'> = (rootPipe Effect.map(() => demarche.titre), Effect.filterOrFail( (titre): titre is ITitre => isNotNullNorUndefined(titre) && titre.administrationsLocales !== undefined, - () => ({ message: etapeIncompletError, detail: 'titre est manquant' }) + () => ({ message: etapeIncompletError, detail: 'titre est manquant ou incomplet' }) ) ) ), diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts index 457c1ed47..aad3d8a55 100644 --- a/packages/api/src/server/rest.ts +++ b/packages/api/src/server/rest.ts @@ -129,7 +129,7 @@ export type RestNewDeleteCall<Route extends NewDeleteRestRoutes> = ( data: Effect.Effect< { pool: Pool - user: User + user: UserNotNull params: z.infer<CaminoRestRoutesType[Route]['params']> cookie: CookieParams }, @@ -232,7 +232,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k }, '/rest/demarches/:demarcheId/geojson': { newGetCall: getPerimetreInfosByDemarche, ...CaminoRestRoutes['/rest/demarches/:demarcheId/geojson'] }, '/rest/etapes/:etapeId/geojson': { newGetCall: getPerimetreInfosByEtape, ...CaminoRestRoutes['/rest/etapes/:etapeId/geojson'] }, - '/rest/etapes/:etapeIdOrSlug': { deleteCall: deleteEtape, newGetCall: getEtape, ...CaminoRestRoutes['/rest/etapes/:etapeIdOrSlug'] }, + '/rest/etapes/:etapeIdOrSlug': { newDeleteCall: deleteEtape, newGetCall: getEtape, ...CaminoRestRoutes['/rest/etapes/:etapeIdOrSlug'] }, '/rest/etapes': { newPostCall: createEtape, newPutCall: updateEtape, ...CaminoRestRoutes['/rest/etapes'] }, '/rest/etapes/:etapeId/depot': { newPutCall: deposeEtape, ...CaminoRestRoutes['/rest/etapes/:etapeId/depot'] }, '/rest/etapes/:etapeId/entrepriseDocuments': { newGetCall: getEtapeEntrepriseDocuments, ...CaminoRestRoutes['/rest/etapes/:etapeId/entrepriseDocuments'] }, @@ -486,15 +486,22 @@ export const restWithPool = (dbPool: Pool): Router => { rest.delete(route, async (req: CaminoRequest, res: express.Response, _next: express.NextFunction) => { try { const call = Effect.Do.pipe( + Effect.bind('user', () => { + if (isNotNullNorUndefined(req.auth)) { + return Effect.succeed(req.auth as UserNotNull) + } else { + return Effect.fail({ message: 'Accès interdit', status: HTTP_STATUS.FORBIDDEN }) + } + }), Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)), Effect.mapError(caminoError => { return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST } }), // TODO 2024-06-26 ici, si on ne met pas les params et les searchParams à any, on se retrouve avec une typescript union hell qui fait tout planter - Effect.bind<'result', { params: any }, Option.Option<never>, CaminoApiError<string>, never>('result', ({ params }) => { + Effect.bind<'result', { params: any; user: UserNotNull }, Option.Option<never>, CaminoApiError<string>, never>('result', ({ params, user }) => { return maRoute.newDeleteCall( Effect.Do.pipe( - Effect.let('user', () => req.auth), + Effect.let('user', () => user), Effect.let('pool', () => dbPool), Effect.let('params', () => params), Effect.let('cookie', () => ({ diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts index a7d5dbab8..193ce0d15 100644 --- a/packages/common/src/rest.ts +++ b/packages/common/src/rest.ts @@ -221,7 +221,7 @@ export const CaminoRestRoutes = { '/rest/etapes/:etapeId/etapeDocuments': { params: etapeIdParamsValidator, newGet: { output: getEtapeDocumentsByEtapeIdValidator } }, '/rest/etapes/:etapeId/etapeAvis': { params: etapeIdParamsValidator, newGet: { output: getEtapeAvisByEtapeIdValidator } }, '/rest/etapes/:etapeId/entrepriseDocuments': { params: etapeIdParamsValidator, newGet: { output: z.array(etapeEntrepriseDocumentValidator) } }, - '/rest/etapes/:etapeIdOrSlug': { params: z.object({ etapeIdOrSlug: etapeIdOrSlugValidator }), delete: true, newGet: { output: flattenEtapeValidator } }, + '/rest/etapes/:etapeIdOrSlug': { params: z.object({ etapeIdOrSlug: etapeIdOrSlugValidator }), newDelete: true, newGet: { output: flattenEtapeValidator } }, '/rest/etapes/:etapeId/depot': { params: etapeIdParamsValidator, newPut: { input: z.object({}), output: z.object({ id: etapeIdValidator }) } }, '/rest/etapes': { params: noParamsValidator, -- GitLab From c5ab5f788566688a593d838d0a3dbb74527062f2 Mon Sep 17 00:00:00 2001 From: Anis Safine Laget <anis.safine@beta.gouv.fr> Date: Wed, 2 Apr 2025 10:40:28 +0200 Subject: [PATCH 3/6] =?UTF-8?q?delete=20etape=20effectis=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/rest/etape-creer.test.integration.ts | 2 +- .../rest/etape-modifier.test.integration.ts | 2 +- .../src/api/rest/etapes.test.integration.ts | 10 +- packages/api/src/api/rest/etapes.ts | 174 +++++++++++------- packages/api/src/server/rest.ts | 29 +-- packages/ui/src/api/client-rest.ts | 2 +- .../src/components/etape/etape-api-client.ts | 18 +- 7 files changed, 138 insertions(+), 99 deletions(-) diff --git a/packages/api/src/api/rest/etape-creer.test.integration.ts b/packages/api/src/api/rest/etape-creer.test.integration.ts index 93d9b9dc7..0fbb6a097 100644 --- a/packages/api/src/api/rest/etape-creer.test.integration.ts +++ b/packages/api/src/api/rest/etape-creer.test.integration.ts @@ -176,7 +176,7 @@ describe('etapeCreer', () => { expect(result.body).toMatchInlineSnapshot(` { "detail": "La valeur du champ "date" est invalide.", - "message": "Problème de validation de données", + "message": "Corps de la requête manquant ou invalide", "status": 400, "zodErrorReadableMessage": "Validation error: Invalid at "date"; date invalide at "date"; Required at "duree"; Required at "dateDebut"; Required at "dateFin"; Required at "substances"; Required at "geojson4326Perimetre"; Required at "geojson4326Points"; Required at "geojsonOriginePoints"; Required at "geojsonOriginePerimetre"; Required at "geojsonOrigineForages"; Required at "geojsonOrigineGeoSystemeId"; Required at "titulaireIds"; Required at "amodiataireIds"; Required at "note"; Required at "contenu"; Required at "heritageProps"; Required at "heritageContenu"; Required at "etapeDocuments"; Required at "entrepriseDocumentIds"; Required at "etapeAvis"", } diff --git a/packages/api/src/api/rest/etape-modifier.test.integration.ts b/packages/api/src/api/rest/etape-modifier.test.integration.ts index cb95b8617..fa59f8348 100644 --- a/packages/api/src/api/rest/etape-modifier.test.integration.ts +++ b/packages/api/src/api/rest/etape-modifier.test.integration.ts @@ -75,7 +75,7 @@ describe('etapeModifier', () => { expect(result.body).toMatchInlineSnapshot(` { "detail": "La valeur du champ "date" est invalide.", - "message": "Problème de validation de données", + "message": "Corps de la requête invalide", "status": 400, "zodErrorReadableMessage": "Validation error: Invalid at "date"; date invalide at "date"; Required at "duree"; Required at "dateDebut"; Required at "dateFin"; Required at "substances"; Required at "geojson4326Perimetre"; Required at "geojson4326Points"; Required at "geojsonOriginePoints"; Required at "geojsonOriginePerimetre"; Required at "geojsonOrigineForages"; Required at "geojsonOrigineGeoSystemeId"; Required at "titulaireIds"; Required at "amodiataireIds"; Required at "note"; Required at "contenu"; Required at "titreDemarcheId"; Required at "heritageProps"; Required at "heritageContenu"; Required at "etapeDocuments"; Required at "entrepriseDocumentIds"; Required at "etapeAvis"", } diff --git a/packages/api/src/api/rest/etapes.test.integration.ts b/packages/api/src/api/rest/etapes.test.integration.ts index 26487e6ee..f68ea39b8 100644 --- a/packages/api/src/api/rest/etapes.test.integration.ts +++ b/packages/api/src/api/rest/etapes.test.integration.ts @@ -1,6 +1,6 @@ import { dbManager } from '../../../tests/db-manager' import { userSuper } from '../../database/user-super' -import { restCall, restDeleteCall, restNewCall, restNewPostCall } from '../../../tests/_utils/index' +import { restCall, restNewCall, restNewDeleteCall, restNewPostCall } from '../../../tests/_utils/index' import { caminoDateValidator, dateAddDays, toCaminoDate } from 'camino-common/src/date' import { afterAll, beforeAll, test, expect, describe, vi } from 'vitest' import type { Pool } from 'pg' @@ -284,14 +284,14 @@ describe('etapeSupprimer', () => { ], }) - const tested = await restDeleteCall( + const tested = await restNewDeleteCall( dbPool, '/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug: etapeId }, role && isAdministrationRole(role) ? { role, administrationId: 'min-mctrct-dgcl-01' } : undefined ) - expect(tested.statusCode).toBe(HTTP_STATUS.FORBIDDEN) + expect(tested.statusCode, JSON.stringify(tested.body)).toBe(HTTP_STATUS.FORBIDDEN) }) test('peut supprimer une étape (utilisateur super)', async () => { @@ -324,7 +324,7 @@ describe('etapeSupprimer', () => { ], }) - const tested = await restDeleteCall(dbPool, '/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug: etapeId }, userSuper) + const tested = await restNewDeleteCall(dbPool, '/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug: etapeId }, userSuper) expect(tested.statusCode).toBe(HTTP_STATUS.NO_CONTENT) }) @@ -380,7 +380,7 @@ describe('etapeSupprimer', () => { const getEtape = await restCall(dbPool, '/rest/titres/:titreId', { titreId: titreId }, user) expect(getEtape.statusCode).toBe(HTTP_STATUS.OK) - const tested = await restDeleteCall(dbPool, '/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug: etapeId }, user) + const tested = await restNewDeleteCall(dbPool, '/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug: etapeId }, user) expect(tested.statusCode).toBe(HTTP_STATUS.FORBIDDEN) }) diff --git a/packages/api/src/api/rest/etapes.ts b/packages/api/src/api/rest/etapes.ts index 18a64f14f..209ab4882 100644 --- a/packages/api/src/api/rest/etapes.ts +++ b/packages/api/src/api/rest/etapes.ts @@ -1,11 +1,8 @@ -import { CaminoRequest, CustomResponse } from './express-type' import { EtapeTypeEtapeStatutWithMainStep, - etapeIdValidator, EtapeId, GetEtapeDocumentsByEtapeId, ETAPE_IS_NOT_BROUILLON, - etapeIdOrSlugValidator, GetEtapeAvisByEtapeId, EtapeBrouillon, etapeSlugValidator, @@ -64,13 +61,14 @@ import { KM2 } from 'camino-common/src/number' import { FeatureMultiPolygon, FeatureCollectionPoints } from 'camino-common/src/perimetre' import { canHaveForages } from 'camino-common/src/permissions/titres' import { SecteursMaritimes, getSecteurMaritime } from 'camino-common/src/static/facades' -import { shortCircuitError, zodParseEffect } from '../../tools/fp-tools' +import { shortCircuitError } from '../../tools/fp-tools' import { RestNewDeleteCall, RestNewGetCall, RestNewPostCall, RestNewPutCall } from '../../server/rest' import { Effect, Match, Option } from 'effect' import { EffectDbQueryAndValidateErrors } from '../../pg-database' import { CaminoError } from 'camino-common/src/zod-tools' import { machineFind } from '../../business/rules-demarches/machines' import { TitreEtapeForMachine } from '../../business/rules-demarches/machine-common' +import { newEtapeId } from '../../database/models/_format/id-create' type GetEtapeEntrepriseDocumentsErrors = EffectDbQueryAndValidateErrors export const getEtapeEntrepriseDocuments: RestNewGetCall<'/rest/etapes/:etapeId/entrepriseDocuments'> = ( @@ -183,25 +181,42 @@ const etapeFetchError = `Une erreur s'est produite lors de la récupération de const etapeIntrouvableError = `L'étape n'existe pas` as const const etapeIncompletError = `L'étape n'a pas été chargée de manière complète` as const const droitsManquantsError = `Les droits d'accès à cette étape ne sont pas suffisants` as const -const fixMeError = 'FIXME' as const -type DeleteEtapeErrors = EffectDbQueryAndValidateErrors | typeof etapeFetchError | typeof etapeIntrouvableError | typeof etapeIncompletError | typeof droitsManquantsError | typeof fixMeError -export const deleteEtape: RestNewDeleteCall<'/rest/etapes/:etapeIdOrSlug'> = - (rootPipe): Effect.Effect<Option.Option<never>, CaminoApiError<DeleteEtapeErrors>> => +const etapeUpdateFail = `La mise à jour de l'étape a échoué` as const +const etapeUpdateTaskFail = `Les tâches après mise à jour de l'étape ont échoué` as const +const demarcheFetchError = `Une erreur s'est produite lors de la récupération de la démarche` as const +const demarcheIntrouvableError = `La démarche associée à l'étape est vide` as const +const titreIntrouvableError = `Le titre associé à l'étape est vide` as const +const demarcheInvalideError = `La suppression de cette étape mène à une démarche invalide` as const +type DeleteEtapeErrors = + | EffectDbQueryAndValidateErrors + | typeof etapeFetchError + | typeof etapeIntrouvableError + | typeof etapeIncompletError + | typeof droitsManquantsError + | typeof etapeUpdateFail + | typeof etapeUpdateTaskFail + | typeof demarcheFetchError + | typeof demarcheIntrouvableError + | typeof titreIntrouvableError + | typeof demarcheInvalideError +export const deleteEtape: RestNewDeleteCall<'/rest/etapes/:etapeIdOrSlug'> = (rootPipe): Effect.Effect<Option.Option<never>, CaminoApiError<DeleteEtapeErrors>> => rootPipe.pipe( Effect.bind('etape', ({ params, user }) => Effect.Do.pipe( - () => Effect.tryPromise({ - try: () => titreEtapeGet( - params.etapeIdOrSlug, - { - fields: { - demarche: { titre: { pointsEtape: { id: {} } } }, - }, - }, - user - ), - catch: (error) => ({ message: etapeFetchError, extra: error }) - }), + () => + Effect.tryPromise({ + try: () => + titreEtapeGet( + params.etapeIdOrSlug, + { + fields: { + demarche: { titre: { pointsEtape: { id: {} } } }, + }, + }, + user + ), + catch: error => ({ message: etapeFetchError, extra: error }), + }), Effect.filterOrFail( (etape): etape is ITitreEtape => isNotNullNorUndefined(etape), () => ({ message: etapeIntrouvableError }) @@ -228,68 +243,89 @@ export const deleteEtape: RestNewDeleteCall<'/rest/etapes/:etapeIdOrSlug'> = ), Effect.filterOrFail( ({ titre, demarche, etape, user }) => - canDeleteEtape( - user, - etape.typeId, - etape.isBrouillon, - etape.titulaireIds ?? [], - titre.administrationsLocales ?? [], - demarche.typeId, - { - typeId: titre.typeId, - titreStatutId: titre.titreStatutId, - }, - ), + canDeleteEtape(user, etape.typeId, etape.isBrouillon, etape.titulaireIds ?? [], titre.administrationsLocales ?? [], demarche.typeId, { + typeId: titre.typeId, + titreStatutId: titre.titreStatutId, + }), () => ({ message: droitsManquantsError }) ), - Effect.bind('fullDemarche', ({ etape }) => Effect.Do.pipe( - () => Effect.tryPromise({ - try: () => titreDemarcheGet( - etape.titreDemarcheId, - { - fields: { - titre: { - demarches: { etapes: { id: {} } }, - }, - etapes: { id: {} }, - }, - }, - userSuper - ), - catch: (error) => ({ message: fixMeError, extra: error }) - }), - Effect.filterOrFail( - (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche), - () => ({ message: fixMeError }) - ), - )), - Effect.bind('fullTitre', ({ fullDemarche }) => Effect.Do.pipe( - Effect.map(() => fullDemarche.titre), - Effect.filterOrFail( - (titre): titre is ITitre => isNotNullNorUndefined(titre), - () => ({ message: fixMeError, detail: 'titre est manquant ou incomplet' }) + Effect.bind('fullDemarche', ({ etape }) => + Effect.Do.pipe( + () => + Effect.tryPromise({ + try: () => + titreDemarcheGet( + etape.titreDemarcheId, + { + fields: { + titre: { + demarches: { etapes: { id: {} } }, + }, + etapes: { id: {} }, + }, + }, + userSuper + ), + catch: error => ({ message: demarcheFetchError, extra: error }), + }), + Effect.filterOrFail( + (demarche): demarche is ITitreDemarche => isNotNullNorUndefined(demarche), + () => ({ message: demarcheIntrouvableError }) + ) + ) + ), + Effect.bind('fullTitre', ({ fullDemarche }) => + Effect.Do.pipe( + Effect.map(() => fullDemarche.titre), + Effect.filterOrFail( + (titre): titre is ITitre => isNotNullNorUndefined(titre), + () => ({ message: titreIntrouvableError, detail: 'titre est manquant ou incomplet' }) + ) ) - )), + ), Effect.tap(({ fullDemarche, fullTitre, etape }) => { const { valid, errors: rulesErrors } = titreDemarcheUpdatedEtatValidate(fullDemarche.typeId, fullTitre, etape, fullDemarche.id, fullDemarche.etapes!, true) if (!valid) { - return Effect.fail({ message: fixMeError, detail: rulesErrors.join(', ') }) + return Effect.fail({ message: demarcheInvalideError, detail: rulesErrors.join(', ') }) } return Effect.succeed(true) }), - Effect.tap(({ etape, fullDemarche, user }) => Effect.tryPromise({ - try: () => titreEtapeUpdate(etape.id, { archive: true }, user, fullDemarche.titreId) - })), - Effect.tap(({ pool, etape, user }) => Effect.tryPromise({ - try: () => titreEtapeUpdateTask(pool, null, etape.titreDemarcheId, user) - })), + Effect.tap(({ etape, fullDemarche, user }) => + Effect.tryPromise({ + try: () => titreEtapeUpdate(etape.id, { archive: true }, user, fullDemarche.titreId), + catch: error => ({ message: etapeUpdateFail, extra: error }), + }) + ), + Effect.tap(({ pool, etape, user }) => + Effect.tryPromise({ + try: () => titreEtapeUpdateTask(pool, null, etape.titreDemarcheId, user), + catch: error => ({ message: etapeUpdateTaskFail, extra: error }), + }) + ), Effect.map(() => Option.none()), Effect.mapError(caminoError => Match.value(caminoError.message).pipe( + Match.when("L'étape n'existe pas", () => ({ + ...caminoError, + status: HTTP_STATUS.NOT_FOUND, + })), + Match.when("Les droits d'accès à cette étape ne sont pas suffisants", () => ({ + ...caminoError, + status: HTTP_STATUS.FORBIDDEN, + })), + Match.when('La suppression de cette étape mène à une démarche invalide', () => ({ + ...caminoError, + status: HTTP_STATUS.BAD_REQUEST, + })), Match.whenOr( - "L'étape n'existe pas", - '', + "L'étape n'a pas été chargée de manière complète", + "La démarche associée à l'étape est vide", + "La mise à jour de l'étape a échoué", + "Le titre associé à l'étape est vide", + "Les tâches après mise à jour de l'étape ont échoué", + "Une erreur s'est produite lors de la récupération de l'étape", + "Une erreur s'est produite lors de la récupération de la démarche", () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR, @@ -529,7 +565,7 @@ const getFlattenEtape = ( }, {}) return Effect.succeed(heritageContenu) }), - Effect.bind('fakeEtapeId', () => zodParseEffect(etapeIdValidator, 'newId')), + Effect.let('fakeEtapeId', () => newEtapeId('newId')), Effect.bind('flattenEtape', ({ perimetreInfos, heritageProps, heritageContenu, fakeEtapeId }) => iTitreEtapeToFlattenEtape({ ...etape, diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts index aad3d8a55..40264ccf3 100644 --- a/packages/api/src/server/rest.ts +++ b/packages/api/src/server/rest.ts @@ -56,7 +56,7 @@ import { titreDemandeCreer } from '../api/rest/titre-demande' import { config } from '../config/index' import { addLog } from '../api/rest/logs.queries' import { HTTP_STATUS } from 'camino-common/src/http' -import { zodParseEffect } from '../tools/fp-tools' +import { zodParseEffectTyped } from '../tools/fp-tools' import { Cause, Effect, Exit, Option, pipe } from 'effect' interface IRestResolverResult { @@ -265,12 +265,12 @@ export const restWithPool = (dbPool: Pool): Router => { const call = Effect.Do.pipe( Effect.bind('searchParams', () => { if ('searchParams' in maRoute.newGet) { - return zodParseEffect(maRoute.newGet.searchParams, req.query) + return zodParseEffectTyped(maRoute.newGet.searchParams, req.query, 'Paramètres de recherche manquants ou invalides' as const) } return Effect.succeed(undefined) }), - Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)), + Effect.bind('params', () => zodParseEffectTyped(maRoute.params, req.params, 'Paramètres manquants ou invalides')), Effect.mapError(caminoError => { return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST } }), @@ -291,7 +291,7 @@ export const restWithPool = (dbPool: Pool): Router => { }), Effect.bind('parsedResult', ({ result }) => pipe( - zodParseEffect(maRoute.newGet.output, result), + zodParseEffectTyped(maRoute.newGet.output, result, 'Résultat de la requête invalide'), Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })) ) ), @@ -342,8 +342,8 @@ export const restWithPool = (dbPool: Pool): Router => { return Effect.fail({ message: 'Accès interdit', status: HTTP_STATUS.FORBIDDEN }) } }), - Effect.bind('body', () => zodParseEffect(maRoute.newPost.input, req.body)), - Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)), + Effect.bind('body', () => zodParseEffectTyped(maRoute.newPost.input, req.body, 'Corps de la requête manquant ou invalide')), + Effect.bind('params', () => zodParseEffectTyped(maRoute.params, req.params, 'Paramètres manquants ou invalides')), Effect.mapError(caminoError => { if (!('status' in caminoError)) { return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST } @@ -364,7 +364,7 @@ export const restWithPool = (dbPool: Pool): Router => { ), Effect.bind('parsedResult', ({ result }) => pipe( - zodParseEffect(maRoute.newPost.output, result), + zodParseEffectTyped(maRoute.newPost.output, result, 'Résultat de la requête invalide'), Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })) ) ), @@ -413,8 +413,8 @@ export const restWithPool = (dbPool: Pool): Router => { return Effect.fail({ message: 'Accès interdit', status: HTTP_STATUS.FORBIDDEN }) } }), - Effect.bind('body', () => zodParseEffect(maRoute.newPut.input, req.body)), - Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)), + Effect.bind('body', () => zodParseEffectTyped(maRoute.newPut.input, req.body, 'Corps de la requête invalide')), + Effect.bind('params', () => zodParseEffectTyped(maRoute.params, req.params, 'Paramètres manquants ou invalides')), Effect.mapError(caminoError => { if (!('status' in caminoError)) { return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST } @@ -435,7 +435,7 @@ export const restWithPool = (dbPool: Pool): Router => { ), Effect.bind('parsedResult', ({ result }) => pipe( - zodParseEffect(maRoute.newPut.output, result), + zodParseEffectTyped(maRoute.newPut.output, result, 'Résultat de la requête invalide'), Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })) ) ), @@ -493,9 +493,14 @@ export const restWithPool = (dbPool: Pool): Router => { return Effect.fail({ message: 'Accès interdit', status: HTTP_STATUS.FORBIDDEN }) } }), - Effect.bind('params', () => zodParseEffect(maRoute.params, req.params)), + // TODO 2025-04-02 ici, si on ne met pas les params à any, on se retrouve avec une typescript union hell qui fait tout planter + Effect.bind('params', () => zodParseEffectTyped(maRoute.params, req.params as any, 'Paramètres manquants ou invalides')), Effect.mapError(caminoError => { - return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST } + if (!('status' in caminoError)) { + return { ...caminoError, status: HTTP_STATUS.BAD_REQUEST } + } + + return caminoError }), // TODO 2024-06-26 ici, si on ne met pas les params et les searchParams à any, on se retrouve avec une typescript union hell qui fait tout planter Effect.bind<'result', { params: any; user: UserNotNull }, Option.Option<never>, CaminoApiError<string>, never>('result', ({ params, user }) => { diff --git a/packages/ui/src/api/client-rest.ts b/packages/ui/src/api/client-rest.ts index 529076e74..b5300bfec 100644 --- a/packages/ui/src/api/client-rest.ts +++ b/packages/ui/src/api/client-rest.ts @@ -190,7 +190,7 @@ export const newDeleteWithJson = async <T extends NewDeleteRestRoutes>(path: T, } /** - * @deprecated use deleteWithJson + * @deprecated use newDeleteWithJson **/ export const deleteWithJson = async <T extends DeleteRestRoutes>(path: T, params: CaminoRestParams<T>, searchParams: Record<string, string | string[]> = {}): Promise<void> => callFetch(path, params, 'delete', searchParams) diff --git a/packages/ui/src/components/etape/etape-api-client.ts b/packages/ui/src/components/etape/etape-api-client.ts index ae97078e3..7f4ab41a8 100644 --- a/packages/ui/src/components/etape/etape-api-client.ts +++ b/packages/ui/src/components/etape/etape-api-client.ts @@ -1,5 +1,5 @@ import { apiGraphQLFetch } from '@/api/_client' -import { deleteWithJson, newGetWithJson, newPostWithJson, newPutWithJson } from '@/api/client-rest' +import { newDeleteWithJson, newGetWithJson, newPostWithJson, newPutWithJson } from '@/api/client-rest' import { CaminoDate, caminoDateValidator } from 'camino-common/src/date' import { DemarcheId } from 'camino-common/src/demarche' import { entrepriseIdValidator } from 'camino-common/src/entreprise' @@ -80,7 +80,7 @@ export type GetEtapeHeritagePotentiel = z.infer<typeof heritageValidator> export type CoreEtapeCreationOrModification = Pick<Nullable<FlattenEtape>, 'id' | 'slug'> & DistributiveOmit<FlattenEtape, 'id' | 'slug'> export interface EtapeApiClient { getEtapesTypesEtapesStatuts: (titreDemarcheId: DemarcheId, titreEtapeId: EtapeId | null, date: CaminoDate) => Promise<EtapeTypeEtapeStatutWithMainStep | CaminoError<string>> - deleteEtape: (titreEtapeId: EtapeId) => Promise<void> + deleteEtape: (titreEtapeId: EtapeId) => Promise<void | CaminoError<string>> deposeEtape: (titreEtapeId: EtapeId) => Promise<{ id: EtapeId } | CaminoError<string>> getEtapeDocumentsByEtapeId: (etapeId: EtapeId) => Promise<GetEtapeDocumentsByEtapeId | CaminoError<string>> getEtapeHeritagePotentiel: (etape: Pick<CoreEtapeCreationOrModification, 'id' | 'date' | 'typeId'>, titreDemarcheId: DemarcheId) => Promise<GetEtapeHeritagePotentiel> @@ -93,13 +93,11 @@ export interface EtapeApiClient { export const etapeApiClient: EtapeApiClient = { getEtapesTypesEtapesStatuts: async (demarcheId, etapeId, date) => newGetWithJson('/rest/etapesTypes/:demarcheId/:date', { demarcheId, date }, etapeId ? { etapeId } : {}), - deleteEtape: async etapeIdOrSlug => { - await deleteWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug }) - }, - deposeEtape: async etapeId => newPutWithJson('/rest/etapes/:etapeId/depot', { etapeId }, {}), + deleteEtape: etapeIdOrSlug => newDeleteWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug }), + deposeEtape: etapeId => newPutWithJson('/rest/etapes/:etapeId/depot', { etapeId }, {}), - getEtapeDocumentsByEtapeId: async etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeDocuments', { etapeId }), - getEtapeAvisByEtapeId: async etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeAvis', { etapeId }), + getEtapeDocumentsByEtapeId: etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeDocuments', { etapeId }), + getEtapeAvisByEtapeId: etapeId => newGetWithJson('/rest/etapes/:etapeId/etapeAvis', { etapeId }), getEtape: async etapeIdOrSlug => { const etape = await newGetWithJson('/rest/etapes/:etapeIdOrSlug', { etapeIdOrSlug }) @@ -199,6 +197,6 @@ export const etapeApiClient: EtapeApiClient = { return heritageData }, - etapeCreer: async etape => newPostWithJson('/rest/etapes', {}, restEtapeCreationValidator.parse(etape)), - etapeModifier: async etape => newPutWithJson('/rest/etapes', {}, restEtapeModificationValidator.parse(etape)), + etapeCreer: etape => newPostWithJson('/rest/etapes', {}, restEtapeCreationValidator.parse(etape)), + etapeModifier: etape => newPutWithJson('/rest/etapes', {}, restEtapeModificationValidator.parse(etape)), } -- GitLab From da1b3b02348f76bff7d65a1c5338e5c2fccbb8ee Mon Sep 17 00:00:00 2001 From: Anis Safine Laget <anis.safine@beta.gouv.fr> Date: Wed, 2 Apr 2025 10:55:35 +0200 Subject: [PATCH 4/6] add test --- .../src/components/_ui/functional-popup.tsx | 4 ++- .../demarche/remove-etape-popup.stories.tsx | 21 ++++++++++++ ...e-etape-popup.stories_snapshots_Error.html | 33 +++++++++++++++++++ .../demarche/remove-etape-popup.tsx | 4 +-- packages/ui/src/components/titre.tsx | 6 +++- 5 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html diff --git a/packages/ui/src/components/_ui/functional-popup.tsx b/packages/ui/src/components/_ui/functional-popup.tsx index f47660e98..27e08a6e8 100644 --- a/packages/ui/src/components/_ui/functional-popup.tsx +++ b/packages/ui/src/components/_ui/functional-popup.tsx @@ -127,7 +127,9 @@ export const FunctionalPopup = defineComponent(<T,>(props: Props<T>) => { <div class={fr.cx('fr-container')}> {props.content()} {validateProcess.value.status === 'ERROR' || validateProcess.value.status === 'NEW_ERROR' ? ( - <LoadingElement class={fr.cx('fr-mt-4v')} data={validateProcess.value} renderItem={() => null} /> + <> + <LoadingElement class={fr.cx('fr-mt-4v')} data={validateProcess.value} renderItem={() => null} /> + </> ) : null} </div> </div> diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx index 42ca2b6ab..6aeb1398a 100644 --- a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx +++ b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx @@ -30,3 +30,24 @@ export const Default: StoryFn = () => ( close={close} /> ) + +export const Error: StoryFn = () => ( + <RemoveEtapePopup + demarcheTypeId="oct" + etapeTypeId="mfr" + titreTypeId="arm" + id={etapeIdValidator.parse('etapeId')} + apiClient={{ + deleteEtape(titreEtapeId) { + deleteEtape(titreEtapeId) + + return Promise.resolve({ + message: 'Ceci est une erreur', + detail: `Ceci est un détail d'erreur`, + }) + }, + }} + titreNom="Nouvelle espérance" + close={close} + /> +) diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html b/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html new file mode 100644 index 000000000..29e3fb1fc --- /dev/null +++ b/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html @@ -0,0 +1,33 @@ +<!--teleport start--> +<div> + <dialog id="monId" class="fr-modal fr-modal--opened" open="" aria-modal="true" role="dialog" aria-labelledby="monId-title" style="z-index: 1000001;"> + <div class="fr-container fr-container--fluid fr-container-md"> + <div class="fr-grid-row fr-grid-row--center"> + <div class="fr-col-12 fr-col-md-8 fr-col-lg-6"> + <div class="fr-modal__body"> + <div class="fr-modal__header"><button class="fr-btn--close fr-btn" aria-controls="monId" title="Fermer">Fermer</button></div> + <div class="fr-modal__content"> + <h1 id="monId-title" class="fr-modal__title"><span class="fr-icon-arrow-right-line fr-icon--lg" aria-hidden="true"></span>Suppression de l'étape</h1> + <div class="fr-container"> + <div class="fr-alert fr-alert--warning"> + <p class="fr-alert__title fr-h4">Attention : cette opération est définitive et ne peut pas être annulée.</p>Souhaitez vous supprimer l'étape <span class="fr-text--bold">demande</span> de la démarche <span class="fr-text--bold">octroi</span> du titre <span class="fr-text--bold">Nouvelle espérance (autorisation de recherches)</span> ? + </div> + <!----> + </div> + </div> + <div class="fr-modal__footer"> + <div style="display: flex; width: 100%; justify-content: end; align-items: center; gap: 1rem;"> + <!----> + <ul class="fr-btns-group fr-btns-group--right fr-btns-group--inline-reverse fr-btns-group--inline-lg fr-btns-group--icon-left" style="width: auto;"> + <li><button class="fr-btn fr-btn--icon-left fr-icon-check-line fr-btn--primary fr-btn--md" title="Supprimer" aria-label="Supprimer" type="button">Supprimer</button></li> + <li><button class="fr-btn fr-btn--icon-left fr-icon-arrow-go-back-fill fr-btn--secondary fr-btn--md" title="Annuler" aria-label="Annuler" aria-controls="monId" type="button">Annuler</button></li> + </ul> + </div> + </div> + </div> + </div> + </div> + </div> + </dialog> +</div> +<!--teleport end--> \ No newline at end of file diff --git a/packages/ui/src/components/demarche/remove-etape-popup.tsx b/packages/ui/src/components/demarche/remove-etape-popup.tsx index 5164c5ed0..37dca1032 100644 --- a/packages/ui/src/components/demarche/remove-etape-popup.tsx +++ b/packages/ui/src/components/demarche/remove-etape-popup.tsx @@ -18,9 +18,7 @@ interface Props { } export const RemoveEtapePopup: FunctionalComponent<Props> = props => { - const deleteEtape = async () => { - await props.apiClient.deleteEtape(props.id) - } + const deleteEtape = () => props.apiClient.deleteEtape(props.id) const content = () => ( <Alert type="warning" diff --git a/packages/ui/src/components/titre.tsx b/packages/ui/src/components/titre.tsx index b625a4bc8..c298cee79 100644 --- a/packages/ui/src/components/titre.tsx +++ b/packages/ui/src/components/titre.tsx @@ -237,7 +237,11 @@ export const PureTitre = defineComponent<Props>(props => { ...props.apiClient, deleteEtape: async titreEtapeId => { if (titreData.value.status === 'LOADED') { - await props.apiClient.deleteEtape(titreEtapeId) + const result = await props.apiClient.deleteEtape(titreEtapeId) + if (typeof result === 'object' && 'message' in result) { + return result + } + await retrieveTitre() } }, -- GitLab From 2fbbdf7043d7bdd69a1b7befbd8c2c8c7743dcf8 Mon Sep 17 00:00:00 2001 From: Anis Safine Laget <anis.safine@beta.gouv.fr> Date: Wed, 2 Apr 2025 11:03:36 +0200 Subject: [PATCH 5/6] =?UTF-8?q?am=C3=A9liorations=20diverses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/src/components/_ui/functional-popup.tsx | 4 +--- .../demarche/remove-etape-popup.stories.tsx | 15 +++++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/_ui/functional-popup.tsx b/packages/ui/src/components/_ui/functional-popup.tsx index 27e08a6e8..f47660e98 100644 --- a/packages/ui/src/components/_ui/functional-popup.tsx +++ b/packages/ui/src/components/_ui/functional-popup.tsx @@ -127,9 +127,7 @@ export const FunctionalPopup = defineComponent(<T,>(props: Props<T>) => { <div class={fr.cx('fr-container')}> {props.content()} {validateProcess.value.status === 'ERROR' || validateProcess.value.status === 'NEW_ERROR' ? ( - <> - <LoadingElement class={fr.cx('fr-mt-4v')} data={validateProcess.value} renderItem={() => null} /> - </> + <LoadingElement class={fr.cx('fr-mt-4v')} data={validateProcess.value} renderItem={() => null} /> ) : null} </div> </div> diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx index 6aeb1398a..7028cf101 100644 --- a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx +++ b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx @@ -2,6 +2,9 @@ import { action } from '@storybook/addon-actions' import { Meta, StoryFn } from '@storybook/vue3' import { RemoveEtapePopup } from './remove-etape-popup' import { etapeIdValidator } from 'camino-common/src/etape' +import { DEMARCHES_TYPES_IDS } from 'camino-common/src/static/demarchesTypes' +import { ETAPES_TYPES } from 'camino-common/src/static/etapesTypes' +import { TITRES_TYPES_IDS } from 'camino-common/src/static/titresTypes' const meta: Meta = { title: 'Components/Demarche/RemoveEtapePopup', @@ -15,9 +18,9 @@ const close = action('close') export const Default: StoryFn = () => ( <RemoveEtapePopup - demarcheTypeId="oct" - etapeTypeId="mfr" - titreTypeId="arm" + demarcheTypeId={DEMARCHES_TYPES_IDS.Octroi} + etapeTypeId={ETAPES_TYPES.demande} + titreTypeId={TITRES_TYPES_IDS.AUTORISATION_DE_RECHERCHE_METAUX} id={etapeIdValidator.parse('etapeId')} apiClient={{ deleteEtape(titreEtapeId) { @@ -33,9 +36,9 @@ export const Default: StoryFn = () => ( export const Error: StoryFn = () => ( <RemoveEtapePopup - demarcheTypeId="oct" - etapeTypeId="mfr" - titreTypeId="arm" + demarcheTypeId={DEMARCHES_TYPES_IDS.Octroi} + etapeTypeId={ETAPES_TYPES.demande} + titreTypeId={TITRES_TYPES_IDS.AUTORISATION_DE_RECHERCHE_METAUX} id={etapeIdValidator.parse('etapeId')} apiClient={{ deleteEtape(titreEtapeId) { -- GitLab From 8e3c1b74d1fd46dd89efee67c41fa0ef7ac12c92 Mon Sep 17 00:00:00 2001 From: Anis Safine Laget <anis.safine@beta.gouv.fr> Date: Wed, 2 Apr 2025 11:07:58 +0200 Subject: [PATCH 6/6] rename story --- .../ui/src/components/demarche/remove-etape-popup.stories.tsx | 2 +- ...html => remove-etape-popup.stories_snapshots_WithError.html} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/ui/src/components/demarche/{remove-etape-popup.stories_snapshots_Error.html => remove-etape-popup.stories_snapshots_WithError.html} (100%) diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx index 7028cf101..dcf4b1c4f 100644 --- a/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx +++ b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx @@ -34,7 +34,7 @@ export const Default: StoryFn = () => ( /> ) -export const Error: StoryFn = () => ( +export const WithError: StoryFn = () => ( <RemoveEtapePopup demarcheTypeId={DEMARCHES_TYPES_IDS.Octroi} etapeTypeId={ETAPES_TYPES.demande} diff --git a/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html b/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_WithError.html similarity index 100% rename from packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_Error.html rename to packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_WithError.html -- GitLab