diff --git a/packages/api/src/api/graphql/resolvers/titres-activites.ts b/packages/api/src/api/graphql/resolvers/titres-activites.ts index a0edd0fd9bbdce7a09fd135ae75ed1a5420649ae..b09adfc6509701c9f15ce291a4198f0b5c2769a7 100644 --- a/packages/api/src/api/graphql/resolvers/titres-activites.ts +++ b/packages/api/src/api/graphql/resolvers/titres-activites.ts @@ -168,7 +168,7 @@ export const activiteDeposer = async ({ id }: { id: ActiviteId }, { user, pool } const activite = await callAndExit(getActiviteById(id, pool, user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires)) - const activitesDocuments = await getActiviteDocumentsByActiviteId(id, pool) + const activitesDocuments = await callAndExit(getActiviteDocumentsByActiviteId(id, pool)) const sectionsWithValue = getSectionsWithValue(activite.sections, activite.contenu) if (!(await isActiviteDeposable(user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, { ...activite, sections_with_value: sectionsWithValue }, activitesDocuments))) { throw new Error('droits insuffisants') diff --git a/packages/api/src/api/rest/activites.queries.ts b/packages/api/src/api/rest/activites.queries.ts index c3b29e96b199f2155fedb37132706400e37234e7..d03e594cf8426ff5874589e1c6d53e0992d1e48c 100644 --- a/packages/api/src/api/rest/activites.queries.ts +++ b/packages/api/src/api/rest/activites.queries.ts @@ -11,15 +11,18 @@ import { IInsertActiviteDocumentInternalQuery, IUpdateActiviteDbQuery, IActiviteDeleteDbQuery, + IGetActivitesSuperDbQuery, } from './activites.queries.types' import { ActiviteDocument, ActiviteDocumentId, ActiviteId, ActiviteIdOrSlug, + ActiviteSuper, activiteDocumentIdValidator, activiteDocumentValidator, activiteIdValidator, + activiteSuperValidator, activiteValidator, } from 'camino-common/src/activite' import { Pool } from 'pg' @@ -53,7 +56,7 @@ export const titreTypeIdByActiviteId = (activiteId: ActiviteIdOrSlug, pool: Pool ) const miseAJourActiviteInterdite = `Interdiction d'éditer une activité` as const -type UpdateActiviteQueryErrors = EffectDbQueryAndValidateErrors | typeof miseAJourActiviteInterdite +export type UpdateActiviteQueryErrors = EffectDbQueryAndValidateErrors | typeof miseAJourActiviteInterdite export const updateActiviteQuery = ( pool: Pool, user: User, @@ -119,7 +122,7 @@ const dbActiviteValidator = activiteValidator const activiteInterdite = `Lecture de l'activité impossible` as const const activiteIntrouvable = `Pas d'activité trouvée` as const -type GetActiviteByIdErrors = EffectDbQueryAndValidateErrors | typeof activiteInterdite | typeof activiteIntrouvable +export type GetActiviteByIdErrors = EffectDbQueryAndValidateErrors | typeof activiteInterdite | typeof activiteIntrouvable export type DbActivite = z.infer<typeof dbActiviteValidator> export const getActiviteById = ( activiteId: ActiviteIdOrSlug, @@ -204,8 +207,8 @@ delete from activites_documents where activite_id = $ activiteId ! ` -export const getActiviteDocumentsByActiviteId = async (activiteId: ActiviteId, pool: Pool): Promise<ActiviteDocument[]> => { - return dbQueryAndValidate( +export const getActiviteDocumentsByActiviteId = (activiteId: ActiviteId, pool: Pool): Effect.Effect<ActiviteDocument[], CaminoError<EffectDbQueryAndValidateErrors>> => + effectDbQueryAndValidate( getActiviteDocumentsInternal, { activiteId, @@ -213,7 +216,6 @@ export const getActiviteDocumentsByActiviteId = async (activiteId: ActiviteId, p pool, activiteDocumentValidator ) -} export const administrationsLocalesByActiviteId = async (activiteId: ActiviteIdOrSlug, pool: Pool): Promise<AdministrationId[]> => { const admins = await dbQueryAndValidate(getAdministrationsLocalesByActiviteId, { activiteId }, pool, administrationsLocalesValidator) @@ -285,26 +287,30 @@ where d.activite_id = $ activiteId ! ` -export const deleteActiviteDocument = async ( +const droitsInsuffisanstPourSupprimerLeDocument = "droits insuffisants pour supprimer un document d'activité" as const +export type DeleteActiviteDocumentErrors = EffectDbQueryAndValidateErrors | typeof droitsInsuffisanstPourSupprimerLeDocument +export const deleteActiviteDocument = ( id: ActiviteDocumentId, activiteDocumentTypeId: ActiviteDocumentTypeId, activiteTypeId: ActivitesTypesId, activiteStatutId: ActivitesStatutId, pool: Pool -): Promise<void[]> => { - if (!canDeleteActiviteDocument(activiteDocumentTypeId, activiteTypeId, activiteStatutId)) { - throw new Error('droits insuffisants') - } - - return dbQueryAndValidate(deleteActiviteDocumentQuery, { id }, pool, z.void()) -} +): Effect.Effect<true, CaminoError<DeleteActiviteDocumentErrors>> => + Effect.Do.pipe( + Effect.filterOrFail( + () => canDeleteActiviteDocument(activiteDocumentTypeId, activiteTypeId, activiteStatutId), + () => ({ message: droitsInsuffisanstPourSupprimerLeDocument }) + ), + Effect.flatMap(() => effectDbQueryAndValidate(deleteActiviteDocumentQuery, { id }, pool, z.void())), + Effect.map(() => true as const) + ) const deleteActiviteDocumentQuery = sql<Redefine<IDeleteActiviteDocumentQueryQuery, { id: ActiviteDocumentId }, void>>` delete from activites_documents where id = $ id ! ` -export const insertActiviteDocument = async ( +export const insertActiviteDocument = ( pool: Pool, params: { id: ActiviteDocumentId @@ -314,7 +320,8 @@ export const insertActiviteDocument = async ( description: string largeobject_id: number } -): Promise<{ id: ActiviteDocumentId }[]> => dbQueryAndValidate(insertActiviteDocumentInternal, params, pool, z.object({ id: activiteDocumentIdValidator })) +): Effect.Effect<{ id: ActiviteDocumentId }[], CaminoError<EffectDbQueryAndValidateErrors>> => + effectDbQueryAndValidate(insertActiviteDocumentInternal, params, pool, z.object({ id: activiteDocumentIdValidator })) const insertActiviteDocumentInternal = sql< Redefine< @@ -375,3 +382,21 @@ where d.id = $ activiteDocumentId ! LIMIT 1 ` + +export const getActivitesSuper = (pool: Pool): Effect.Effect<ActiviteSuper[], CaminoError<EffectDbQueryAndValidateErrors>> => + Effect.Do.pipe(Effect.flatMap(() => effectDbQueryAndValidate(getActivitesSuperDb, {}, pool, activiteSuperValidator))) + +const getActivitesSuperDb = sql<Redefine<IGetActivitesSuperDbQuery, {}, z.infer<typeof activiteSuperValidator>>>` +select + t.nom as titre_nom, + t.type_id as titre_type_id, + ta.id, + ta.annee, + ta.type_id, + ta.periode_id, + ta.activite_statut_id +from titres_activites ta +left join titres t on ta.titre_id = t.id +where ta.suppression is true +order by t.nom asc, ta.annee asc, ta.periode_id asc +` diff --git a/packages/api/src/api/rest/activites.queries.types.ts b/packages/api/src/api/rest/activites.queries.types.ts index d55d5236373fc9fc944c8edd4911cf8445fc28cf..5e6a94c7da58d77456bc9db9c80717b3a454b192 100644 --- a/packages/api/src/api/rest/activites.queries.types.ts +++ b/packages/api/src/api/rest/activites.queries.types.ts @@ -197,3 +197,23 @@ export interface IGetLargeobjectIdByActiviteDocumentIdInternalQuery { result: IGetLargeobjectIdByActiviteDocumentIdInternalResult; } +/** 'GetActivitesSuperDb' parameters type */ +export type IGetActivitesSuperDbParams = void; + +/** 'GetActivitesSuperDb' return type */ +export interface IGetActivitesSuperDbResult { + activite_statut_id: string; + annee: number; + id: string; + periode_id: number; + titre_nom: string; + titre_type_id: string; + type_id: string; +} + +/** 'GetActivitesSuperDb' query type */ +export interface IGetActivitesSuperDbQuery { + params: IGetActivitesSuperDbParams; + result: IGetActivitesSuperDbResult; +} + diff --git a/packages/api/src/api/rest/activites.test.integration.ts b/packages/api/src/api/rest/activites.test.integration.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdd8a4c92132bda50d394789eebbbab008bd4089 --- /dev/null +++ b/packages/api/src/api/rest/activites.test.integration.ts @@ -0,0 +1,150 @@ +/* eslint-disable sql/no-unsafe-query */ +import { restCall, restNewPutCall } from '../../../tests/_utils/index' +import { dbManager } from '../../../tests/db-manager' +import { test, describe, afterAll, beforeAll, vi, expect } from 'vitest' +import type { Pool } from 'pg' +import { toCaminoDate } from 'camino-common/src/date' +import { insertTitreGraph, ITitreInsert } from '../../../tests/integration-test-helper' +import { ETAPE_IS_NOT_BROUILLON } from 'camino-common/src/etape' +import { newTitreId, newDemarcheId, newEtapeId, idGenerate } from '../../database/models/_format/id-create' +import { TITRES_TYPES_IDS } from 'camino-common/src/static/titresTypes' +import { DEMARCHES_TYPES_IDS } from 'camino-common/src/static/demarchesTypes' +import { DemarchesStatutsIds } from 'camino-common/src/static/demarchesStatuts' +import { TitresStatutIds } from 'camino-common/src/static/titresStatuts' +import { Knex } from 'knex' +import { ACTIVITES_TYPES_IDS } from 'camino-common/src/static/activitesTypes' +import { ACTIVITES_STATUTS_IDS } from 'camino-common/src/static/activitesStatuts' +import { ETAPES_TYPES } from 'camino-common/src/static/etapesTypes' +import { ETAPES_STATUTS } from 'camino-common/src/static/etapesStatuts' +import { Activite, activiteDocumentIdValidator, activiteIdValidator } from 'camino-common/src/activite' +import { userSuper } from '../../database/user-super' +import { DOCUMENTS_TYPES_IDS } from 'camino-common/src/static/documentsTypes' +import { copyFileSync, mkdirSync } from 'node:fs' +import { tempDocumentNameValidator } from 'camino-common/src/document' +import { SectionWithValue } from 'camino-common/src/sections' + +const dir = `${process.cwd()}/files/tmp/` + +console.info = vi.fn() +console.error = vi.fn() +let dbPool: Pool +let knex: Knex +beforeAll(async () => { + const { knex: knexInstance, pool } = await dbManager.populateDb() + dbPool = pool + knex = knexInstance +}) + +afterAll(async () => { + await dbManager.closeKnex() +}) + +describe('updateActivite', () => { + test('met à jour une activité', async () => { + const titreId = newTitreId('titre-id') + const titre: ITitreInsert = { + id: titreId, + nom: 'mon titre', + typeId: TITRES_TYPES_IDS.AUTORISATION_DE_RECHERCHE_METAUX, + titreStatutId: TitresStatutIds.Valide, + publicLecture: true, + propsTitreEtapesIds: { points: 'titre-id-demarche-id-dpu' }, + demarches: [ + { + id: newDemarcheId('titre-id-demarche-id'), + titreId: titreId, + typeId: DEMARCHES_TYPES_IDS.Octroi, + statutId: DemarchesStatutsIds.Accepte, + publicLecture: true, + etapes: [ + { + id: newEtapeId('titre-id-demarche-id-dpu'), + typeId: ETAPES_TYPES.publicationDeDecisionAuJORF, + ordre: 0, + titreDemarcheId: newDemarcheId('titre-id-demarche-id'), + statutId: ETAPES_STATUTS.ACCEPTE, + date: toCaminoDate('2020-02-02'), + administrationsLocales: ['dea-guyane-01'], + isBrouillon: ETAPE_IS_NOT_BROUILLON, + }, + ], + }, + ], + } + + await insertTitreGraph(titre) + + const activiteId = activiteIdValidator.parse('activite1Id') + await knex.raw( + `INSERT INTO titres_activites ( id, titre_id, utilisateur_id, "date", date_saisie, contenu, type_id, activite_statut_id, annee, periode_id, sections, suppression, slug) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,?,?,?,?)`, + [ + activiteId, + titreId, + null, + toCaminoDate('2021-01-01'), + null, + null, + ACTIVITES_TYPES_IDS["rapport trimestriel d'exploitation d'or en Guyane"], + ACTIVITES_STATUTS_IDS.ABSENT, + 2023, + 1, + [JSON.stringify({ id: 'id', elements: [{ id: 'element', type: 'number', optionnel: true }] })], + false, + 'slug', + ] + ) + + const activite1DocumentId = activiteDocumentIdValidator.parse('activiteDocumentId1') + const activite2DocumentId = activiteDocumentIdValidator.parse('activiteDocumentId2') + await knex.raw('INSERT INTO activites_documents(id,activite_document_type_id,"date",activite_id,description,largeobject_id) VALUES (?, ?,?,?,?,?), (?, ?,?,?,?,?)', [ + activite1DocumentId, + DOCUMENTS_TYPES_IDS.rapportAnnuelDExploitation, + toCaminoDate('2021-01-01'), + activiteId, + '', + 54300, + activite2DocumentId, + DOCUMENTS_TYPES_IDS.rapportAnnuelDExploitation, + toCaminoDate('2023-01-01'), + activiteId, + '', + 54300, + ]) + + let tested = await restNewPutCall(dbPool, '/rest/activites/:activiteId', { activiteId: activiteId }, undefined, { sectionsWithValue: [], activiteDocumentIds: [], newTempDocuments: [] }) + + expect(tested.body).toMatchInlineSnapshot(` + { + "message": "Accès interdit", + "status": 403, + } + `) + const fileName = `existing_temp_file_${idGenerate()}` + mkdirSync(dir, { recursive: true }) + copyFileSync(`./src/tools/small.pdf`, `${dir}/${fileName}`) + + const sectionWithValue: SectionWithValue[] = [{ id: 'id', elements: [{ id: 'element', type: 'number', value: 12, optionnel: true }] }] + tested = await restNewPutCall(dbPool, '/rest/activites/:activiteId', { activiteId: activiteId }, userSuper, { + sectionsWithValue: sectionWithValue, + activiteDocumentIds: [activite1DocumentId], + newTempDocuments: [ + { activite_document_type_id: DOCUMENTS_TYPES_IDS.rapportEnvironnementalDExploration, description: 'Une jolie description', tempDocumentName: tempDocumentNameValidator.parse(fileName) }, + ], + }) + + expect(tested.body).toMatchInlineSnapshot(` + { + "id": "activite1Id", + } + `) + + tested = await restCall(dbPool, '/rest/activites/:activiteId', { activiteId }, userSuper) + const activite: Activite = tested.body + expect(activite.activite_statut_id).toStrictEqual(ACTIVITES_STATUTS_IDS.EN_CONSTRUCTION) + expect(activite.sections_with_value).toStrictEqual(sectionWithValue) + const newDocumentIds = activite.activite_documents.map(document => document.id) + expect(newDocumentIds).not.toContain(activite2DocumentId) + expect(newDocumentIds).toContain(activite1DocumentId) + expect(activite.activite_documents).toHaveLength(2) + }) +}) diff --git a/packages/api/src/api/rest/activites.ts b/packages/api/src/api/rest/activites.ts index 7010e6dbc25c727ec9c686e48af9a6203cbb75fd..b84b5b6137c8efd80a4141083a9a9f7acb6536ef 100644 --- a/packages/api/src/api/rest/activites.ts +++ b/packages/api/src/api/rest/activites.ts @@ -1,13 +1,14 @@ import { CaminoRequest, CustomResponse } from './express-type' import { HTTP_STATUS } from 'camino-common/src/http' import { Pool } from 'pg' -import { Activite, activiteDocumentIdValidator, activiteEditionValidator, activiteIdOrSlugValidator, activiteIdValidator } from 'camino-common/src/activite' +import { Activite, activiteDocumentIdValidator, ActiviteId, activiteIdOrSlugValidator, activiteIdValidator, ActiviteSuper } from 'camino-common/src/activite' import { Contenu, administrationsLocalesByActiviteId, deleteActiviteDocument, entreprisesTitulairesOuAmoditairesByActiviteId, getActiviteById, + GetActiviteByIdErrors, getActiviteDocumentsByActiviteId, getLargeobjectIdByActiviteDocumentId, insertActiviteDocument, @@ -15,22 +16,29 @@ import { updateActiviteQuery, DbActivite, activiteDeleteQuery, + UpdateActiviteQueryErrors, + DeleteActiviteDocumentErrors, + getActivitesSuper, } from './activites.queries' import { NewDownload } from './fichiers' -import { SimplePromiseFn, isNonEmptyArray, isNullOrUndefined, memoize } from 'camino-common/src/typescript-tools' +import { SimplePromiseFn, isNonEmptyArray, memoize } from 'camino-common/src/typescript-tools' import { canEditActivite, isActiviteDeposable } from 'camino-common/src/permissions/activites' import { SectionWithValue } from 'camino-common/src/sections' import { Section, getSectionsWithValue } from 'camino-common/src/static/titresTypes_demarchesTypes_etapesTypes/sections' import { newActiviteDocumentId } from '../../database/models/_format/id-create' import { ACTIVITES_STATUTS_IDS } from 'camino-common/src/static/activitesStatuts' import { Unites } from 'camino-common/src/static/unites' -import { User } from 'camino-common/src/roles' +import { isSuper, User } from 'camino-common/src/roles' import { TitreTypeId } from 'camino-common/src/static/titresTypes' import { AdministrationId } from 'camino-common/src/static/administrations' import { EntrepriseId } from 'camino-common/src/entreprise' import { getCurrent } from 'camino-common/src/date' -import { createLargeObject } from '../../database/largeobjects' +import { createLargeObject, CreateLargeObjectError } from '../../database/largeobjects' import { callAndExit } from '../../tools/fp-tools' +import { RestNewGetCall, RestNewPutCall } from '../../server/rest' +import { CaminoApiError } from '../../types' +import { Effect, Match, Option } from 'effect' +import { EffectDbQueryAndValidateErrors } from '../../pg-database' const extractContenuFromSectionWithValue = (sections: Section[], sectionsWithValue: SectionWithValue[]): Contenu => { const contenu: Contenu = {} @@ -58,74 +66,91 @@ const extractContenuFromSectionWithValue = (sections: Section[], sectionsWithVal return contenu } -export const updateActivite = - (pool: Pool) => - async (req: CaminoRequest, res: CustomResponse<void>): Promise<void> => { - const activiteIdParsed = activiteIdOrSlugValidator.safeParse(req.params.activiteId) - const user = req.auth - - if (!activiteIdParsed.success) { - res.sendStatus(HTTP_STATUS.BAD_REQUEST) - } else if (isNullOrUndefined(user)) { - res.sendStatus(HTTP_STATUS.BAD_REQUEST) - } else { - try { - const titreTypeId = memoize(() => callAndExit(titreTypeIdByActiviteId(activiteIdParsed.data, pool))) - const administrationsLocales = memoize(() => administrationsLocalesByActiviteId(activiteIdParsed.data, pool)) - const entreprisesTitulairesOuAmodiataires = memoize(() => entreprisesTitulairesOuAmoditairesByActiviteId(activiteIdParsed.data, pool)) - - const result = await callAndExit(getActiviteById(activiteIdParsed.data, pool, user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires)) +const canEditActiviteError = "Impossible de vérifier si on peut éditer l'activité" as const +const editionDeLActiviteImpossible = "Droit insuffisants pour éditer l'activité" as const +type UpdateActiviteErrors = + | GetActiviteByIdErrors + | typeof canEditActiviteError + | typeof editionDeLActiviteImpossible + | UpdateActiviteQueryErrors + | DeleteActiviteDocumentErrors + | CreateLargeObjectError +export const updateActivite: RestNewPutCall<'/rest/activites/:activiteId'> = (rootPipe): Effect.Effect<{ id: ActiviteId }, CaminoApiError<UpdateActiviteErrors>> => + rootPipe.pipe( + Effect.let('titreTypeId', ({ params, pool }) => memoize(() => callAndExit(titreTypeIdByActiviteId(params.activiteId, pool)))), + Effect.let('administrationsLocales', ({ params, pool }) => memoize(() => administrationsLocalesByActiviteId(params.activiteId, pool))), + Effect.let('entreprisesTitulairesOuAmodiataires', ({ params, pool }) => memoize(() => entreprisesTitulairesOuAmoditairesByActiviteId(params.activiteId, pool))), + Effect.bind('result', ({ params, pool, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, user }) => + getActiviteById(params.activiteId, pool, user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires) + ), + Effect.tap(({ user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, result }) => + Effect.tryPromise({ + try: () => canEditActivite(user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, result.activite_statut_id), + catch: e => ({ message: canEditActiviteError, extra: e }), + }).pipe( + Effect.filterOrFail( + canRead => canRead, + () => ({ message: editionDeLActiviteImpossible }) + ) + ) + ), + Effect.let('contenu', ({ result, body }) => extractContenuFromSectionWithValue(result.sections, body.sectionsWithValue)), + Effect.tap(({ pool, user, result, contenu, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires }) => + updateActiviteQuery(pool, user, result.id, contenu, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires) + ), + Effect.bind('oldActiviteDocuments', ({ pool, result }) => getActiviteDocumentsByActiviteId(result.id, pool)), + Effect.tap(({ body, oldActiviteDocuments, pool, result }) => { + const alreadyExistingDocumentIds = body.activiteDocumentIds - if (!(await canEditActivite(user, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires, result.activite_statut_id))) { - res.sendStatus(HTTP_STATUS.FORBIDDEN) - } else { - const parsed = activiteEditionValidator.safeParse(req.body) - - if (!parsed.success) { - res.sendStatus(HTTP_STATUS.BAD_REQUEST) - } else { - const contenu = extractContenuFromSectionWithValue(result.sections, parsed.data.sectionsWithValue) - await callAndExit(updateActiviteQuery(pool, user, result.id, contenu, titreTypeId, administrationsLocales, entreprisesTitulairesOuAmodiataires)) - - const activiteDocumentsToCreate = parsed.data.newTempDocuments - const alreadyExistingDocumentIds = parsed.data.activiteDocumentIds - const oldActiviteDocuments = await getActiviteDocumentsByActiviteId(result.id, pool) - - if (isNonEmptyArray(oldActiviteDocuments)) { - // supprime les anciens documents ou ceux qui n'ont pas de fichier - for (const oldActiviteDocument of oldActiviteDocuments) { - const documentId = alreadyExistingDocumentIds.find(id => id === oldActiviteDocument.id) - - if (!documentId) { - await deleteActiviteDocument(oldActiviteDocument.id, oldActiviteDocument.activite_document_type_id, result.type_id, ACTIVITES_STATUTS_IDS.EN_CONSTRUCTION, pool) - } - } - } - - for (const document of activiteDocumentsToCreate) { - const loid = await callAndExit(createLargeObject(pool, document.tempDocumentName)) - - const date = getCurrent() - - await insertActiviteDocument(pool, { - id: newActiviteDocumentId(date, document.activite_document_type_id), - activite_document_type_id: document.activite_document_type_id, - description: document.description ?? '', - date, - largeobject_id: loid, - activite_id: result.id, - }) - } - - res.sendStatus(HTTP_STATUS.NO_CONTENT) + if (isNonEmptyArray(oldActiviteDocuments)) { + return Effect.forEach(oldActiviteDocuments, oldActiviteDocument => { + const documentId = alreadyExistingDocumentIds.find(id => id === oldActiviteDocument.id) + if (!documentId) { + return deleteActiviteDocument(oldActiviteDocument.id, oldActiviteDocument.activite_document_type_id, result.type_id, ACTIVITES_STATUTS_IDS.EN_CONSTRUCTION, pool) } - } - } catch (e: any) { - console.error(e) - res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR) + return Effect.succeed(true) + }) } - } - } + return Effect.succeed(Option.none) + }), + Effect.tap(({ body, pool, result }) => + Effect.forEach(body.newTempDocuments, document => { + return Effect.Do.pipe( + Effect.flatMap(() => createLargeObject(pool, document.tempDocumentName)), + Effect.flatMap(loid => { + const date = getCurrent() + return insertActiviteDocument(pool, { + id: newActiviteDocumentId(date, document.activite_document_type_id), + activite_document_type_id: document.activite_document_type_id, + description: document.description ?? '', + date, + largeobject_id: loid, + activite_id: result.id, + }) + }) + ) + }) + ), + Effect.map(({ result }) => ({ id: result.id })), + Effect.mapError(caminoError => + Match.value(caminoError.message).pipe( + Match.when("Pas d'activité trouvée", () => ({ ...caminoError, status: HTTP_STATUS.NOT_FOUND })), + Match.whenOr("Droit insuffisants pour éditer l'activité", "Interdiction d'éditer une activité", "droits insuffisants pour supprimer un document d'activité", () => ({ + ...caminoError, + status: HTTP_STATUS.FORBIDDEN, + })), + Match.whenOr( + "Impossible d'exécuter la requête dans la base de données", + "Impossible de vérifier si on peut éditer l'activité", + "Lecture de l'activité impossible", + 'Les données en base ne correspondent pas à ce qui est attendu', + "impossible d'insérer un fichier en base", + () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR }) + ), + Match.exhaustive + ) + ) + ) const formatActivite = async ( dbActivite: DbActivite, @@ -137,7 +162,7 @@ const formatActivite = async ( ): Promise<Activite> => { const sectionsWithValue: SectionWithValue[] = getSectionsWithValue(dbActivite.sections, dbActivite.contenu) - const activiteDocuments = await getActiviteDocumentsByActiviteId(dbActivite.id, pool) + const activiteDocuments = await callAndExit(getActiviteDocumentsByActiviteId(dbActivite.id, pool)) const deposable = await isActiviteDeposable( user, titreTypeId, @@ -220,3 +245,28 @@ export const activiteDocumentDownload: NewDownload = async (params, user, pool) return { loid: activiteDocumentLargeObjectId, fileName: activiteDocumentId } } + +const permissionsInsuffisantes = 'Permissions insuffisantes' as const +type GetActivitesSuperErrors = EffectDbQueryAndValidateErrors | typeof permissionsInsuffisantes + +export const getActivitesForTDBSuper: RestNewGetCall<'/rest/activitesSuper'> = (rootPipe): Effect.Effect<ActiviteSuper[], CaminoApiError<GetActivitesSuperErrors>> => + rootPipe.pipe( + Effect.filterOrFail( + ({ user }) => isSuper(user), + () => ({ message: permissionsInsuffisantes }) + ), + Effect.flatMap(({ pool }) => getActivitesSuper(pool)), + Effect.mapError(caminoError => + Match.value(caminoError.message).pipe( + Match.whenOr('Permissions insuffisantes', () => ({ + ...caminoError, + status: HTTP_STATUS.FORBIDDEN, + })), + Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', () => ({ + ...caminoError, + status: HTTP_STATUS.INTERNAL_SERVER_ERROR, + })), + Match.exhaustive + ) + ) + ) diff --git a/packages/api/src/api/rest/etapes.test.integration.ts b/packages/api/src/api/rest/etapes.test.integration.ts index f68ea39b823b658d722459346fc485d530bd1936..2ada5d99e31c27e788730f40501a5c288c4b5fd7 100644 --- a/packages/api/src/api/rest/etapes.test.integration.ts +++ b/packages/api/src/api/rest/etapes.test.integration.ts @@ -438,9 +438,7 @@ describe('getEtapeAvis', () => { await expect(callAndExit(insertEtapeAvisWithLargeObjectId(dbPool, etapeId, { ...avis, description: '' }, etapeAvisIdValidator.parse('avisId'), largeObjectIdValidator.parse(42)))).rejects .toThrowErrorMatchingInlineSnapshot(` [Error: Impossible d'exécuter la requête dans la base de données - extra: new row for relation "etape_avis" violates check constraint "etape_avis_description_required" - detail: undefined - zod: undefined] + extra: new row for relation "etape_avis" violates check constraint "etape_avis_description_required"] `) await callAndExit(insertEtapeAvisWithLargeObjectId(dbPool, etapeId, avis, etapeAvisIdValidator.parse('avisId'), largeObjectIdValidator.parse(42))) diff --git a/packages/api/src/api/rest/utilisateurs.test.integration.ts b/packages/api/src/api/rest/utilisateurs.test.integration.ts index ff81c9bc5c1c0388463a9f4314b651b00d1fe3dc..b48ba5cf9bdf663cac9a51a1bbea2f42277452e6 100644 --- a/packages/api/src/api/rest/utilisateurs.test.integration.ts +++ b/packages/api/src/api/rest/utilisateurs.test.integration.ts @@ -1,4 +1,4 @@ -import { restCall, restNewCall, restNewPostCall, restPostCall, userGenerate } from '../../../tests/_utils/index' +import { restNewCall, restNewPostCall, userGenerate } from '../../../tests/_utils/index' import { dbManager } from '../../../tests/db-manager' import { Knex } from 'knex' import { expect, test, describe, afterAll, beforeAll, vi, beforeEach } from 'vitest' @@ -67,9 +67,14 @@ describe('utilisateurModifier', () => { entrepriseIds: [], administrationId: null, } - const tested = await restPostCall(dbPool, '/rest/utilisateurs/:id/permission', { id: utilisateurToEdit.id }, undefined, utilisateurToEdit) + const tested = await restNewPostCall(dbPool, '/rest/utilisateurs/:id/permission', { id: utilisateurToEdit.id }, undefined, utilisateurToEdit) - expect(tested.statusCode).toBe(403) + expect(tested.body).toMatchInlineSnapshot(` + { + "message": "Accès interdit", + "status": 403, + } + `) }) test("peut modifier le rôle d'un compte utilisateur", async () => { @@ -81,7 +86,7 @@ describe('utilisateurModifier', () => { entrepriseIds: [], administrationId: 'aut-97300-01', } - const tested = await restPostCall( + const tested = await restNewPostCall( dbPool, '/rest/utilisateurs/:id/permission', { id: userToEdit.id }, @@ -91,17 +96,21 @@ describe('utilisateurModifier', () => { utilisateurToEdit ) - expect(tested.statusCode).toBe(204) + expect(tested.body).toMatchInlineSnapshot(` + { + "id": "defaut-user", + } + `) }) }) describe('utilisateurSupprimer', () => { test('ne peut pas supprimer un compte (utilisateur anonyme)', async () => { - const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('test') }, undefined) - expect(tested.statusCode).toBe(500) + const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('test') }, undefined) expect(tested.body).toMatchInlineSnapshot(` { - "error": "droits insuffisants", + "message": "Droits insuffisants pour supprimer l'utilisateur", + "status": 403, } `) }) @@ -112,7 +121,7 @@ describe('utilisateurSupprimer', () => { renewConfig() const user = await userGenerate(dbPool, { role: 'defaut' }) - const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id: user.id }, { role: 'defaut' }) + const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id: user.id }, { role: 'defaut' }) expect(tested.statusCode).toBe(302) expect(tested.header.location).toBe(`${OAUTH_URL}/oauth2/sign_out`) }) @@ -129,16 +138,20 @@ describe('utilisateurSupprimer', () => { keycloakId: idUserKeycloakRecognised, }) - const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id }, { role: 'super' }) - expect(tested.statusCode).toBe(204) + const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id }, { role: 'super' }) + expect(tested.body).toMatchInlineSnapshot(` + { + "id": "user-todelete", + } + `) }) test('ne peut pas supprimer un utilisateur inexistant (utilisateur super)', async () => { - const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('not-existing') }, { role: 'super' }) - expect(tested.statusCode).toBe(500) + const tested = await restNewCall(dbPool, '/rest/utilisateurs/:id/delete', { id: utilisateurIdValidator.parse('not-existing') }, { role: 'super' }) expect(tested.body).toMatchInlineSnapshot(` { - "error": "aucun utilisateur avec cet id ou droits insuffisants pour voir cet utilisateur", + "message": "Impossible de trouver l'utilisateur", + "status": 400, } `) }) diff --git a/packages/api/src/api/rest/utilisateurs.ts b/packages/api/src/api/rest/utilisateurs.ts index d6a30267b6c96c90f516618969e626a89248529e..e0b8d5e360aa854d2b7e71963f69b5adfc9b5c98 100644 --- a/packages/api/src/api/rest/utilisateurs.ts +++ b/packages/api/src/api/rest/utilisateurs.ts @@ -1,13 +1,12 @@ -import { CaminoRequest, CustomResponse } from './express-type' import { CaminoApiError } from '../../types' import { HTTP_STATUS } from 'camino-common/src/http' -import { User, UserNotNull, utilisateurIdValidator } from 'camino-common/src/roles' +import { User, UserNotNull, UtilisateurId } from 'camino-common/src/roles' import { utilisateursFormatTable } from './format/utilisateurs' import { tableConvert } from './_convert' import { fileNameCreate } from '../../tools/file-name-create' -import { QGISTokenRest, qgisTokenValidator, utilisateursSearchParamsValidator, UtilisateursTable, utilisateurToEdit } from 'camino-common/src/utilisateur' +import { QGISTokenRest, qgisTokenValidator, utilisateursSearchParamsValidator, UtilisateursTable } from 'camino-common/src/utilisateur' import { idGenerate } from '../../database/models/_format/id-create' -import { utilisateurUpdationValidate } from '../../business/validations/utilisateur-updation-validate' +import { utilisateurUpdationValidate, UtilisateurUpdationValidateErrors } from '../../business/validations/utilisateur-updation-validate' import { canDeleteUtilisateur } from 'camino-common/src/permissions/utilisateurs' import { Pool } from 'pg' import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools' @@ -16,50 +15,51 @@ import { Effect, Match } from 'effect' import { RestNewGetCall, RestNewPostCall } from '../../server/rest' import { getKeycloakIdByUserId, - getUtilisateurById, + GetKeycloakIdByUserIdErrors, GetUtilisateurByIdErrors, getUtilisateursFilteredAndSorted, + GetUtilisateursFilteredAndSortedErrors, newGetUtilisateurById, softDeleteUtilisateur, updateUtilisateurRole, } from '../../database/queries/utilisateurs.queries' import { EffectDbQueryAndValidateErrors } from '../../pg-database' -import { callAndExit, shortCircuitError, zodParseEffect, ZodUnparseable } from '../../tools/fp-tools' +import { callAndExit, shortCircuitError, zodParseEffectTyped } from '../../tools/fp-tools' import { z } from 'zod' import { getEntreprises } from './entreprises.queries' import { fetch } from 'undici' import { updateQgisToken } from './utilisateurs.queries' -export const updateUtilisateurPermission = - (pool: Pool) => - async (req: CaminoRequest, res: CustomResponse<void>): Promise<void> => { - const user = req.auth - - if (!req.params.id) { - res.sendStatus(HTTP_STATUS.FORBIDDEN) - } else { - const userId = utilisateurIdValidator.parse(req.params.id) - const utilisateurOld = await getUtilisateurById(pool, userId, user) - - if (!user || !utilisateurOld) { - res.sendStatus(HTTP_STATUS.FORBIDDEN) - } else { - try { - const utilisateur = utilisateurToEdit.parse(req.body) - - utilisateurUpdationValidate(user, utilisateur, utilisateurOld) - - await updateUtilisateurRole(pool, utilisateur) - - res.sendStatus(HTTP_STATUS.NO_CONTENT) - } catch (e) { - console.error(e) - - res.sendStatus(HTTP_STATUS.INTERNAL_SERVER_ERROR) - } - } - } - } +type UpdateUtilisateurPermissionErrors = EffectDbQueryAndValidateErrors | GetUtilisateurByIdErrors | UtilisateurUpdationValidateErrors +export const updateUtilisateurPermission: RestNewPostCall<'/rest/utilisateurs/:id/permission'> = (rootPipe): Effect.Effect<{ id: UtilisateurId }, CaminoApiError<UpdateUtilisateurPermissionErrors>> => + rootPipe.pipe( + Effect.bind('utilisateurOld', ({ params, pool, user }) => newGetUtilisateurById(pool, params.id, user)), + Effect.tap(({ user, body, utilisateurOld }) => utilisateurUpdationValidate(user, body, utilisateurOld)), + Effect.tap(({ body, pool }) => updateUtilisateurRole(pool, body)), + Effect.map(({ params }) => ({ id: params.id })), + Effect.mapError(caminoError => + Match.value(caminoError.message).pipe( + Match.when("L'utilisateur est invalide", () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })), + Match.whenOr( + 'droits insuffisants', + "Impossible d'exécuter la requête dans la base de données", + 'Les données en base ne correspondent pas à ce qui est attendu', + 'utilisateur incorrect', + "l'utilisateur n'existe pas", + 'droits insuffisants', + 'impossible de modifier son propre rôle', + 'droits insuffisants pour modifier les rôles', + 'droits insuffisants pour modifier les administrations', + 'droits insuffisants pour modifier les entreprises', + () => ({ + ...caminoError, + status: HTTP_STATUS.BAD_REQUEST, + }) + ), + Match.exhaustive + ) + ) + ) export type KeycloakAccessTokenResponse = { access_token: string } @@ -96,54 +96,58 @@ const getKeycloakApiToken = async (): Promise<string> => { } } -export const deleteUtilisateur = - (pool: Pool) => - async (req: CaminoRequest, res: CustomResponse<void>): Promise<void> => { - const user = req.auth - - if (!req.params.id) { - res.sendStatus(HTTP_STATUS.FORBIDDEN) - } else { - try { - const utilisateurId = utilisateurIdValidator.parse(req.params.id) - - if (!canDeleteUtilisateur(user, utilisateurId)) { - throw new Error('droits insuffisants') - } - - const utilisateurKeycloakId = await getKeycloakIdByUserId(pool, utilisateurId) - if (isNullOrUndefined(utilisateurKeycloakId)) { - throw new Error('aucun utilisateur avec cet id ou droits insuffisants pour voir cet utilisateur') - } - - const authorizationToken = await getKeycloakApiToken() - - const deleteFromKeycloak = await fetch(`${config().KEYCLOAK_URL}/admin/realms/Camino/users/${utilisateurKeycloakId}`, { - method: 'DELETE', - headers: { - authorization: `Bearer ${authorizationToken}`, - }, - }) - if (!deleteFromKeycloak.ok) { - throw new Error(`une erreur est apparue durant la suppression de l'utilisateur sur keycloak`) - } - - await softDeleteUtilisateur(pool, utilisateurId) +const droitsInsuffisantsPourSupprimerLUtilisateur = "Droits insuffisants pour supprimer l'utilisateur" as const +const erreurLorsDeLaSuppressionDeLUtilisateur = "Erreur lors de la suppression de l'utilisateur" as const - if (isNotNullNorUndefined(user) && user.id === req.params.id) { - const uiUrl = config().OAUTH_URL - const oauthLogoutUrl = new URL(`${uiUrl}/oauth2/sign_out`) - res.redirect(oauthLogoutUrl.href) - } else { - res.sendStatus(HTTP_STATUS.NO_CONTENT) - } - } catch (e: any) { - console.error(e) +type DeleteUtilisateurErrors = GetKeycloakIdByUserIdErrors | typeof droitsInsuffisantsPourSupprimerLUtilisateur | typeof erreurLorsDeLaSuppressionDeLUtilisateur +export const deleteUtilisateur: RestNewGetCall<'/rest/utilisateurs/:id/delete'> = (rootPipe): Effect.Effect<{ id: UtilisateurId }, CaminoApiError<DeleteUtilisateurErrors>> => + rootPipe.pipe( + Effect.let('utilisateurId', ({ params }) => params.id), + Effect.filterOrFail( + ({ user, utilisateurId }) => canDeleteUtilisateur(user, utilisateurId), + () => ({ message: droitsInsuffisantsPourSupprimerLUtilisateur }) + ), + Effect.bind('keycloakId', ({ pool, utilisateurId }) => getKeycloakIdByUserId(pool, utilisateurId)), + Effect.tap(({ keycloakId }) => + Effect.tryPromise({ + try: async () => { + const authorizationToken = await getKeycloakApiToken() + + const deleteFromKeycloak = await fetch(`${config().KEYCLOAK_URL}/admin/realms/Camino/users/${keycloakId}`, { + method: 'DELETE', + headers: { + authorization: `Bearer ${authorizationToken}`, + }, + }) + if (!deleteFromKeycloak.ok) { + throw new Error(`une erreur est apparue durant la suppression de l'utilisateur sur keycloak`) + } + }, + catch: e => ({ message: erreurLorsDeLaSuppressionDeLUtilisateur, extra: e }), + }) + ), + Effect.tap(({ pool, utilisateurId }) => softDeleteUtilisateur(pool, utilisateurId)), - res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: e.message ?? `Une erreur s'est produite` }) + Effect.tap(({ utilisateurId, redirect, user }) => { + if (isNotNullNorUndefined(user) && user.id === utilisateurId) { + const uiUrl = config().OAUTH_URL + const oauthLogoutUrl = new URL(`${uiUrl}/oauth2/sign_out`) + redirect(oauthLogoutUrl.href) } - } - } + }), + Effect.map(({ utilisateurId }) => ({ id: utilisateurId })), + Effect.mapError(caminoError => + Match.value(caminoError.message).pipe( + Match.when("Droits insuffisants pour supprimer l'utilisateur", () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })), + Match.when("Erreur lors de la suppression de l'utilisateur", () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })), + Match.whenOr("Impossible de trouver l'utilisateur", "Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', () => ({ + ...caminoError, + status: HTTP_STATUS.BAD_REQUEST, + })), + Match.exhaustive + ) + ) + ) export const moi: RestNewGetCall<'/moi'> = (rootPipe): Effect.Effect<User, CaminoApiError<GetUtilisateurByIdErrors>> => { return rootPipe.pipe( @@ -160,7 +164,7 @@ export const moi: RestNewGetCall<'/moi'> = (rootPipe): Effect.Effect<User, Camin Match.value(caminoError.message).pipe( Match.when('droits insuffisants', () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })), Match.when('Les données en base ne correspondent pas à ce qui est attendu', () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })), - Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Problème de validation de données', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })), + Match.whenOr("Impossible d'exécuter la requête dans la base de données", "L'utilisateur est invalide", () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })), Match.exhaustive ) @@ -168,14 +172,17 @@ export const moi: RestNewGetCall<'/moi'> = (rootPipe): Effect.Effect<User, Camin ) } -export const generateQgisToken: RestNewPostCall<'/rest/utilisateur/generateQgisToken'> = (rootPipe): Effect.Effect<QGISTokenRest, CaminoApiError<EffectDbQueryAndValidateErrors | ZodUnparseable>> => +const impossibleDeGenererUnTokenQgis = 'impossible de générer un token Qgis' as const +export const generateQgisToken: RestNewPostCall<'/rest/utilisateur/generateQgisToken'> = ( + rootPipe +): Effect.Effect<QGISTokenRest, CaminoApiError<EffectDbQueryAndValidateErrors | typeof impossibleDeGenererUnTokenQgis>> => rootPipe.pipe( - Effect.bind('token', () => zodParseEffect(qgisTokenValidator, idGenerate())), + Effect.bind('token', () => zodParseEffectTyped(qgisTokenValidator, idGenerate(), impossibleDeGenererUnTokenQgis)), Effect.tap(({ token, pool, user }) => updateQgisToken(pool, user, token)), Effect.map(({ token, user }) => ({ token, url: `https://${user.email.replace('@', '%40')}:${token}@${config().API_HOST}/titres_qgis?` })), Effect.mapError(caminoError => Match.value(caminoError.message).pipe( - Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Problème de validation de données', 'Les données en base ne correspondent pas à ce qui est attendu', () => ({ + Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', 'impossible de générer un token Qgis', () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR, })), @@ -211,7 +218,7 @@ export const utilisateurs = : null } -type GetUtilisateursError = EffectDbQueryAndValidateErrors | ZodUnparseable | "Impossible d'accéder à la liste des utilisateurs" | 'droits insuffisants' +type GetUtilisateursError = GetUtilisateursFilteredAndSortedErrors export const getUtilisateurs: RestNewGetCall<'/rest/utilisateurs'> = (rootPipe): Effect.Effect<UtilisateursTable, CaminoApiError<GetUtilisateursError>> => { return rootPipe.pipe( @@ -229,14 +236,14 @@ export const getUtilisateurs: RestNewGetCall<'/rest/utilisateurs'> = (rootPipe): status: HTTP_STATUS.INTERNAL_SERVER_ERROR, })), Match.when('droits insuffisants', () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })), - Match.when('Problème de validation de données', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })), + Match.when("L'utilisateur est invalide", () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })), Match.exhaustive ) ) ) } -type GetUtilisateurError = EffectDbQueryAndValidateErrors | ZodUnparseable | 'droits insuffisants' +type GetUtilisateurError = GetUtilisateurByIdErrors | 'droits insuffisants' export const getUtilisateur: RestNewGetCall<'/rest/utilisateurs/:id'> = (rootPipe): Effect.Effect<UserNotNull, CaminoApiError<GetUtilisateurError>> => { return rootPipe.pipe( Effect.flatMap(({ pool, params, user }) => newGetUtilisateurById(pool, params.id, user)), @@ -247,7 +254,7 @@ export const getUtilisateur: RestNewGetCall<'/rest/utilisateurs/:id'> = (rootPip status: HTTP_STATUS.INTERNAL_SERVER_ERROR, })), Match.when('droits insuffisants', () => ({ ...caminoError, status: HTTP_STATUS.FORBIDDEN })), - Match.when('Problème de validation de données', () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })), + Match.when("L'utilisateur est invalide", () => ({ ...caminoError, status: HTTP_STATUS.BAD_REQUEST })), Match.exhaustive ) ) diff --git a/packages/api/src/business/processes/titres-etapes-consentement.test.integration.ts b/packages/api/src/business/processes/titres-etapes-consentement.test.integration.ts index 4cf90bbb790b5b38c4afb6030b1c2cd6e14921c4..85475834d8ef4ae182bbeabb6e0c8f025fc49eb2 100644 --- a/packages/api/src/business/processes/titres-etapes-consentement.test.integration.ts +++ b/packages/api/src/business/processes/titres-etapes-consentement.test.integration.ts @@ -34,9 +34,7 @@ describe('etapeConsentementUpdate', () => { const etapeId = newEtapeId() await expect(callAndExit(etapeConsentementUpdate(dbPool, etapeId))).rejects.toThrowErrorMatchingInlineSnapshot(` [Error: Élément non trouvé dans la base de données - extra: [object Object] - detail: undefined - zod: undefined] + extra: [object Object]] `) }) diff --git a/packages/api/src/business/validations/utilisateur-updation-validate.test.ts b/packages/api/src/business/validations/utilisateur-updation-validate.test.ts index 2cfad9d23f771c596e6e0043544b7b02ef529304..508b629c83189c45679a83c25fe494642e32b4e1 100644 --- a/packages/api/src/business/validations/utilisateur-updation-validate.test.ts +++ b/packages/api/src/business/validations/utilisateur-updation-validate.test.ts @@ -2,9 +2,10 @@ import { newEntrepriseId } from 'camino-common/src/entreprise' import { Role, UserNotNull } from 'camino-common/src/roles' import { AdministrationId } from 'camino-common/src/static/administrations' import { testBlankUser } from 'camino-common/src/tests-utils' -import { test, expect } from 'vitest' +import { test, expect, vi } from 'vitest' import { utilisateurUpdationValidate } from './utilisateur-updation-validate' import { newUtilisateurId } from '../../database/models/_format/id-create' +import { callAndExit } from '../../tools/fp-tools' const users: Record<Role, UserNotNull> = { super: { ...testBlankUser, role: 'super' }, @@ -30,124 +31,161 @@ const users: Record<Role, UserNotNull> = { const fakeAdministrationId = 'fakeAdminId' as AdministrationId -test('utilisateurUpdationValidate privilege escalation forbidden', () => { - expect(() => utilisateurUpdationValidate(users.defaut, { ...users.defaut, role: 'super', administrationId: null, entrepriseIds: [] }, users.defaut)).toThrowErrorMatchingInlineSnapshot( - `[Error: droits insuffisants]` - ) - expect(() => utilisateurUpdationValidate(users.admin, { ...users.admin, role: 'super', entrepriseIds: [], administrationId: null }, users.admin)).toThrowErrorMatchingInlineSnapshot( - `[Error: droits insuffisants]` - ) - expect(() => utilisateurUpdationValidate(users.lecteur, { ...users.lecteur, role: 'super', entrepriseIds: [], administrationId: null }, users.lecteur)).toThrowErrorMatchingInlineSnapshot( - `[Error: droits insuffisants]` - ) - expect(() => utilisateurUpdationValidate(users.editeur, { ...users.editeur, role: 'super', entrepriseIds: [], administrationId: null }, users.editeur)).toThrowErrorMatchingInlineSnapshot( - `[Error: droits insuffisants]` - ) - expect(() => utilisateurUpdationValidate(users.entreprise, { ...users.entreprise, role: 'super', entrepriseIds: [], administrationId: null }, users.entreprise)).toThrowErrorMatchingInlineSnapshot( - `[Error: droits insuffisants]` - ) - expect(() => - utilisateurUpdationValidate(users["bureau d'études"], { ...users["bureau d'études"], role: 'super', entrepriseIds: [], administrationId: null }, users.entreprise) - ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) +console.error = vi.fn() +test('utilisateurUpdationValidate privilege escalation forbidden', async () => { + await expect( + callAndExit(utilisateurUpdationValidate(users.defaut, { ...users.defaut, role: 'super', administrationId: null, entrepriseIds: [] }, users.defaut)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.admin, { ...users.admin, role: 'super', entrepriseIds: [], administrationId: null }, users.admin)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.lecteur, { ...users.lecteur, role: 'super', entrepriseIds: [], administrationId: null }, users.lecteur)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.editeur, { ...users.editeur, role: 'super', entrepriseIds: [], administrationId: null }, users.editeur)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.entreprise, { ...users.entreprise, role: 'super', entrepriseIds: [], administrationId: null }, users.entreprise)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit(utilisateurUpdationValidate(users["bureau d'études"], { ...users["bureau d'études"], role: 'super', entrepriseIds: [], administrationId: null }, users.entreprise)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) - expect(() => - utilisateurUpdationValidate(users.editeur, { ...users.editeur, role: 'entreprise', administrationId: null, entrepriseIds: [newEntrepriseId('id')] }, users.editeur) - ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) - expect(() => - utilisateurUpdationValidate(users.defaut, { ...users.defaut, role: 'entreprise', administrationId: null, entrepriseIds: [newEntrepriseId('id')] }, users.defaut) - ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.editeur, { ...users.editeur, role: 'entreprise', administrationId: null, entrepriseIds: [newEntrepriseId('id')] }, users.editeur)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.defaut, { ...users.defaut, role: 'entreprise', administrationId: null, entrepriseIds: [newEntrepriseId('id')] }, users.defaut)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) }) -test('utilisateurUpdationValidate incorrect users throw error', () => { - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', administrationId: null, entrepriseIds: [] }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`) +test('utilisateurUpdationValidate incorrect users throw error', async () => { + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', administrationId: null, entrepriseIds: [] }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', administrationId: 'aut-97300-01', entrepriseIds: [] }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', administrationId: 'aut-97300-01', entrepriseIds: [] }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', administrationId: null, entrepriseIds: [] }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', administrationId: 'aut-97300-01', entrepriseIds: [] }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', administrationId: null, entrepriseIds: [] }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`) + await expect( + callAndExit( + utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined) + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'defaut', administrationId: 'aut-97300-01', entrepriseIds: [] }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: null, entrepriseIds: [] }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: fakeAdministrationId, entrepriseIds: [] }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: null, entrepriseIds: [] }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', entrepriseIds: [newEntrepriseId('entrepriseId')], administrationId: null }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: fakeAdministrationId, entrepriseIds: [] }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: null, entrepriseIds: [] }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: null, entrepriseIds: [] }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate( - users.super, - { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: fakeAdministrationId, entrepriseIds: [newEntrepriseId('entrepriseId')] }, - undefined + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: null, entrepriseIds: [] }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: null, entrepriseIds: [] }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + await expect( + callAndExit( + utilisateurUpdationValidate( + users.super, + { id: newUtilisateurId('utilisateurId'), role: 'entreprise', administrationId: fakeAdministrationId, entrepriseIds: [newEntrepriseId('entrepriseId')] }, + undefined + ) ) - ).toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: utilisateur incorrect]`) - expect(() => - utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', entrepriseIds: [], administrationId: null }, undefined) - ).toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.super, { id: newUtilisateurId('utilisateurId'), role: 'super', entrepriseIds: [], administrationId: null }, undefined)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: l'utilisateur n'existe pas]`) - expect(() => - utilisateurUpdationValidate( - users.admin, - { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-97300-01', entrepriseIds: [] }, - { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'admin', administrationId: 'aut-97300-01' } + await expect( + callAndExit( + utilisateurUpdationValidate( + users.admin, + { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-97300-01', entrepriseIds: [] }, + { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'admin', administrationId: 'aut-97300-01' } + ) ) - ).not.toThrowError() - expect(() => - utilisateurUpdationValidate( - users.admin, - { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: 'aut-97300-01', entrepriseIds: [] }, - { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-97300-01' } + ).resolves.toMatchInlineSnapshot(` + { + "administrationId": "aut-97300-01", + "email": "email@gmail.com", + "id": "fakeId", + "nom": "nom", + "prenom": "prenom", + "role": "admin", + "telephone_fixe": null, + "telephone_mobile": null, + } + `) + await expect( + callAndExit( + utilisateurUpdationValidate( + users.admin, + { id: newUtilisateurId('utilisateurId'), role: 'admin', administrationId: 'aut-97300-01', entrepriseIds: [] }, + { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-97300-01' } + ) ) - ).not.toThrowError() - expect(() => - utilisateurUpdationValidate( - users.admin, - { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-mrae-guyane-01', entrepriseIds: [] }, - { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-97300-01' } + ).resolves.toMatchInlineSnapshot(` + { + "administrationId": "aut-97300-01", + "email": "email@gmail.com", + "id": "fakeId", + "nom": "nom", + "prenom": "prenom", + "role": "editeur", + "telephone_fixe": null, + "telephone_mobile": null, + } + `) + await expect( + callAndExit( + utilisateurUpdationValidate( + users.admin, + { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-mrae-guyane-01', entrepriseIds: [] }, + { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-97300-01' } + ) ) - ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) - expect(() => - utilisateurUpdationValidate( - users.admin, - { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-97300-01', entrepriseIds: [] }, - { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-mrae-guyane-01' } + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit( + utilisateurUpdationValidate( + users.admin, + { id: newUtilisateurId('utilisateurId'), role: 'editeur', administrationId: 'aut-97300-01', entrepriseIds: [] }, + { ...testBlankUser, id: newUtilisateurId('fakeId'), role: 'editeur', administrationId: 'aut-mrae-guyane-01' } + ) ) - ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) - expect(() => utilisateurUpdationValidate(users.editeur, { ...users.editeur, administrationId: 'dea-reunion-01', entrepriseIds: [] }, { ...users.editeur })).toThrowErrorMatchingInlineSnapshot( - `[Error: droits insuffisants]` - ) - expect(() => utilisateurUpdationValidate(users.lecteur, { ...users.lecteur, administrationId: 'dea-reunion-01', entrepriseIds: [] }, { ...users.lecteur })).toThrowErrorMatchingInlineSnapshot( - `[Error: droits insuffisants]` - ) + await expect( + callAndExit(utilisateurUpdationValidate(users.editeur, { ...users.editeur, administrationId: 'dea-reunion-01', entrepriseIds: [] }, { ...users.editeur })) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.lecteur, { ...users.lecteur, administrationId: 'dea-reunion-01', entrepriseIds: [] }, { ...users.lecteur })) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) - expect(() => - utilisateurUpdationValidate(users.entreprise, { ...users.entreprise, administrationId: null, entrepriseIds: [newEntrepriseId('newEntreprise')] }, { ...users.entreprise }) - ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) - expect(() => - utilisateurUpdationValidate(users["bureau d'études"], { ...users["bureau d'études"], administrationId: null, entrepriseIds: [newEntrepriseId('newEntreprise')] }, { ...users["bureau d'études"] }) - ).toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit(utilisateurUpdationValidate(users.entreprise, { ...users.entreprise, administrationId: null, entrepriseIds: [newEntrepriseId('newEntreprise')] }, { ...users.entreprise })) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) + await expect( + callAndExit( + utilisateurUpdationValidate(users["bureau d'études"], { ...users["bureau d'études"], administrationId: null, entrepriseIds: [newEntrepriseId('newEntreprise')] }, { ...users["bureau d'études"] }) + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: droits insuffisants]`) }) diff --git a/packages/api/src/business/validations/utilisateur-updation-validate.ts b/packages/api/src/business/validations/utilisateur-updation-validate.ts index ee5144ab8b529ca6b80f4b4b59e56020b4a0fe10..7d6ef5102ad1492d2f2287de757b3dc01c9b5716 100644 --- a/packages/api/src/business/validations/utilisateur-updation-validate.ts +++ b/packages/api/src/business/validations/utilisateur-updation-validate.ts @@ -15,6 +15,9 @@ import { canEditPermission, getAssignableRoles } from 'camino-common/src/permiss import { equalStringArrays } from '../../tools/index' import { isAdministrationId } from 'camino-common/src/static/administrations' import { UtilisateurToEdit } from 'camino-common/src/utilisateur' +import { CaminoError } from 'camino-common/src/zod-tools' +import { Effect, Option } from 'effect' +import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools' const userIsCorrect = (utilisateur: UtilisateurToEdit): boolean => { if (!isRole(utilisateur.role)) { @@ -34,34 +37,65 @@ const userIsCorrect = (utilisateur: UtilisateurToEdit): boolean => { return false } -export const utilisateurUpdationValidate = (user: UserNotNull, utilisateur: UtilisateurToEdit, utilisateurOld: User): void => { - if (!userIsCorrect(utilisateur)) { - throw new Error('utilisateur incorrect') - } - - if (!utilisateurOld) { - throw new Error("l'utilisateur n'existe pas") - } - - if (!canEditPermission(user, utilisateurOld) || !canEditPermission(user, utilisateur as unknown as UserNotNull)) { - throw new Error('droits insuffisants') - } - - if (utilisateur.role !== utilisateurOld.role) { - if (user.id === utilisateur.id) { - throw new Error('impossible de modifier son propre rôle') - } else if (!getAssignableRoles(user).includes(utilisateur.role)) { - throw new Error('droits insuffisants pour modifier les rôles') - } - } - - if (!isSuper(user)) { - if (isAdministration(utilisateurOld) && utilisateur.administrationId && utilisateur.administrationId !== utilisateurOld.administrationId) { - throw new Error('droits insuffisants pour modifier les administrations') - } - - if (!isAdministrationAdmin(user) && isEntrepriseOrBureauDEtude(utilisateurOld) && !equalStringArrays(utilisateurOld.entrepriseIds.toSorted(), utilisateur.entrepriseIds.toSorted())) { - throw new Error('droits insuffisants pour modifier les entreprises') - } - } +const utilisateurIncorrect = 'utilisateur incorrect' as const +const utilisateurNonExistant = "l'utilisateur n'existe pas" as const +const droitsInsuffisants = 'droits insuffisants' as const +const impossibleDeModifierSonRole = 'impossible de modifier son propre rôle' as const +const impossibleDeModifierLesRoles = 'droits insuffisants pour modifier les rôles' as const +const droitsInsuffisantsPourModifierAdministration = 'droits insuffisants pour modifier les administrations' as const +const droitsInsuffisantPourModifierEntreprises = 'droits insuffisants pour modifier les entreprises' as const +export type UtilisateurUpdationValidateErrors = + | typeof utilisateurIncorrect + | typeof utilisateurNonExistant + | typeof droitsInsuffisants + | typeof impossibleDeModifierLesRoles + | typeof droitsInsuffisantsPourModifierAdministration + | typeof impossibleDeModifierSonRole + | typeof droitsInsuffisantPourModifierEntreprises +export const utilisateurUpdationValidate = (user: UserNotNull, newUtilisateur: UtilisateurToEdit, utilisateurOld: User): Effect.Effect<void, CaminoError<UtilisateurUpdationValidateErrors>> => { + return Effect.Do.pipe( + Effect.map(() => utilisateurOld), + Effect.filterOrFail( + () => userIsCorrect(newUtilisateur), + () => ({ message: utilisateurIncorrect }) + ), + Effect.filterOrFail( + (oldUser): oldUser is UserNotNull => isNotNullNorUndefined(oldUser), + () => ({ message: utilisateurNonExistant }) + ), + Effect.filterOrFail( + oldUser => canEditPermission(user, oldUser) && canEditPermission(user, newUtilisateur as unknown as UserNotNull), + () => ({ message: droitsInsuffisants }) + ), + Effect.tap(oldUser => { + if (newUtilisateur.role !== oldUser.role) { + return Effect.Do.pipe( + Effect.filterOrFail( + () => user.id !== newUtilisateur.id, + () => ({ message: impossibleDeModifierSonRole }) + ), + Effect.filterOrFail( + () => getAssignableRoles(user).includes(newUtilisateur.role), + () => ({ message: impossibleDeModifierLesRoles }) + ) + ) + } + return Effect.succeed(oldUser) + }), + Effect.tap(oldUser => { + if (!isSuper(user)) { + return Effect.Do.pipe( + Effect.filterOrFail( + () => !isAdministration(oldUser) || newUtilisateur.administrationId === oldUser.administrationId, + () => ({ message: droitsInsuffisantsPourModifierAdministration }) + ), + Effect.filterOrFail( + () => isAdministrationAdmin(user) || (isEntrepriseOrBureauDEtude(utilisateurOld) && equalStringArrays(utilisateurOld.entrepriseIds.toSorted(), newUtilisateur.entrepriseIds.toSorted())), + () => ({ message: droitsInsuffisantPourModifierEntreprises }) + ) + ) + } + return Effect.succeed(Option.none) + }) + ) } diff --git a/packages/api/src/database/queries/utilisateurs.queries.ts b/packages/api/src/database/queries/utilisateurs.queries.ts index 93408db21b8f23a812855b7e1276ea3adfed1142..33d86b3183183ffd0067f54b9b044ec77c09b04d 100644 --- a/packages/api/src/database/queries/utilisateurs.queries.ts +++ b/packages/api/src/database/queries/utilisateurs.queries.ts @@ -1,5 +1,5 @@ import { sql } from '@pgtyped/runtime' -import { Effect, pipe } from 'effect' +import { Effect, Option, pipe } from 'effect' import { EffectDbQueryAndValidateErrors, Redefine, dbQueryAndValidate, effectDbQueryAndValidate } from '../../pg-database' import { AdminUserNotNull, @@ -34,7 +34,7 @@ import { IUpdateUtilisateurDbQuery, IUpdateUtilisateurRoleDbQuery, } from './utilisateurs.queries.types' -import { ZodUnparseable, callAndExit, zodParseEffect } from '../../tools/fp-tools' +import { callAndExit, zodParseEffectTyped } from '../../tools/fp-tools' import { NonEmptyArray, Nullable, isNotNullNorUndefinedNorEmpty, isNullOrUndefinedOrEmpty } from 'camino-common/src/typescript-tools' import { EntrepriseId, entrepriseIdValidator } from 'camino-common/src/entreprise' import { CaminoDate } from 'camino-common/src/date' @@ -53,7 +53,7 @@ const getUtilisateursValidator = z.object({ }) export type GetUtilisateur = z.infer<typeof getUtilisateursValidator> -type GetUtilisateursFilteredAndSortedErrors = EffectDbQueryAndValidateErrors | ZodUnparseable | 'droits insuffisants' +export type GetUtilisateursFilteredAndSortedErrors = EffectDbQueryAndValidateErrors | 'droits insuffisants' | typeof utilisateurInvalid export const getUtilisateursFilteredAndSorted = (pool: Pool, user: User, searchParams: UtilisateursSearchParams): Effect.Effect<UserNotNull[], CaminoError<GetUtilisateursFilteredAndSortedErrors>> => { return Effect.Do.pipe( Effect.filterOrFail( @@ -69,7 +69,7 @@ export const getUtilisateursFilteredAndSorted = (pool: Pool, user: User, searchP Effect.flatMap(utilisateurs => { return Effect.forEach(utilisateurs, u => { return pipe( - zodParseEffect(userNotNullValidator, userDbToUser(u)), + zodParseEffectTyped(userNotNullValidator, userDbToUser(u) as UserNotNull, utilisateurInvalid), Effect.mapError(error => { return { ...error, extra: { email: u.email } } }) @@ -167,20 +167,21 @@ const userDbToUser = ( return { ...user, prenom: user.prenom ?? '', entrepriseIds: user.entreprise_ids ?? [], administrationId: user.administration_id } } -export type GetUtilisateurByIdErrors = 'droits insuffisants' | EffectDbQueryAndValidateErrors | ZodUnparseable +const utilisateurInvalid = "L'utilisateur est invalide" as const +export type GetUtilisateurByIdErrors = 'droits insuffisants' | EffectDbQueryAndValidateErrors | typeof utilisateurInvalid export const newGetUtilisateurById = (pool: Pool, id: UtilisateurId, user: User): Effect.Effect<UserNotNull, CaminoError<GetUtilisateurByIdErrors>> => { return pipe( effectDbQueryAndValidate(getUtilisateurByIdDb, { id }, pool, getUtilisateursValidator), Effect.filterOrFail( utilisateurs => isNotNullNorUndefinedNorEmpty(utilisateurs), - () => ({ message: 'droits insuffisants' as const }) + () => ({ message: 'droits insuffisants' as const, detail: 'La liste des utilisateurs retournée par la base est vide' }) ), Effect.flatMap(utilisateurs => { - return zodParseEffect(userNotNullValidator, userDbToUser(utilisateurs[0])) + return zodParseEffectTyped(userNotNullValidator, userDbToUser(utilisateurs[0]) as UserNotNull, utilisateurInvalid) }), Effect.filterOrFail( utilisateur => canReadUtilisateur(user, utilisateur), - () => ({ message: 'droits insuffisants' as const }) + () => ({ message: 'droits insuffisants' as const, detail: 'Permissions insuffisantes pour accéder aux détails de cet utilisateur' }) ) ) } @@ -202,11 +203,17 @@ const getUtilisateurByIdDb = sql<Redefine<IGetUtilisateurByIdDbQuery, { id: Util const getKeycloakIdByUserIdValidator = z.object({ keycloak_id: z.string() }) type GetKeycloakIdByUser = z.infer<typeof getKeycloakIdByUserIdValidator> -export const getKeycloakIdByUserId = async (pool: Pool, utilisateurId: UtilisateurId): Promise<string | null> => { - const result = await dbQueryAndValidate(getKeycloakIdByUserIdDb, { id: utilisateurId }, pool, getKeycloakIdByUserIdValidator) - return isNullOrUndefinedOrEmpty(result) ? null : result[0].keycloak_id -} +const utilisateurNonTrouve = "Impossible de trouver l'utilisateur" as const +export type GetKeycloakIdByUserIdErrors = EffectDbQueryAndValidateErrors | typeof utilisateurNonTrouve +export const getKeycloakIdByUserId = (pool: Pool, utilisateurId: UtilisateurId): Effect.Effect<string, CaminoError<GetKeycloakIdByUserIdErrors>> => + effectDbQueryAndValidate(getKeycloakIdByUserIdDb, { id: utilisateurId }, pool, getKeycloakIdByUserIdValidator).pipe( + Effect.filterOrFail( + result => isNotNullNorUndefinedNorEmpty(result), + () => ({ message: utilisateurNonTrouve }) + ), + Effect.map(result => result[0].keycloak_id) + ) const getKeycloakIdByUserIdDb = sql<Redefine<IGetKeycloakIdByUserIdDbQuery, { id: UtilisateurId }, GetKeycloakIdByUser>>` select @@ -343,25 +350,24 @@ const updateUtilisateurDb = sql<Redefine<IUpdateUtilisateurDbQuery, Pick<UserNot ` type UpdateUtilisateurRole = Pick<UserNotNull, 'id' | 'role'> & Nullable<Pick<AdminUserNotNull, 'administrationId'>> & Pick<EntrepriseUserNotNull, 'entrepriseIds'> -export const updateUtilisateurRole = async (pool: Pool, user: UpdateUtilisateurRole): Promise<void> => { - await dbQueryAndValidate(updateUtilisateurRoleDb, user, pool, z.void()) - - await dbQueryAndValidate(deleteUtilisateurEntrepriseDb, { utilisateur_id: user.id }, pool, z.void()) - - if (isEntrepriseOrBureauDetudeRole(user.role)) { - for (const entreprise_id of user.entrepriseIds) { - await dbQueryAndValidate(createUtilisateurEntrepriseDb, { utilisateur_id: user.id, entreprise_id }, pool, z.void()) - } - } -} +export const updateUtilisateurRole = (pool: Pool, user: UpdateUtilisateurRole): Effect.Effect<void, CaminoError<EffectDbQueryAndValidateErrors>> => + Effect.Do.pipe( + Effect.tap(() => effectDbQueryAndValidate(updateUtilisateurRoleDb, user, pool, z.void())), + Effect.tap(() => effectDbQueryAndValidate(deleteUtilisateurEntrepriseDb, { utilisateur_id: user.id }, pool, z.void())), + Effect.tap(() => { + if (isEntrepriseOrBureauDetudeRole(user.role)) { + return Effect.forEach(user.entrepriseIds, entreprise_id => effectDbQueryAndValidate(createUtilisateurEntrepriseDb, { utilisateur_id: user.id, entreprise_id }, pool, z.void())) + } + return Effect.succeed(Option.none) + }) + ) const updateUtilisateurRoleDb = sql<Redefine<IUpdateUtilisateurRoleDbQuery, Pick<UserNotNull, 'id' | 'role'> & Nullable<Pick<AdminUserNotNull, 'administrationId'>>, void>>` update utilisateurs set role = $role!, administration_id = $administrationId! where id = $id! ` -export const softDeleteUtilisateur = async (pool: Pool, id: UtilisateurId): Promise<void> => { - await dbQueryAndValidate(softDeleteUtilisateurDb, { id }, pool, z.void()) -} +export const softDeleteUtilisateur = (pool: Pool, id: UtilisateurId): Effect.Effect<void, CaminoError<EffectDbQueryAndValidateErrors>> => + effectDbQueryAndValidate(softDeleteUtilisateurDb, { id }, pool, z.void()) const softDeleteUtilisateurDb = sql<Redefine<ISoftDeleteUtilisateurDbQuery, { id: UtilisateurId }, void>>` update utilisateurs set keycloak_id = null, email = null where id = $id! diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts index 6b8f2c1edd9945b2e9bb6258d15f1dcda9aabeab..9d82737a4d4b19c5e9d47ce933942fa76a02c510 100644 --- a/packages/api/src/server/rest.ts +++ b/packages/api/src/server/rest.ts @@ -28,7 +28,6 @@ import { contentTypes, GetRestRoutes, PostRestRoutes, - PutRestRoutes, DeleteRestRoutes, isCaminoRestRoute, DownloadRestRoutes, @@ -46,7 +45,7 @@ import { createEtape, deleteEtape, deposeEtape, getEtape, getEtapeAvis, getEtape import { ZodType, z } from 'zod' import { getCommunes } from '../api/rest/communes' import { SendFileOptions } from 'express-serve-static-core' -import { activiteDocumentDownload, getActivite, updateActivite, deleteActivite } from '../api/rest/activites' +import { activiteDocumentDownload, getActivite, updateActivite, deleteActivite, getActivitesForTDBSuper } from '../api/rest/activites' import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools' import { getDemarcheByIdOrSlugApi, demarcheSupprimer, demarcheCreer, getDemarchesEnConcurrence, getResultatEnConcurrence } from '../api/rest/demarches' import { geojsonImport, geojsonImportPoints, geojsonImportForages, getPerimetreInfosByDemarche, getPerimetreInfosByEtape } from '../api/rest/perimetre' @@ -120,6 +119,7 @@ export type RestNewGetCall<Route extends NewGetRestRoutes> = ( params: z.infer<CaminoRestRoutesType[Route]['params']> searchParams: SearchParams<Route> cookie: CookieParams + redirect: (value: string) => void }, never, never @@ -140,7 +140,6 @@ export type RestNewDeleteCall<Route extends NewDeleteRestRoutes> = ( ) => Effect.Effect<Option.Option<never>, CaminoApiError<string>> type RestPostCall<Route extends PostRestRoutes> = (pool: Pool) => (req: CaminoRequest, res: CustomResponse<z.infer<CaminoRestRoutesType[Route]['post']['output']>>) => Promise<void> -type RestPutCall<Route extends PutRestRoutes> = (pool: Pool) => (req: CaminoRequest, res: CustomResponse<z.infer<CaminoRestRoutesType[Route]['put']['output']>>) => Promise<void> type RestDeleteCall = (pool: Pool) => (req: CaminoRequest, res: CustomResponse<void | Error>) => Promise<void> type RestDownloadCall = (pool: Pool) => IRestResolver @@ -149,7 +148,6 @@ type Transform<Route> = (Route extends GetRestRoutes ? { getCall: RestGetCall<Ro (Route extends PostRestRoutes ? { postCall: RestPostCall<Route> } : {}) & (Route extends NewPostRestRoutes ? { newPostCall: RestNewPostCall<Route> } : {}) & (Route extends NewPutRestRoutes ? { newPutCall: RestNewPutCall<Route> } : {}) & - (Route extends PutRestRoutes ? { putCall: RestPutCall<Route> } : {}) & (Route extends DeleteRestRoutes ? { deleteCall: RestDeleteCall } : {}) & (Route extends NewDeleteRestRoutes ? { newDeleteCall: RestNewDeleteCall<Route> } : {}) & (Route extends NewDownloadRestRoutes ? { newDownloadCall: NewDownload } : {}) & @@ -210,8 +208,8 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k '/rest/demarches/:demarcheId/miseEnConcurrence': { newGetCall: getDemarchesEnConcurrence, ...CaminoRestRoutes['/rest/demarches/:demarcheId/miseEnConcurrence'] }, '/rest/demarches/:demarcheId/resultatMiseEnConcurrence': { newGetCall: getResultatEnConcurrence, ...CaminoRestRoutes['/rest/demarches/:demarcheId/resultatMiseEnConcurrence'] }, '/rest/utilisateur/generateQgisToken': { newPostCall: generateQgisToken, ...CaminoRestRoutes['/rest/utilisateur/generateQgisToken'] }, - '/rest/utilisateurs/:id/permission': { postCall: updateUtilisateurPermission, ...CaminoRestRoutes['/rest/utilisateurs/:id/permission'] }, - '/rest/utilisateurs/:id/delete': { getCall: deleteUtilisateur, ...CaminoRestRoutes['/rest/utilisateurs/:id/delete'] }, + '/rest/utilisateurs/:id/permission': { newPostCall: updateUtilisateurPermission, ...CaminoRestRoutes['/rest/utilisateurs/:id/permission'] }, + '/rest/utilisateurs/:id/delete': { newGetCall: deleteUtilisateur, ...CaminoRestRoutes['/rest/utilisateurs/:id/delete'] }, '/rest/utilisateurs/:id': { newGetCall: getUtilisateur, ...CaminoRestRoutes['/rest/utilisateurs/:id'] }, '/rest/utilisateurs': { newGetCall: getUtilisateurs, ...CaminoRestRoutes['/rest/utilisateurs'] }, '/rest/entreprises/:entrepriseId/fiscalite/:annee': { getCall: fiscalite, ...CaminoRestRoutes['/rest/entreprises/:entrepriseId/fiscalite/:annee'] }, // UNTESTED YET @@ -240,7 +238,8 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k '/rest/etapes/:etapeId/entrepriseDocuments': { newGetCall: getEtapeEntrepriseDocuments, ...CaminoRestRoutes['/rest/etapes/:etapeId/entrepriseDocuments'] }, '/rest/etapes/:etapeId/etapeDocuments': { newGetCall: getEtapeDocuments, ...CaminoRestRoutes['/rest/etapes/:etapeId/etapeDocuments'] }, '/rest/etapes/:etapeId/etapeAvis': { newGetCall: getEtapeAvis, ...CaminoRestRoutes['/rest/etapes/:etapeId/etapeAvis'] }, - '/rest/activites/:activiteId': { getCall: getActivite, putCall: updateActivite, deleteCall: deleteActivite, ...CaminoRestRoutes['/rest/activites/:activiteId'] }, + '/rest/activites/:activiteId': { getCall: getActivite, newPutCall: updateActivite, deleteCall: deleteActivite, ...CaminoRestRoutes['/rest/activites/:activiteId'] }, + '/rest/activitesSuper': { newGetCall: getActivitesForTDBSuper, ...CaminoRestRoutes['/rest/activitesSuper'] }, '/rest/communes': { newGetCall: getCommunes, ...CaminoRestRoutes['/rest/communes'] }, '/rest/geojson/import/:geoSystemeId': { newPostCall: geojsonImport, ...CaminoRestRoutes['/rest/geojson/import/:geoSystemeId'] }, '/rest/geojson_points/import/:geoSystemeId': { newPostCall: geojsonImportPoints, ...CaminoRestRoutes['/rest/geojson_points/import/:geoSystemeId'] }, @@ -287,7 +286,8 @@ export const restWithPool = (dbPool: Pool): Router => { Effect.let('cookie', () => ({ clearConnectedCookie: () => res.clearCookie('shouldBeConnected'), addConnectedCookie: () => res.cookie('shouldBeConnected', 'anyValueIsGood, We just check the presence of this cookie'), - })) + })), + Effect.let('redirect', () => (redirect: string) => res.redirect(redirect)) ) ) }), @@ -303,10 +303,12 @@ export const restWithPool = (dbPool: Pool): Router => { res.status(caminoError.status).json(caminoError) }, onSuccess: ({ parsedResult }) => { - if (isNullOrUndefined(parsedResult)) { - res.sendStatus(HTTP_STATUS.NO_CONTENT) - } else { - res.json(parsedResult) + if (!res.writableEnded) { + if (isNullOrUndefined(parsedResult)) { + res.sendStatus(HTTP_STATUS.NO_CONTENT) + } else { + res.json(parsedResult) + } } }, }), @@ -473,10 +475,6 @@ export const restWithPool = (dbPool: Pool): Router => { } }) } - if ('putCall' in maRoute) { - console.info(`PUT ${route}`) - rest.put(route, restCatcherWithMutation('put', maRoute.putCall(dbPool), dbPool)) // eslint-disable-line @typescript-eslint/no-misused-promises - } if ('deleteCall' in maRoute) { console.info(`delete ${route}`) diff --git a/packages/api/src/tools/fp-tools.ts b/packages/api/src/tools/fp-tools.ts index 73202de0231f6a40f61accd5cadbb9af8a25ff8a..5ced5c46047e0ea070a5d50959a484e4f78f453e 100644 --- a/packages/api/src/tools/fp-tools.ts +++ b/packages/api/src/tools/fp-tools.ts @@ -52,7 +52,17 @@ export const callAndExit = async <A>(toCall: Effect.Effect<A, CaminoError<string } else { if (Cause.isFailType(pipeline.cause)) { console.error(pipeline.cause.error) - throw new Error(`${pipeline.cause.error.message}\n extra: ${pipeline.cause.error.extra}\ndetail: ${pipeline.cause.error.detail}\n zod: ${pipeline.cause.error.zodErrorReadableMessage}`) + let errorMessage = pipeline.cause.error.message + if (isNotNullNorUndefined(pipeline.cause.error.extra)) { + errorMessage += `\n extra: ${pipeline.cause.error.extra}` + } + if (isNotNullNorUndefined(pipeline.cause.error.detail)) { + errorMessage += `\n detail: ${pipeline.cause.error.detail}` + } + if (isNotNullNorUndefined(pipeline.cause.error.zodErrorReadableMessage)) { + errorMessage += `\n zod: ${pipeline.cause.error.zodErrorReadableMessage}` + } + throw new Error(errorMessage) } else { throw new Error(`Unexpected error ${pipeline.cause}`) } diff --git a/packages/common/src/activite.ts b/packages/common/src/activite.ts index 6240904452e0c116de66e3c1f3020bb3e39b9f24..475cde5431c836a806c938907df3fb14b5e398eb 100644 --- a/packages/common/src/activite.ts +++ b/packages/common/src/activite.ts @@ -5,6 +5,7 @@ import { sectionWithValueValidator } from './sections' import { activiteStatutIdValidator } from './static/activitesStatuts' import { activiteTypeIdValidator } from './static/activitesTypes' import { tempDocumentNameValidator } from './document' +import { titreTypeIdValidator } from './static/titresTypes' export const activiteSlugValidator = z.string().brand<'ActiviteSlug'>() @@ -54,3 +55,14 @@ export const activiteEditionValidator = z.object({ activiteDocumentIds: z.array(activiteDocumentIdValidator), newTempDocuments: z.array(tempActiviteDocumentValidator), }) + +export const activiteSuperValidator = z.object({ + titre_nom: z.string(), + titre_type_id: titreTypeIdValidator, + id: activiteIdValidator, + annee: caminoAnneeValidator, + type_id: activiteTypeIdValidator, + periode_id: z.number(), + activite_statut_id: activiteStatutIdValidator, +}) +export type ActiviteSuper = z.infer<typeof activiteSuperValidator> diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts index 96a20d901b7d88efb0e26728e11a8c6dea12c8db..78f248921e440a7869745da1ce84bc58b8dd4adf 100644 --- a/packages/common/src/rest.ts +++ b/packages/common/src/rest.ts @@ -53,7 +53,7 @@ import { fiscaliteValidator } from './validators/fiscalite' import { caminoConfigValidator } from './static/config' import { communeIdValidator, communeValidator } from './static/communes' import { Expect, isFalse, isNotNullNorUndefined, isTrue } from './typescript-tools' -import { activiteDocumentIdValidator, activiteEditionValidator, activiteIdOrSlugValidator, activiteValidator } from './activite' +import { activiteDocumentIdValidator, activiteEditionValidator, activiteIdOrSlugValidator, activiteIdValidator, activiteSuperValidator, activiteValidator } from './activite' import { geoSystemeIdValidator } from './static/geoSystemes' import { geojsonImportBodyValidator, @@ -74,7 +74,6 @@ type CaminoRoute<T extends string> = { params: ZodObjectParsUrlParams<T> } & { newGet?: { output: ZodType; searchParams?: ZodType } post?: { input: ZodType; output: ZodType } newPost?: { input: ZodType; output: ZodType } - put?: { input: ZodType; output: ZodType } newPut?: { input: ZodType; output: ZodType } newDelete?: true delete?: true @@ -126,6 +125,7 @@ const IDS = [ '/rest/etapes/:etapeId/depot', '/rest/etapes', '/rest/activites/:activiteId', + '/rest/activitesSuper', '/rest/geojson/import/:geoSystemeId', '/rest/geojson_points/import/:geoSystemeId', '/rest/geojson_forages/import/:geoSystemeId', @@ -165,8 +165,8 @@ export const CaminoRestRoutes = { '/moi': { params: noParamsValidator, newGet: { output: userValidator } }, '/rest/utilisateurs/:id': { params: utilisateurIdParamsValidator, newGet: { output: userNotNullValidator } }, // On passe par un http get plutot qu'un http delete car nous terminons par une redirection vers la deconnexion de oauth2, qui se traduit mal sur certains navigateurs et essaie de faire un delete sur une route get - '/rest/utilisateurs/:id/delete': { params: utilisateurIdParamsValidator, get: { output: z.void() } }, - '/rest/utilisateurs/:id/permission': { params: utilisateurIdParamsValidator, post: { input: utilisateurToEdit, output: z.void() } }, + '/rest/utilisateurs/:id/delete': { params: utilisateurIdParamsValidator, newGet: { output: z.object({ id: utilisateurIdValidator }) } }, + '/rest/utilisateurs/:id/permission': { params: utilisateurIdParamsValidator, newPost: { input: utilisateurToEdit, output: z.object({ id: utilisateurIdValidator }) } }, '/rest/utilisateurs': { params: noParamsValidator, newGet: { output: utilisateursTableValidator, searchParams: utilisateursSearchParamsValidator } }, '/rest/statistiques/minerauxMetauxMetropole': { params: noParamsValidator, get: { output: statistiquesMinerauxMetauxMetropoleValidator } }, '/rest/statistiques/guyane': { params: noParamsValidator, get: { output: statistiquesGuyaneDataValidator } }, @@ -237,9 +237,13 @@ export const CaminoRestRoutes = { '/rest/activites/:activiteId': { params: z.object({ activiteId: activiteIdOrSlugValidator }), get: { output: activiteValidator }, - put: { input: activiteEditionValidator, output: z.void() }, + newPut: { input: activiteEditionValidator, output: z.object({ id: activiteIdValidator }) }, delete: true, }, + '/rest/activitesSuper': { + newGet: { output: z.array(activiteSuperValidator) }, + params: noParamsValidator, + }, '/rest/communes': { params: noParamsValidator, newGet: { output: z.array(communeValidator), searchParams: z.object({ ids: z.array(communeIdValidator).nonempty() }) } }, '/rest/geojson/import/:geoSystemeId': { params: geoSystemIdParamsValidator, @@ -313,7 +317,6 @@ export type DeleteRestRoutes = CaminoRestRouteList<typeof IDS, 'delete'>[number] export type NewDeleteRestRoutes = CaminoRestRouteList<typeof IDS, 'newDelete'>[number] export type DownloadRestRoutes = CaminoRestRouteList<typeof IDS, 'download'>[number] export type NewDownloadRestRoutes = CaminoRestRouteList<typeof IDS, 'newDownload'>[number] -export type PutRestRoutes = CaminoRestRouteList<typeof IDS, 'put'>[number] export type CaminoRestParams<Route extends CaminoRestRoute> = z.infer<(typeof CaminoRestRoutes)[Route]['params']> diff --git a/packages/ui/src/api/client-rest.ts b/packages/ui/src/api/client-rest.ts index b5300bfec0ec49cdddb48aa6e5fac7d7a10fe143..1128d1874bc7deed26eb4d8d6c6dd991e653ce2c 100644 --- a/packages/ui/src/api/client-rest.ts +++ b/packages/ui/src/api/client-rest.ts @@ -9,7 +9,6 @@ import { getRestRoute, GetRestRoutes, PostRestRoutes, - PutRestRoutes, NewPostRestRoutes, NewGetRestRoutes, NewPutRestRoutes, @@ -231,14 +230,6 @@ export const newPostWithJson = async <T extends NewPostRestRoutes>( return { ...errorMessage, extra: e } } } -/** - * @deprecated use newPutWithJson - **/ -export const putWithJson = async <T extends PutRestRoutes>( - path: T, - params: CaminoRestParams<T>, - body: z.infer<(typeof CaminoRestRoutes)[T]['put']['input']> -): Promise<z.infer<(typeof CaminoRestRoutes)[T]['put']['output']>> => callFetch(path, params, 'put', {}, body) export const newPutWithJson = async <T extends NewPutRestRoutes>( path: T, diff --git a/packages/ui/src/components/activite-edition.stories.tsx b/packages/ui/src/components/activite-edition.stories.tsx index c43a118df44347996acd0ca2f19ff1bb05c789b0..cf12d3c654b4bc95bea84dfb2028405df1412fdb 100644 --- a/packages/ui/src/components/activite-edition.stories.tsx +++ b/packages/ui/src/components/activite-edition.stories.tsx @@ -169,8 +169,8 @@ const activite: Activite = { } const apiClient: Props['apiClient'] = { - updateActivite(_activiteId, _sectionsWithValue, _activiteDocumentIds, _newTempDocuments): Promise<void> { - return Promise.resolve(undefined) + updateActivite(activiteId, _sectionsWithValue, _activiteDocumentIds, _newTempDocuments) { + return Promise.resolve({ id: activiteId }) }, getActivite: activiteId => { getActiviteAction(activiteId) diff --git a/packages/ui/src/components/activite-edition.tsx b/packages/ui/src/components/activite-edition.tsx index de6cc2f37ad17d3bef5308003eda883166e434b5..e9a65908d45d0ddd6cd6a6b9ce9b22da1cf222b4 100644 --- a/packages/ui/src/components/activite-edition.tsx +++ b/packages/ui/src/components/activite-edition.tsx @@ -101,23 +101,22 @@ export const PureActiviteEdition = defineComponent<Props>(props => { const save = async (goBack: boolean) => { if (data.value.status === 'LOADED') { - try { - await props.apiClient.updateActivite( - data.value.value.id, - data.value.value.type_id, - sectionsComplete.value.sectionsWithValue, - documentsComplete.value.activiteDocumentIds, - documentsComplete.value.tempsDocuments - ) + const result = await props.apiClient.updateActivite( + data.value.value.id, + data.value.value.type_id, + sectionsComplete.value.sectionsWithValue, + documentsComplete.value.activiteDocumentIds, + documentsComplete.value.tempsDocuments + ) + if ('message' in result) { + data.value = { + status: 'NEW_ERROR', + error: result, + } + } else { if (goBack) { props.goBack(data.value.value.id) } - } catch (e: any) { - console.error('error', e) - data.value = { - status: 'ERROR', - message: e.message ?? "Une erreur s'est produite", - } } } } diff --git a/packages/ui/src/components/activite/activite-api-client.ts b/packages/ui/src/components/activite/activite-api-client.ts index ca9de4ddf11dfe4d292af76242c8c18ed7a2055d..32f8daa1ca7092efea8c9174c973c0b6aaa47727 100644 --- a/packages/ui/src/components/activite/activite-api-client.ts +++ b/packages/ui/src/components/activite/activite-api-client.ts @@ -4,9 +4,10 @@ import { CaminoAnnee } from 'camino-common/src/date' import { ActivitesStatutId } from 'camino-common/src/static/activitesStatuts' import { ActivitesTypesId } from 'camino-common/src/static/activitesTypes' import gql from 'graphql-tag' -import { deleteWithJson, getWithJson, putWithJson } from '../../api/client-rest' +import { deleteWithJson, getWithJson, newPutWithJson } from '../../api/client-rest' import { SectionWithValue } from 'camino-common/src/sections' import { EntrepriseId } from 'camino-common/src/entreprise' +import { CaminoError } from 'camino-common/src/zod-tools' export interface UiGraphqlActivite { id: string @@ -33,7 +34,7 @@ export interface ActiviteApiClient { sectionsWithValue: SectionWithValue[], activiteDocumentIds: ActiviteDocumentId[], newTempDocuments: TempActiviteDocument[] - ) => Promise<void> + ) => Promise<{ id: ActiviteId } | CaminoError<string>> } type GetActivitesParams = { @@ -125,7 +126,7 @@ export const activiteApiClient: ActiviteApiClient = { activiteDocumentIds: ActiviteDocumentId[], newTempDocuments: TempActiviteDocument[] ) => { - return putWithJson( + return newPutWithJson( '/rest/activites/:activiteId', { activiteId, diff --git a/packages/ui/src/components/dashboard.tsx b/packages/ui/src/components/dashboard.tsx index 727b6bbef962b83f4f7848d14940f09289bd3ef4..1c3871328c5026848c243a26c6469c0193b4b237 100644 --- a/packages/ui/src/components/dashboard.tsx +++ b/packages/ui/src/components/dashboard.tsx @@ -1,9 +1,11 @@ -import { FunctionalComponent, defineAsyncComponent, defineComponent, inject, onMounted, ref } from 'vue' +import { defineAsyncComponent, defineComponent, inject, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { dashboardApiClient } from './dashboard/dashboard-api-client' -import { User, isAdministration, isEntrepriseOrBureauDEtude, isSuper } from 'camino-common/src/roles' +import { AdminUserNotNull, EntrepriseUserNotNull, User, isAdministration, isEntrepriseOrBureauDEtude, isSuper } from 'camino-common/src/roles' import { entreprisesKey, userKey } from '@/moi' import { Entreprise } from 'camino-common/src/entreprise' +import { CaminoRouteLocation } from '@/router/routes' +import { CaminoRouter } from '@/typings/vue-router' export const Dashboard = defineComponent({ setup() { @@ -18,37 +20,60 @@ export const Dashboard = defineComponent({ } }) - return () => <PureDashboard user={user} entreprises={entreprises.value} /> + return () => <PureDashboard user={user} entreprises={entreprises.value} router={router} route={router.currentRoute.value} /> }, }) -const PureDashboard: FunctionalComponent<{ user: User; entreprises: Entreprise[] }> = props => { - if (isEntrepriseOrBureauDEtude(props.user)) { - const PureEntrepriseDashboard = defineAsyncComponent(async () => { - const { PureEntrepriseDashboard } = await import('@/components/dashboard/pure-entreprise-dashboard') +type PureDashboardProps = { user: User; entreprises: Entreprise[]; route: CaminoRouteLocation; router: CaminoRouter } +const PureDashboard = defineComponent((props: PureDashboardProps) => { + return () => ( + <> + {isEntrepriseOrBureauDEtude(props.user) ? <PureEntrepriseDashboard user={props.user} entreprises={props.entreprises} /> : null} + {isAdministration(props.user) ? <PureAdministrationDashboard user={props.user} entreprises={props.entreprises} /> : null} + {isSuper(props.user) ? <PureSuperDashboard {...props} /> : null} + </> + ) +}) - return PureEntrepriseDashboard - }) - const entrepriseIds = props.user.entrepriseIds ?? [] +const PureEntrepriseDashboard = defineComponent((props: Pick<PureDashboardProps, 'entreprises'> & { user: EntrepriseUserNotNull }) => { + const PureEntrepriseDashboardComponent = defineAsyncComponent(async () => { + const { PureEntrepriseDashboard } = await import('@/components/dashboard/pure-entreprise-dashboard') - return <PureEntrepriseDashboard apiClient={dashboardApiClient} user={props.user} entrepriseIds={entrepriseIds} allEntreprises={props.entreprises} /> - } else if (isAdministration(props.user)) { - const PureAdministrationDashboard = defineAsyncComponent(async () => { - const { PureAdministrationDashboard } = await import('@/components/dashboard/pure-administration-dashboard') + return PureEntrepriseDashboard + }) + const entrepriseIds = props.user.entrepriseIds ?? [] - return PureAdministrationDashboard - }) + return () => <PureEntrepriseDashboardComponent apiClient={dashboardApiClient} user={props.user} entrepriseIds={entrepriseIds} allEntreprises={props.entreprises} /> +}) - return <PureAdministrationDashboard apiClient={dashboardApiClient} user={props.user} entreprises={props.entreprises} /> - } else if (isSuper(props.user)) { - const PureSuperDashboard = defineAsyncComponent(async () => { - const { PureSuperDashboard } = await import('@/components/dashboard/pure-super-dashboard') +const PureSuperDashboard = defineComponent((props: PureDashboardProps) => { + const PureSuperDashboardComponent = defineAsyncComponent(async () => { + const { PureSuperDashboard } = await import('@/components/dashboard/pure-super-dashboard') - return PureSuperDashboard - }) + return PureSuperDashboard + }) + + return () => <PureSuperDashboardComponent apiClient={dashboardApiClient} user={props.user} route={props.route} router={props.router} /> +}) + +const PureAdministrationDashboard = defineComponent((props: Pick<PureDashboardProps, 'entreprises'> & { user: AdminUserNotNull }) => { + const PureAdministrationDashboardComponent = defineAsyncComponent(async () => { + const { PureAdministrationDashboard } = await import('@/components/dashboard/pure-administration-dashboard') + + return PureAdministrationDashboard + }) + + return () => <PureAdministrationDashboardComponent apiClient={dashboardApiClient} user={props.user} entreprises={props.entreprises} /> +}) + +// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833 +PureDashboard.props = ['user', 'entreprises', 'route', 'router'] + +// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833 +PureSuperDashboard.props = ['user', 'entreprises', 'route', 'router'] - return <PureSuperDashboard apiClient={dashboardApiClient} user={props.user} /> - } +// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833 +PureAdministrationDashboard.props = ['user', 'entreprises', 'route', 'router'] - return null -} +// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833 +PureEntrepriseDashboard.props = ['user', 'entreprises', 'route', 'router'] diff --git a/packages/ui/src/components/dashboard/dashboard-api-client.ts b/packages/ui/src/components/dashboard/dashboard-api-client.ts index e94ba5b7ea2f4cca7776d245ec85735651e59132..deebe9b650c092b43bddabaec9d2435858954183 100644 --- a/packages/ui/src/components/dashboard/dashboard-api-client.ts +++ b/packages/ui/src/components/dashboard/dashboard-api-client.ts @@ -1,5 +1,6 @@ import { apiGraphQLFetch } from '@/api/_client' import { getWithJson, newGetWithJson } from '@/api/client-rest' +import { ActiviteSuper } from 'camino-common/src/activite' import { EntrepriseId, TitreEntreprise } from 'camino-common/src/entreprise' import { StatistiquesDGTM } from 'camino-common/src/statistiques' import { CommonTitreAdministration, SuperTitre } from 'camino-common/src/titres' @@ -11,6 +12,7 @@ export interface DashboardApiClient { getDgtmStats: () => Promise<StatistiquesDGTM> getEntreprisesTitres: (entreprisesIds: EntrepriseId[]) => Promise<TitreEntreprise[]> getTitresAvecEtapeEnBrouillon: () => Promise<SuperTitre[] | CaminoError<string>> + getActivitesSuper: () => Promise<ActiviteSuper[] | CaminoError<string>> } const titres = apiGraphQLFetch(gql` query Titres( @@ -80,4 +82,5 @@ export const dashboardApiClient: DashboardApiClient = { return (await titres({ entreprisesIds })).elements }, getTitresAvecEtapeEnBrouillon: async (): Promise<SuperTitre[] | CaminoError<string>> => newGetWithJson('/rest/titresSuper', {}), + getActivitesSuper: () => newGetWithJson('/rest/activitesSuper', {}), } diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx b/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx index b665f212f24485ce061bdff977117b96304e33d4..a3b7dfb341a7cd3d4eb64148e79719467c859284 100644 --- a/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories.tsx @@ -4,8 +4,14 @@ import { titreSlugValidator } from 'camino-common/src/validators/titres' import { PureSuperDashboard } from './pure-super-dashboard' import { SuperTitre } from 'camino-common/src/titres' import { demarcheSlugValidator } from 'camino-common/src/demarche' -import { caminoDateValidator } from 'camino-common/src/date' +import { caminoDateValidator, toCaminoAnnee } from 'camino-common/src/date' import { etapeSlugValidator } from 'camino-common/src/etape' +import { activiteIdValidator, ActiviteSuper } from 'camino-common/src/activite' +import { CaminoRouteLocation } from '@/router/routes' +import { CaminoRouter } from '@/typings/vue-router' +import { ACTIVITES_TYPES_IDS } from 'camino-common/src/static/activitesTypes' +import { ACTIVITES_STATUTS_IDS } from 'camino-common/src/static/activitesStatuts' +import { TITRES_TYPES_IDS } from 'camino-common/src/static/titresTypes' const meta: Meta = { title: 'Components/Dashboard/Super', @@ -14,6 +20,26 @@ const meta: Meta = { } export default meta +const activites: ActiviteSuper[] = [ + { + activite_statut_id: ACTIVITES_STATUTS_IDS.DEPOSE, + annee: toCaminoAnnee(2025), + id: activiteIdValidator.parse('id1'), + periode_id: 1, + titre_nom: 'Nom du titre', + titre_type_id: TITRES_TYPES_IDS.AUTORISATION_D_EXPLOITATION_METAUX, + type_id: ACTIVITES_TYPES_IDS["rapport trimestriel d'exploitation d'or en Guyane"], + }, + { + activite_statut_id: ACTIVITES_STATUTS_IDS.CLOTURE, + annee: toCaminoAnnee(2024), + id: activiteIdValidator.parse('id2'), + periode_id: 3, + titre_nom: 'Nom du titre 2', + titre_type_id: TITRES_TYPES_IDS.PERMIS_D_EXPLOITATION_METAUX, + type_id: ACTIVITES_TYPES_IDS["rapport d'intensité d'exploration"], + }, +] const titres: SuperTitre[] = [ { titre_nom: 'Aachen', @@ -39,10 +65,112 @@ const titres: SuperTitre[] = [ }, ] -export const TableauVide: StoryFn = () => <PureSuperDashboard user={{ role: 'super', ...testBlankUser }} apiClient={{ getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]) }} /> -export const TableauPlein: StoryFn = () => <PureSuperDashboard user={{ role: 'super', ...testBlankUser }} apiClient={{ getTitresAvecEtapeEnBrouillon: () => Promise.resolve(titres) }} /> -export const Loading: StoryFn = () => <PureSuperDashboard user={{ role: 'super', ...testBlankUser }} apiClient={{ getTitresAvecEtapeEnBrouillon: () => new Promise<SuperTitre[]>(_resolve => {}) }} /> +const router: Pick<CaminoRouter, 'push'> = { + push: _ => { + return Promise.resolve() + }, +} + +const routeBrouillon: CaminoRouteLocation = { + name: 'dashboard', + params: {}, + query: { vueId: 'brouillons' }, +} + +const routeActivite: CaminoRouteLocation = { + name: 'dashboard', + params: {}, + query: { vueId: 'activites' }, +} + +export const TableauVideBrouillon: StoryFn = () => ( + <PureSuperDashboard + user={{ role: 'super', ...testBlankUser }} + apiClient={{ + getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]), + getActivitesSuper: () => Promise.resolve([]), + }} + route={routeBrouillon} + router={router} + /> +) +export const TableauVideActivites: StoryFn = () => ( + <PureSuperDashboard + user={{ role: 'super', ...testBlankUser }} + apiClient={{ + getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]), + getActivitesSuper: () => Promise.resolve([]), + }} + route={routeActivite} + router={router} + /> +) + +export const TableauPleinBrouillon: StoryFn = () => ( + <PureSuperDashboard + user={{ role: 'super', ...testBlankUser }} + apiClient={{ + getTitresAvecEtapeEnBrouillon: () => Promise.resolve(titres), + getActivitesSuper: () => Promise.resolve(activites), + }} + route={routeBrouillon} + router={router} + /> +) +export const TableauPleinActivites: StoryFn = () => ( + <PureSuperDashboard + user={{ role: 'super', ...testBlankUser }} + apiClient={{ + getTitresAvecEtapeEnBrouillon: () => Promise.resolve(titres), + getActivitesSuper: () => Promise.resolve(activites), + }} + route={routeActivite} + router={router} + /> +) +export const LoadingBrouillon: StoryFn = () => ( + <PureSuperDashboard + user={{ role: 'super', ...testBlankUser }} + apiClient={{ + getTitresAvecEtapeEnBrouillon: () => new Promise<SuperTitre[]>(_resolve => {}), + getActivitesSuper: () => Promise.resolve([]), + }} + route={routeBrouillon} + router={router} + /> +) +export const LoadingActivites: StoryFn = () => ( + <PureSuperDashboard + user={{ role: 'super', ...testBlankUser }} + apiClient={{ + getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]), + getActivitesSuper: () => new Promise<ActiviteSuper[]>(_resolve => {}), + }} + route={routeActivite} + router={router} + /> +) + +export const WithErrorBrouillon: StoryFn = () => ( + <PureSuperDashboard + user={{ role: 'super', ...testBlankUser }} + apiClient={{ + getTitresAvecEtapeEnBrouillon: () => Promise.resolve({ message: 'Une erreur' }), + getActivitesSuper: () => Promise.resolve([]), + }} + route={routeBrouillon} + router={router} + /> +) -export const WithError: StoryFn = () => ( - <PureSuperDashboard user={{ role: 'super', ...testBlankUser }} apiClient={{ getTitresAvecEtapeEnBrouillon: () => Promise.resolve({ message: 'Une erreur' }) }} /> +export const WithErrorActivites: StoryFn = () => ( + <PureSuperDashboard + user={{ role: 'super', ...testBlankUser }} + apiClient={{ + getTitresAvecEtapeEnBrouillon: () => Promise.resolve([]), + getActivitesSuper: () => Promise.resolve({ message: 'Une erreur' }), + }} + route={routeActivite} + router={router} + /> ) diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingActivites.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingActivites.html new file mode 100644 index 0000000000000000000000000000000000000000..12c8a3946536d6e99fc0311fecde13b7a375e8c8 --- /dev/null +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingActivites.html @@ -0,0 +1,29 @@ +<div> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Tableau de bord</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div class="fr-tabs" style="--tabs-height: 0px;"> + <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable"> + <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Brouillons" aria-selected="false" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li> + <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Activités" aria-selected="true" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li> + </ul> + <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-start" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0"> + <!----> + </div> + <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0"> + <div class="_top-level_3306d0" style="display: flex; justify-content: center;"> + <!----> + <!----> + <div class="_spinner_3306d0"></div> + </div> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingBrouillon.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingBrouillon.html new file mode 100644 index 0000000000000000000000000000000000000000..d6b4bac9b8f8181623abf73580dd5409348b6d5e --- /dev/null +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_LoadingBrouillon.html @@ -0,0 +1,29 @@ +<div> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Tableau de bord</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div class="fr-tabs" style="--tabs-height: 0px;"> + <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable"> + <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Brouillons" aria-selected="true" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li> + <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Activités" aria-selected="false" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li> + </ul> + <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0"> + <div class="_top-level_3306d0" style="display: flex; justify-content: center;"> + <!----> + <!----> + <div class="_spinner_3306d0"></div> + </div> + </div> + <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-end" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0"> + <!----> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinActivites.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinActivites.html new file mode 100644 index 0000000000000000000000000000000000000000..ae367c77b6faa25d41a9c1f0c2ff3eb97e136730 --- /dev/null +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinActivites.html @@ -0,0 +1,69 @@ +<div> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Tableau de bord</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div class="fr-tabs" style="--tabs-height: 0px;"> + <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable"> + <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Brouillons" aria-selected="false" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li> + <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Activités" aria-selected="true" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li> + </ul> + <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-start" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0"> + <!----> + </div> + <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0"> + <div> + <div class="fr-table fr-table--no-scroll" style="overflow: auto;"> + <div class="fr-table__wrapper" style="width: auto;"> + <div class="fr-table__container"> + <div class="fr-table__content"> + <table style="display: table; width: 100%;"> + <caption>Activités supprimables</caption> + <thead> + <tr> + <th scope="col">Nom</th> + <th scope="col">Type</th> + <th scope="col">Année</th> + <th scope="col">Période</th> + <th scope="col">Type de rapport</th> + <th scope="col">Statut</th> + </tr> + </thead> + <tbody> + <tr class="fr-label--error"> + <td><a href="/mocked-href" title="Nom du titre" aria-label="Nom du titre">Nom du titre</a></td> + <td><span class="small bold">Autorisation d'exploitation</span></td> + <td><span class="">2025</span></td> + <td><span class="">1er trimestre</span></td> + <td><span class="">rapport trimestriel d'exploitation d'or en Guyane</span></td> + <td> + <p style="z-index: unset; margin-bottom: 0px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;" class="fr-badge fr-badge--md fr-badge--green-bourgeon" title="déposé" aria-label="déposé">déposé</p> + </td> + </tr> + <tr> + <td><a href="/mocked-href" title="Nom du titre 2" aria-label="Nom du titre 2">Nom du titre 2</a></td> + <td><span class="small bold">Permis d'exploitation</span></td> + <td><span class="">2024</span></td> + <td><span class=""></span></td> + <td><span class="">rapport d'intensité d'exploration</span></td> + <td> + <p style="z-index: unset; margin-bottom: 0px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;" class="fr-badge fr-badge--md fr-badge--beige-gris-galet" title="cloturé" aria-label="cloturé">cloturé</p> + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinBrouillon.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinBrouillon.html new file mode 100644 index 0000000000000000000000000000000000000000..91ad572118cbc54109b76090f0048cec839b7a73 --- /dev/null +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauPleinBrouillon.html @@ -0,0 +1,76 @@ +<div> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Tableau de bord</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div class="fr-tabs" style="--tabs-height: 0px;"> + <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable"> + <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Brouillons" aria-selected="true" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li> + <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Activités" aria-selected="false" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li> + </ul> + <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0"> + <div> + <div class="fr-table fr-table--no-scroll" style="overflow: auto;"> + <div class="fr-table__wrapper" style="width: auto;"> + <div class="fr-table__container"> + <div class="fr-table__content"> + <table style="display: table; width: 100%;"> + <caption>Titres avec une étape en brouillon</caption> + <thead> + <tr> + <th scope="col">Nom</th> + <th scope="col">Type de démarche</th> + <th scope="col">Type de titre</th> + <th scope="col">-</th> + <th scope="col">Statut du titre</th> + <th scope="col">Étape en brouillon</th> + <th scope="col">Date de l'étape</th> + </tr> + </thead> + <tbody> + <tr> + <td><a href="/mocked-href" title="Aachen" aria-label="Aachen">Aachen</a></td> + <td><span class="">Octroi</span></td> + <td><span class="small bold">Concession</span></td> + <td> + <p class="fr-tag fr-tag--md mono" title="Domaine minéraux et métaux" aria-label="Domaine minéraux et métaux" style="min-width: 2rem; background-color: var(--background-contrast-blue-ecume); color: black;">M</p> + </td> + <td> + <p style="z-index: unset; margin-bottom: 0px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;" class="fr-badge fr-badge--md fr-badge--beige-gris-galet" title="échu" aria-label="échu">échu</p> + </td> + <td><span class="">Demande</span></td> + <td><span class="">1810-01-01</span></td> + </tr> + <tr> + <td><a href="/mocked-href" title="Amadis 5" aria-label="Amadis 5">Amadis 5</a></td> + <td><span class="">Octroi</span></td> + <td><span class="small bold">Permis d'exploitation</span></td> + <td> + <p class="fr-tag fr-tag--md mono" title="Domaine géothermie" aria-label="Domaine géothermie" style="min-width: 2rem; background-color: var(--background-contrast-pink-tuile); color: black;">G</p> + </td> + <td> + <p style="z-index: unset; margin-bottom: 0px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;" class="fr-badge fr-badge--md fr-badge--green-bourgeon" title="valide" aria-label="valide">valide</p> + </td> + <td><span class="">Avis des services et commissions consultatives</span></td> + <td><span class="">2022-01-01</span></td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + </div> + <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-end" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0"> + <!----> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideActivites.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideActivites.html new file mode 100644 index 0000000000000000000000000000000000000000..69ad48dfc449ff63d5313e4b4a189da527f97412 --- /dev/null +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideActivites.html @@ -0,0 +1,25 @@ +<div> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Tableau de bord</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div class="fr-tabs" style="--tabs-height: 0px;"> + <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable"> + <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Brouillons" aria-selected="false" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li> + <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Activités" aria-selected="true" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li> + </ul> + <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-start" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0"> + <!----> + </div> + <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0"> + <p>Aucune activité supprimable</p> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideBrouillon.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideBrouillon.html new file mode 100644 index 0000000000000000000000000000000000000000..3eaa5ac02f173445c861b689bb6070c2e057b639 --- /dev/null +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_TableauVideBrouillon.html @@ -0,0 +1,25 @@ +<div> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Tableau de bord</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div class="fr-tabs" style="--tabs-height: 0px;"> + <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable"> + <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Brouillons" aria-selected="true" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li> + <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Activités" aria-selected="false" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li> + </ul> + <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0"> + <p>Aucune étape en brouillon</p> + </div> + <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-end" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0"> + <!----> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorActivites.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorActivites.html new file mode 100644 index 0000000000000000000000000000000000000000..38c87d3a0be3bfca184dcea5307c8800f48862c6 --- /dev/null +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorActivites.html @@ -0,0 +1,31 @@ +<div> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Tableau de bord</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div class="fr-tabs" style="--tabs-height: 0px;"> + <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable"> + <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Brouillons" aria-selected="false" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li> + <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Activités" aria-selected="true" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li> + </ul> + <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-start" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0"> + <!----> + </div> + <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0"> + <div class="" style="display: flex; justify-content: center;"> + <!----> + <div class="fr-alert fr-alert--error fr-alert--sm" role="alert"> + <p>Une erreur</p> + </div> + <!----> + </div> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorBrouillon.html b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorBrouillon.html new file mode 100644 index 0000000000000000000000000000000000000000..21af986a83ab1011dd99bb39472702cb0d185235 --- /dev/null +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.stories_snapshots_WithErrorBrouillon.html @@ -0,0 +1,31 @@ +<div> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Tableau de bord</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div class="fr-tabs" style="--tabs-height: 0px;"> + <ul class="fr-tabs__list" role="tablist" aria-label="Affichage des titres contenant un brouillon, ou des activités supprimable"> + <li role="presentation"><button id="tabpanel-brouillons-tdb_super_vues" class="fr-tabs__tab fr-icon-draft-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-label="Brouillons" aria-selected="true" aria-controls="tabpanel-brouillons-tdb_super_vues-panel">Brouillons</button></li> + <li role="presentation"><button id="tabpanel-activites-tdb_super_vues" class="fr-tabs__tab fr-icon-delete-bin-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-label="Activités" aria-selected="false" aria-controls="tabpanel-activites-tdb_super_vues-panel">Activités</button></li> + </ul> + <div id="tabpanel-brouillons-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-brouillons-tdb_super_vues" tabindex="0"> + <div class="" style="display: flex; justify-content: center;"> + <!----> + <div class="fr-alert fr-alert--error fr-alert--sm" role="alert"> + <p>Une erreur</p> + </div> + <!----> + </div> + </div> + <div id="tabpanel-activites-tdb_super_vues-panel" class="fr-tabs__panel fr-tabs__panel--direction-end" role="tabpanel" aria-labelledby="tabpanel-activites-tdb_super_vues" tabindex="0"> + <!----> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/dashboard/pure-super-dashboard.tsx b/packages/ui/src/components/dashboard/pure-super-dashboard.tsx index 993d95bb03baf07d1de68e2097305729414df3ca..ec83dd855a144d43eece1e2e0478a39827f84812 100644 --- a/packages/ui/src/components/dashboard/pure-super-dashboard.tsx +++ b/packages/ui/src/components/dashboard/pure-super-dashboard.tsx @@ -1,6 +1,6 @@ import { defineComponent, onMounted, ref } from 'vue' import { Column, TableSimple } from '../_ui/table-simple' -import { nomColumn, statutCell, typeCell, domaineColumn, domaineCell } from '@/components/titres/table-utils' +import { nomColumn, statutCell, typeCell, domaineColumn, domaineCell, typeColumn } from '@/components/titres/table-utils' import { SuperTitre } from 'camino-common/src/titres' import { LoadingElement } from '@/components/_ui/functional-loader' import { AsyncData } from '@/api/client-rest' @@ -8,18 +8,31 @@ import { ComponentColumnData, JSXElementColumnData, TableRow, TextColumnData } f import { DashboardApiClient } from './dashboard-api-client' import { User } from 'camino-common/src/roles' import { PageContentHeader } from '../_common/page-header-content' -import { isNotNullNorUndefinedNorEmpty } from 'camino-common/src/typescript-tools' -import { CaminoRouterLink } from '@/router/camino-router-link' +import { exhaustiveCheck, isNotNullNorUndefinedNorEmpty } from 'camino-common/src/typescript-tools' +import { CaminoRouterLink, routerQueryToString } from '@/router/camino-router-link' import { DemarchesTypes } from 'camino-common/src/static/demarchesTypes' import { capitalize } from 'camino-common/src/strings' import { EtapesTypes } from 'camino-common/src/static/etapesTypes' import { getDomaineId } from 'camino-common/src/static/titresTypes' +import { computed } from 'vue' +import { Tab, Tabs } from '../_ui/tabs' +import { CaminoRouteLocation } from '@/router/routes' +import { CaminoRouter } from '@/typings/vue-router' +import { ActiviteSuper } from 'camino-common/src/activite' +import { activitesColonneIdAnnee } from '../activites' +import { getPeriode } from 'camino-common/src/static/frequence' +import { ActivitesTypes } from 'camino-common/src/static/activitesTypes' +import { ActiviteStatut } from '../_common/activite-statut' +import { fr } from '@codegouvfr/react-dsfr' +import { ACTIVITES_STATUTS_IDS } from 'camino-common/src/static/activitesStatuts' interface Props { - apiClient: Pick<DashboardApiClient, 'getTitresAvecEtapeEnBrouillon'> + apiClient: Pick<DashboardApiClient, 'getTitresAvecEtapeEnBrouillon' | 'getActivitesSuper'> user: User + route: CaminoRouteLocation + router: Pick<CaminoRouter, 'push'> } -const columns = [ +const brouillonsColumns = [ nomColumn, { id: 'demarche_type', contentTitle: 'Type de démarche' }, { id: 'titre_type', contentTitle: 'Type de titre' }, @@ -28,17 +41,70 @@ const columns = [ { id: 'etape_brouillon', contentTitle: 'Étape en brouillon' }, { id: 'etape_date', contentTitle: "Date de l'étape" }, ] as const satisfies Column[] -type ColumnId = (typeof columns)[number]['id'] +type BrouillonColumnId = (typeof brouillonsColumns)[number]['id'] + +const activitesColumns = [ + nomColumn, + typeColumn, + { id: activitesColonneIdAnnee, contentTitle: 'Année' }, + { id: 'periode', contentTitle: 'Période' }, + { id: 'activite_type', contentTitle: 'Type de rapport' }, + { id: 'statut', contentTitle: 'Statut' }, +] as const satisfies Column[] +type ActiviteColumnId = (typeof activitesColumns)[number]['id'] + +const tabs = ['brouillons', 'activites'] as const +type TabId = (typeof tabs)[number] export const PureSuperDashboard = defineComponent<Props>(props => { - const data = ref<AsyncData<TableRow<ColumnId>[]>>({ status: 'LOADING' }) + const brouillonsData = ref<AsyncData<TableRow<BrouillonColumnId>[]>>({ status: 'LOADING' }) + const activitesData = ref<AsyncData<TableRow<ActiviteColumnId>[]>>({ status: 'LOADING' }) + + type BrouillonColumns = (typeof brouillonsColumns)[number]['id'] - type Columns = (typeof columns)[number]['id'] + const tabId = computed<TabId>(() => routerQueryToString(props.route.query.vueId, 'brouillons') as TabId) - const titresLignesBuild = (titres: SuperTitre[]): TableRow<Columns>[] => { + const vues = [ + { + id: 'brouillons', + icon: 'fr-icon-draft-line', + title: 'Brouillons', + renderContent: () => ( + <LoadingElement + data={brouillonsData.value} + renderItem={item => { + if (isNotNullNorUndefinedNorEmpty(item)) { + return <TableSimple caption={{ value: 'Titres avec une étape en brouillon', visible: true }} columns={brouillonsColumns} rows={item} /> + } + + return <p>Aucune étape en brouillon</p> + }} + /> + ), + }, + { + id: 'activites', + icon: 'fr-icon-delete-bin-line', + title: 'Activités', + renderContent: () => ( + <LoadingElement + data={activitesData.value} + renderItem={item => { + if (isNotNullNorUndefinedNorEmpty(item)) { + return <TableSimple caption={{ value: 'Activités supprimables', visible: true }} columns={activitesColumns} rows={item} /> + } + + return <p>Aucune activité supprimable</p> + }} + /> + ), + }, + ] as const satisfies readonly Tab<TabId>[] + + const titresLignesBuild = (titres: SuperTitre[]): TableRow<BrouillonColumns>[] => { return titres.map(titre => { const columns: { - [key in Columns]: JSXElementColumnData | ComponentColumnData | TextColumnData + [key in BrouillonColumns]: JSXElementColumnData | ComponentColumnData | TextColumnData } = { nom: { type: 'jsx', @@ -62,38 +128,116 @@ export const PureSuperDashboard = defineComponent<Props>(props => { return { id: titre.titre_slug, - link: { name: 'titre', params: { id: titre.titre_slug } }, + link: null, + columns, + } + }) + } + const activitesLignesBuild = (activites: ActiviteSuper[]): TableRow<ActiviteColumnId>[] => { + return activites.map(activite => { + const columns: { + [key in ActiviteColumnId]: JSXElementColumnData | ComponentColumnData | TextColumnData + } = { + nom: { + type: 'jsx', + jsxElement: ( + <CaminoRouterLink to={{ name: 'activite', params: { activiteId: activite.id } }} isDisabled={false} title={activite.titre_nom}> + {capitalize(activite.titre_nom)} + </CaminoRouterLink> + ), + value: activite.titre_nom, + }, + type: typeCell(activite.titre_type_id), + periode: { + type: 'text', + value: getPeriode(ActivitesTypes[activite.type_id].frequenceId, activite.periode_id), + }, + annee: { + type: 'text', + value: activite.annee, + }, + activite_type: { + type: 'text', + value: ActivitesTypes[activite.type_id].nom, + }, + statut: { + type: 'jsx', + jsxElement: <ActiviteStatut activiteStatutId={activite.activite_statut_id} />, + value: activite.activite_statut_id, + }, + } + + return { + class: activite.activite_statut_id === ACTIVITES_STATUTS_IDS.DEPOSE ? [fr.cx('fr-label--error')] : undefined, + id: activite.id, + link: null, columns, } }) } - onMounted(async () => { - const titres = await props.apiClient.getTitresAvecEtapeEnBrouillon() - if ('message' in titres) { - data.value = { - status: 'NEW_ERROR', - error: titres, + const loadBrouillons = async () => { + if (brouillonsData.value.status !== 'LOADED') { + const titres = await props.apiClient.getTitresAvecEtapeEnBrouillon() + if ('message' in titres) { + brouillonsData.value = { + status: 'NEW_ERROR', + error: titres, + } + } else { + brouillonsData.value = { + status: 'LOADED', + value: titresLignesBuild(titres), + } } - } else { - data.value = { - status: 'LOADED', - value: titresLignesBuild(titres), + } + } + const loadActivites = async () => { + if (activitesData.value.status !== 'LOADED') { + const activites = await props.apiClient.getActivitesSuper() + if ('message' in activites) { + activitesData.value = { + status: 'NEW_ERROR', + error: activites, + } + } else { + activitesData.value = { + status: 'LOADED', + value: activitesLignesBuild(activites), + } } } + } + + const reload = async (tabId: TabId) => { + switch (tabId) { + case 'brouillons': + await loadBrouillons() + break + case 'activites': + await loadActivites() + break + default: + exhaustiveCheck(tabId) + } + } + onMounted(async () => { + await reload(tabId.value) }) return () => ( <div> <PageContentHeader nom="Tableau de bord" download={null} renderButton={null} /> - <LoadingElement - data={data.value} - renderItem={item => { - if (isNotNullNorUndefinedNorEmpty(item)) { - return <TableSimple caption={{ value: 'Titres avec une étape en brouillon', visible: true }} columns={columns} rows={item} /> - } - - return <p>Aucune étape en brouillon</p> + + <Tabs + id="tdb_super_vues" + initTab={tabId.value} + tabs={vues} + tabsTitle={'Affichage des titres contenant un brouillon, ou des activités supprimable'} + tabClicked={async newTabId => { + const query: CaminoRouteLocation['query'] = { ...props.route.query, vueId: newTabId } + await props.router.push({ name: props.route.name ?? undefined, query, params: props.route.params }) + await reload(newTabId) }} /> </div> @@ -101,4 +245,4 @@ export const PureSuperDashboard = defineComponent<Props>(props => { }) // @ts-ignore waiting for https://github.com/vuejs/core/issues/7833 -PureSuperDashboard.props = ['apiClient', 'user'] +PureSuperDashboard.props = ['apiClient', 'user', 'route', 'router'] diff --git a/packages/ui/src/components/utilisateur.stories.tsx b/packages/ui/src/components/utilisateur.stories.tsx index d6a7d85cae1d112e76c1ebb0e57045f54bd53ff8..d78efd8f03cc07487bf471c620a48b0361f5fbc2 100644 --- a/packages/ui/src/components/utilisateur.stories.tsx +++ b/packages/ui/src/components/utilisateur.stories.tsx @@ -31,12 +31,12 @@ const apiClientMock: Props['apiClient'] = { removeUtilisateur: params => { deleteUtilisateur(params) - return Promise.resolve() + return Promise.resolve({ id: params }) }, updateUtilisateur: params => { updateUtilisateur(params) - return Promise.resolve() + return Promise.resolve({ id: params.id }) }, getQGISToken: () => new Promise(resolve => setTimeout(() => resolve({ token: qgisTokenValidator.parse('token123'), url: 'https://google.fr' }), 1000)), } diff --git a/packages/ui/src/components/utilisateur.tsx b/packages/ui/src/components/utilisateur.tsx index c171c9f75911b5d6585597f726974949e71e6ff7..99a837b2fe1846e058bce893f2af85ac46722a37 100644 --- a/packages/ui/src/components/utilisateur.tsx +++ b/packages/ui/src/components/utilisateur.tsx @@ -30,14 +30,16 @@ export const Utilisateur = defineComponent({ if (isMe) { // TODO 2023-10-23 type window.location pour s'appuyer sur nos routes rest et pas sur n'importe quoi window.location.replace(`/apiUrl/rest/utilisateurs/${userId}/delete`) + return { id: userId } } else { - await utilisateurApiClient.removeUtilisateur(userId) + const value = await utilisateurApiClient.removeUtilisateur(userId) + if ('message' in value) { + return value + } router.push({ name: 'utilisateurs', params: {} }) + return value } } - const updateUtilisateur = async (utilisateur: UtilisateurToEdit) => { - await utilisateurApiClient.updateUtilisateur(utilisateur) - } const passwordUpdate = () => { window.location.replace('/apiUrl/changerMotDePasse') } @@ -57,7 +59,7 @@ export const Utilisateur = defineComponent({ {utilisateurId.value ? ( <PureUtilisateur passwordUpdate={passwordUpdate} - apiClient={{ ...utilisateurApiClient, updateUtilisateur, removeUtilisateur: deleteUtilisateur }} + apiClient={{ ...utilisateurApiClient, removeUtilisateur: deleteUtilisateur }} utilisateurId={utilisateurId.value} user={user} entreprises={entreprises.value} @@ -110,8 +112,12 @@ export const PureUtilisateur = defineComponent<Props>(props => { } const updateUtilisateur = async (utilisateur: UtilisateurToEdit) => { - await props.apiClient.updateUtilisateur(utilisateur) + const result = await props.apiClient.updateUtilisateur(utilisateur) + if ('message' in result) { + return result + } await get() + return result } return () => ( @@ -162,15 +168,7 @@ export const PureUtilisateur = defineComponent<Props>(props => { /> {removePopup.value && utilisateur.value.status === 'LOADED' ? ( - <RemovePopup - close={() => (removePopup.value = !removePopup.value)} - utilisateur={utilisateur.value.value} - deleteUser={async () => { - if (utilisateur.value.status === 'LOADED') { - await props.apiClient.removeUtilisateur(utilisateur.value.value.id) - } - }} - /> + <RemovePopup close={() => (removePopup.value = !removePopup.value)} utilisateur={utilisateur.value.value} apiClient={props.apiClient} /> ) : null} </div> ) diff --git a/packages/ui/src/components/utilisateur/permission-edit.stories.tsx b/packages/ui/src/components/utilisateur/permission-edit.stories.tsx index 5196442b99cebb2dd0d7a5238ff6552a89f7339e..ae84f7d788622cb99e48d7136355f7e59bc4d562 100644 --- a/packages/ui/src/components/utilisateur/permission-edit.stories.tsx +++ b/packages/ui/src/components/utilisateur/permission-edit.stories.tsx @@ -23,7 +23,7 @@ export const Default: StoryFn = () => ( new Promise(resolve => setTimeout(() => { update(user) - resolve() + resolve({ id: user.id }) }, 1000) ), }} @@ -39,7 +39,7 @@ export const Administration: StoryFn = () => ( new Promise(resolve => setTimeout(() => { update(user) - resolve() + resolve({ id: user.id }) }, 1000) ), }} @@ -63,7 +63,7 @@ export const Entreprise: StoryFn = () => ( new Promise(resolve => setTimeout(() => { update(user) - resolve() + resolve({ id: user.id }) }, 1000) ), }} @@ -80,7 +80,7 @@ export const UserAdminCanEditDefautIntoLecteur: StoryFn = () => ( new Promise(resolve => setTimeout(() => { update(user) - resolve() + resolve({ id: user.id }) }, 1000) ), }} diff --git a/packages/ui/src/components/utilisateur/permission-edit.tsx b/packages/ui/src/components/utilisateur/permission-edit.tsx index 1b9114bf16ca25d4d73bd8b6624a18f6cd25970d..f9f4667e1ab8b20a2df5bf690ba8005b806ddff7 100644 --- a/packages/ui/src/components/utilisateur/permission-edit.tsx +++ b/packages/ui/src/components/utilisateur/permission-edit.tsx @@ -1,4 +1,4 @@ -import { isEntrepriseOrBureauDetudeRole, Role, User, UserNotNull, isAdministration, isSuper, isEntrepriseOrBureauDEtude, isAdministrationRole } from 'camino-common/src/roles' +import { isEntrepriseOrBureauDetudeRole, Role, User, UserNotNull, isAdministration, isSuper, isEntrepriseOrBureauDEtude, isAdministrationRole, UtilisateurId } from 'camino-common/src/roles' import { computed, defineComponent, ref } from 'vue' import { AdministrationId, Administrations, sortedAdministrations } from 'camino-common/src/static/administrations' import { Entreprise, EntrepriseId } from 'camino-common/src/entreprise' @@ -12,6 +12,10 @@ import { DsfrSelect } from '../_ui/dsfr-select' import { DsfrButton, DsfrButtonIcon } from '../_ui/dsfr-button' import { LabelWithValue } from '../_ui/label-with-value' import { DsfrTag } from '../_ui/tag' +import { CaminoError } from 'camino-common/src/zod-tools' +import { AsyncData } from '@/api/client-rest' +import { LoadingElement } from '../_ui/functional-loader' +import { fr } from '@codegouvfr/react-dsfr' interface Props { user: User @@ -24,8 +28,12 @@ export const PermissionDisplay = defineComponent<Props>(props => { const mode = ref<'read' | 'edit'>('read') const updateUtilisateur = async (utilisateur: UtilisateurToEdit) => { - await props.apiClient.updateUtilisateur(utilisateur) + const value = await props.apiClient.updateUtilisateur(utilisateur) + if ('message' in value) { + return value + } mode.value = 'read' + return value } return () => ( @@ -48,7 +56,7 @@ export const PermissionDisplay = defineComponent<Props>(props => { <LabelWithValue title={`Entreprise${props.utilisateur.entrepriseIds.length > 0 ? 's' : ''}`} item={ - <ul class="fr-tags-group"> + <ul class={fr.cx('fr-tags-group')}> {isEntrepriseOrBureauDEtude(props.utilisateur) && props.utilisateur.entrepriseIds.map(entrepriseId => { const e = props.entreprises.find(({ id }) => id === entrepriseId) @@ -89,11 +97,12 @@ type PermissionEditProps = { user: User utilisateur: UserNotNull entreprises: Entreprise[] - updateUtilisateur: (utilisateur: UtilisateurToEdit) => Promise<void> + updateUtilisateur: (utilisateur: UtilisateurToEdit) => Promise<{ id: UtilisateurId } | CaminoError<string>> cancelEdition: () => void } const PermissionEdit = defineComponent<PermissionEditProps>(props => { + const asyncData = ref<AsyncData<{ id: UtilisateurId }>>({ status: 'LOADED', value: { id: props.utilisateur.id } }) const updatingUtilisateur = ref<UtilisateurToEdit>({ id: props.utilisateur.id, role: props.utilisateur.role, @@ -127,6 +136,7 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => { const save = async () => { if (complete.value) { + asyncData.value = { status: 'LOADING' } if (!isAdministrationRole(updatingUtilisateur.value.role)) { updatingUtilisateur.value.administrationId = null } @@ -135,7 +145,12 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => { updatingUtilisateur.value.entrepriseIds = [] } - await props.updateUtilisateur(updatingUtilisateur.value) + const result = await props.updateUtilisateur(updatingUtilisateur.value) + if ('message' in result) { + asyncData.value = { status: 'NEW_ERROR', error: result } + } else { + asyncData.value = { status: 'LOADED', value: result } + } } } @@ -153,10 +168,10 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => { <LabelWithValue title="Rôles" item={ - <ul class="fr-tags-group"> + <ul class={fr.cx('fr-tags-group')}> {assignableRoles.map(role => ( <li> - <DsfrButton class="fr-tag" onClick={() => roleToggle(role)} aria-pressed={updatingUtilisateur.value.role === role} title={capitalize(role)} /> + <DsfrButton class={fr.cx('fr-tag')} onClick={() => roleToggle(role)} aria-pressed={updatingUtilisateur.value.role === role} title={capitalize(role)} /> </li> ))} </ul> @@ -200,9 +215,10 @@ const PermissionEdit = defineComponent<PermissionEditProps>(props => { <LabelWithValue title="" item={ - <div> - <DsfrButton title="Annuler" buttonType="secondary" onClick={props.cancelEdition} /> - <DsfrButton class="fr-ml-2w" title="Enregistrer" buttonType="primary" onClick={save} disabled={!complete.value} /> + <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> + <DsfrButton title="Annuler" buttonType="secondary" onClick={props.cancelEdition} disabled={asyncData.value.status === 'LOADING'} /> + <DsfrButton title="Enregistrer" buttonType="primary" onClick={save} disabled={!complete.value || asyncData.value.status === 'LOADING'} /> + {asyncData.value.status !== 'LOADED' ? <LoadingElement data={asyncData.value} renderItem={_ => null} /> : null} </div> } /> diff --git a/packages/ui/src/components/utilisateur/remove-popup.stories.tsx b/packages/ui/src/components/utilisateur/remove-popup.stories.tsx index c4847e81f8ff88c1088adc0838cecb725dbad385..934735712dc06383b6c2101ca7617df720ffd94e 100644 --- a/packages/ui/src/components/utilisateur/remove-popup.stories.tsx +++ b/packages/ui/src/components/utilisateur/remove-popup.stories.tsx @@ -1,6 +1,7 @@ import { action } from '@storybook/addon-actions' import { Meta, StoryFn } from '@storybook/vue3' import { RemovePopup } from './remove-popup' +import { utilisateurIdValidator } from 'camino-common/src/roles' const meta: Meta = { title: 'Components/Utilisateur/RemovePopup', @@ -13,11 +14,13 @@ const close = action('close') export const Default: StoryFn = () => ( <RemovePopup - utilisateur={{ nom: 'Nom', prenom: 'Prénom' }} - deleteUser={() => { - deleteUser() + utilisateur={{ id: utilisateurIdValidator.parse('id'), nom: 'Nom', prenom: 'Prénom' }} + apiClient={{ + removeUtilisateur: utilisateurId => { + deleteUser(utilisateurId) - return Promise.resolve() + return Promise.resolve({ id: utilisateurId }) + }, }} close={close} /> diff --git a/packages/ui/src/components/utilisateur/remove-popup.tsx b/packages/ui/src/components/utilisateur/remove-popup.tsx index 24e216cc9d018b700bfcb11d2a29fc6844969dd6..3fa9da80eef48e138eabd9ede386acc26b8780a7 100644 --- a/packages/ui/src/components/utilisateur/remove-popup.tsx +++ b/packages/ui/src/components/utilisateur/remove-popup.tsx @@ -1,10 +1,12 @@ import { FunctionalComponent } from 'vue' import { FunctionalPopup } from '../_ui/functional-popup' import { Alert } from '@/components/_ui/alert' +import { UtilisateurApiClient } from './utilisateur-api-client' +import { UtilisateurId } from 'camino-common/src/roles' interface Props { - utilisateur: { nom: string; prenom: string } + utilisateur: { id: UtilisateurId; nom: string; prenom: string } close: () => void - deleteUser: () => Promise<void> + apiClient: Pick<UtilisateurApiClient, 'removeUtilisateur'> } export const RemovePopup: FunctionalComponent<Props> = props => { @@ -24,5 +26,13 @@ export const RemovePopup: FunctionalComponent<Props> = props => { /> ) - return <FunctionalPopup title={`Suppression du compte utilisateur`} content={content} close={props.close} validate={{ action: props.deleteUser, text: 'Supprimer' }} canValidate={true} /> + return ( + <FunctionalPopup + title={`Suppression du compte utilisateur`} + content={content} + close={props.close} + validate={{ action: () => props.apiClient.removeUtilisateur(props.utilisateur.id), text: 'Supprimer' }} + canValidate={true} + /> + ) } diff --git a/packages/ui/src/components/utilisateur/utilisateur-api-client.ts b/packages/ui/src/components/utilisateur/utilisateur-api-client.ts index 56322beb529120fb8a6581c203e736055def7e57..9600aad97ab85497a39a574841e530f81b0d755d 100644 --- a/packages/ui/src/components/utilisateur/utilisateur-api-client.ts +++ b/packages/ui/src/components/utilisateur/utilisateur-api-client.ts @@ -1,13 +1,13 @@ import { QGISTokenRest, UtilisateurToEdit, UtilisateursSearchParamsInput, UtilisateursTable } from 'camino-common/src/utilisateur' -import { getWithJson, newGetWithJson, newPostWithJson, postWithJson } from '../../api/client-rest' +import { newGetWithJson, newPostWithJson } from '../../api/client-rest' import { UserNotNull, UtilisateurId } from 'camino-common/src/roles' import { CaminoError } from 'camino-common/src/zod-tools' export interface UtilisateurApiClient { getUtilisateur: (userId: UtilisateurId) => Promise<CaminoError<string> | UserNotNull> - removeUtilisateur: (userId: UtilisateurId) => Promise<void> - updateUtilisateur: (user: UtilisateurToEdit) => Promise<void> + removeUtilisateur: (userId: UtilisateurId) => Promise<{ id: UtilisateurId } | CaminoError<string>> + updateUtilisateur: (user: UtilisateurToEdit) => Promise<{ id: UtilisateurId } | CaminoError<string>> getQGISToken: () => Promise<CaminoError<string> | QGISTokenRest> getUtilisateurs: (params: UtilisateursSearchParamsInput) => Promise<CaminoError<string> | UtilisateursTable> } @@ -19,7 +19,7 @@ export const utilisateurApiClient: UtilisateurApiClient = { getUtilisateur: async (userId: UtilisateurId) => { return newGetWithJson('/rest/utilisateurs/:id', { id: userId }) }, - removeUtilisateur: async (userId: UtilisateurId) => getWithJson('/rest/utilisateurs/:id/delete', { id: userId }), - updateUtilisateur: async (utilisateur: UtilisateurToEdit) => postWithJson('/rest/utilisateurs/:id/permission', { id: utilisateur.id }, utilisateur), + removeUtilisateur: async (userId: UtilisateurId) => newGetWithJson('/rest/utilisateurs/:id/delete', { id: userId }), + updateUtilisateur: async (utilisateur: UtilisateurToEdit) => newPostWithJson('/rest/utilisateurs/:id/permission', { id: utilisateur.id }, utilisateur), getQGISToken: async () => newPostWithJson('/rest/utilisateur/generateQgisToken', {}, {}), }