From a5efb3a2dccd2c18c58d46586a61b4d612082607 Mon Sep 17 00:00:00 2001 From: SAFINE LAGET Anis <anis.safine@beta.gouv.fr> Date: Wed, 2 Apr 2025 09:17:01 +0000 Subject: [PATCH] =?UTF-8?q?chore(api):=20passage=20=C3=A0=20effect=20de=20?= =?UTF-8?q?getEtape=20et=20deleteEtape=20(pub/pnm-public/camino!1691)?= 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 | 339 ++++++++++++------ packages/api/src/server/rest.ts | 44 ++- packages/common/src/rest.ts | 2 +- packages/ui/src/api/client-rest.ts | 2 +- .../demarche/remove-etape-popup.stories.tsx | 30 +- ...ape-popup.stories_snapshots_WithError.html | 33 ++ .../demarche/remove-etape-popup.tsx | 4 +- packages/ui/src/components/etape-edition.tsx | 7 +- .../src/components/etape/etape-api-client.ts | 30 +- packages/ui/src/components/titre.tsx | 6 +- 13 files changed, 356 insertions(+), 155 deletions(-) create mode 100644 packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_WithError.html 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 46487ac62..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 { callAndExit, shortCircuitError, zodParseEffect } from '../../tools/fp-tools' -import { RestNewGetCall, RestNewPostCall, RestNewPutCall } from '../../server/rest' -import { Effect, Match } from 'effect' +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'> = ( @@ -179,114 +177,239 @@ 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, - { - fields: { - demarche: { titre: { pointsEtape: { id: {} } } }, - }, - }, - user +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 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.filterOrFail( + (etape): etape is ITitreEtape => isNotNullNorUndefined(etape), + () => ({ message: etapeIntrouvableError }) ) - - 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: {} } }, + ) + ), + 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: { + titre: { + demarches: { etapes: { id: {} } }, + }, + 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: 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: demarcheInvalideError, detail: rulesErrors.join(', ') }) } - } - } -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, + return Effect.succeed(true) + }), + 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'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, }) - ) { - res.sendStatus(HTTP_STATUS.FORBIDDEN) - } else { - res.json(await callAndExit(iTitreEtapeToFlattenEtape(titreEtape))) - } - } catch (e) { - console.error(e) + ), + Match.exhaustive + ) + ) + ) - res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR) - } - } - } +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 ou incomplet' }) + ) + ) + ), + 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, + }) + ), + Match.exhaustive + ) + ) + ) const documentDEntrepriseIncorrects = "document d'entreprise incorrects" as const type ValidateAndGetEntrepriseDocumentsErrors = typeof documentDEntrepriseIncorrects | EffectDbQueryAndValidateErrors @@ -442,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 891111944..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 { @@ -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, getCall: 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'] }, @@ -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 })) ) ), @@ -486,15 +486,27 @@ 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('params', () => zodParseEffect(maRoute.params, req.params)), + 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 }) + } + }), + // 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 }, 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 c5e809b96..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, get: { 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, 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/demarche/remove-etape-popup.stories.tsx b/packages/ui/src/components/demarche/remove-etape-popup.stories.tsx index 42ca2b6ab..dcf4b1c4f 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) { @@ -30,3 +33,24 @@ export const Default: StoryFn = () => ( close={close} /> ) + +export const WithError: StoryFn = () => ( + <RemoveEtapePopup + demarcheTypeId={DEMARCHES_TYPES_IDS.Octroi} + etapeTypeId={ETAPES_TYPES.demande} + titreTypeId={TITRES_TYPES_IDS.AUTORISATION_DE_RECHERCHE_METAUX} + 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_WithError.html b/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_WithError.html new file mode 100644 index 000000000..29e3fb1fc --- /dev/null +++ b/packages/ui/src/components/demarche/remove-etape-popup.stories_snapshots_WithError.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/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..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, getWithJson, 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,12 +80,12 @@ 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> 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 }> } @@ -93,17 +93,23 @@ 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 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) => { @@ -191,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)), } 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