From 376d9ccd76ce491547268e5bebfe73af6ccafa69 Mon Sep 17 00:00:00 2001 From: dthib Date: Thu, 10 Oct 2024 10:16:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend/fiches):=20ajout=20d'une=20route?= =?UTF-8?q?=20de=20synth=C3=A8se=20(#3347)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/app.module.ts | 2 + .../common/controllers/version.controller.ts | 3 +- .../src/common/models/count-synthese.dto.ts | 16 + .../src/common/models/modified-since.enum.ts | 32 ++ .../controllers/fiches-action.controller.ts | 61 ++++ backend/src/fiches/fiches-action.module.ts | 14 + backend/src/fiches/models/axe.table.ts | 32 ++ .../fiches/models/fiche-action-axe.table.ts | 16 + .../fiche-action-partenaire-tag.table.ts | 18 ++ .../models/fiche-action-pilote.table.ts | 24 ++ .../models/fiche-action-referent.table.ts | 19 ++ .../models/fiche-action-service.table.ts | 18 ++ .../src/fiches/models/fiche-action.table.ts | 130 ++++++++ .../get-fiches-action-synthese.response.ts | 14 + .../get-fiches-actions-filter.request.ts | 77 +++++ .../models/plan-action-type-category.table.ts | 8 + .../fiches/models/plan-action-type.table.ts | 21 ++ .../fiches-action-synthese.service.ts | 306 ++++++++++++++++++ .../controllers/indicateurs.controller.ts | 3 +- .../models/action-definition.table.ts | 64 ++++ .../taxonomie/models/partenaire-tag.table.ts | 14 + .../taxonomie/models/personne-tag.table.ts | 10 + .../src/taxonomie/models/service-tag.table.ts | 10 + backend/src/taxonomie/models/tag.basetable.ts | 10 + 24 files changed, 920 insertions(+), 2 deletions(-) create mode 100644 backend/src/common/models/count-synthese.dto.ts create mode 100644 backend/src/common/models/modified-since.enum.ts create mode 100644 backend/src/fiches/controllers/fiches-action.controller.ts create mode 100644 backend/src/fiches/fiches-action.module.ts create mode 100644 backend/src/fiches/models/axe.table.ts create mode 100644 backend/src/fiches/models/fiche-action-axe.table.ts create mode 100644 backend/src/fiches/models/fiche-action-partenaire-tag.table.ts create mode 100644 backend/src/fiches/models/fiche-action-pilote.table.ts create mode 100644 backend/src/fiches/models/fiche-action-referent.table.ts create mode 100644 backend/src/fiches/models/fiche-action-service.table.ts create mode 100644 backend/src/fiches/models/fiche-action.table.ts create mode 100644 backend/src/fiches/models/get-fiches-action-synthese.response.ts create mode 100644 backend/src/fiches/models/get-fiches-actions-filter.request.ts create mode 100644 backend/src/fiches/models/plan-action-type-category.table.ts create mode 100644 backend/src/fiches/models/plan-action-type.table.ts create mode 100644 backend/src/fiches/services/fiches-action-synthese.service.ts create mode 100644 backend/src/referentiels/models/action-definition.table.ts create mode 100644 backend/src/taxonomie/models/partenaire-tag.table.ts create mode 100644 backend/src/taxonomie/models/personne-tag.table.ts create mode 100644 backend/src/taxonomie/models/service-tag.table.ts create mode 100644 backend/src/taxonomie/models/tag.basetable.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5fc962d952..38b4409e02 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,6 +4,7 @@ import { AuthModule } from './auth/auth.module'; import { CollectivitesModule } from './collectivites/collectivites.module'; import { CommonModule } from './common/common.module'; import { validateBackendConfiguration } from './common/services/backend-configuration.service'; +import { FichesActionModule } from './fiches/fiches-action.module'; import { IndicateursModule } from './indicateurs/indicateurs.module'; import { SheetModule } from './spreadsheets/sheet.module'; import { TrpcRouter } from './trpc.router'; @@ -21,6 +22,7 @@ import { TrpcModule } from './trpc/trpc.module'; CollectivitesModule, IndicateursModule, AuthModule, + FichesActionModule, ], controllers: [], exports: [TrpcRouter], diff --git a/backend/src/common/controllers/version.controller.ts b/backend/src/common/controllers/version.controller.ts index 602d11b13f..93d8dad81e 100644 --- a/backend/src/common/controllers/version.controller.ts +++ b/backend/src/common/controllers/version.controller.ts @@ -1,12 +1,13 @@ import { createZodDto } from '@anatine/zod-nestjs'; import { Controller, Get } from '@nestjs/common'; -import { ApiOkResponse } from '@nestjs/swagger'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { PublicEndpoint } from '../../auth/decorators/public-endpoint.decorator'; import { versionResponseSchema } from '../models/version.models'; /** * Création des classes de réponse à partir du schema pour générer automatiquement la documentation OpenAPI */ +@ApiTags('Application') export class VersionResponseClass extends createZodDto(versionResponseSchema) {} @Controller() export class VersionController { diff --git a/backend/src/common/models/count-synthese.dto.ts b/backend/src/common/models/count-synthese.dto.ts new file mode 100644 index 0000000000..a2a5734dd7 --- /dev/null +++ b/backend/src/common/models/count-synthese.dto.ts @@ -0,0 +1,16 @@ +import { extendApi, extendZodWithOpenApi } from '@anatine/zod-openapi'; +import { z } from 'zod'; +extendZodWithOpenApi(z); + +export const countSyntheseValeurSchema = extendApi( + z.object({ + count: z.number().int(), + valeur: z.union([z.string(), z.number(), z.boolean(), z.null()]), + }), +); +export type CountSyntheseValeurType = z.infer; + +export const countSyntheseSchema = extendApi( + z.record(z.string(), countSyntheseValeurSchema), +); +export type CountSyntheseType = z.infer; diff --git a/backend/src/common/models/modified-since.enum.ts b/backend/src/common/models/modified-since.enum.ts new file mode 100644 index 0000000000..087b7c87fe --- /dev/null +++ b/backend/src/common/models/modified-since.enum.ts @@ -0,0 +1,32 @@ +import { DateTime } from 'luxon'; +import { z } from 'zod'; + +export enum ModifiedSinceEnum { + LAST_90_DAYS = 'last-90-days', + LAST_60_DAYS = 'last-60-days', + LAST_30_DAYS = 'last-30-days', + LAST_15_DAYS = 'last-15-days', +} + +export const modifiedSinceSchema = z.enum([ + ModifiedSinceEnum.LAST_90_DAYS, + ModifiedSinceEnum.LAST_60_DAYS, + ModifiedSinceEnum.LAST_30_DAYS, + ModifiedSinceEnum.LAST_15_DAYS, +]); + +export const getModifiedSinceDate = ( + modifiedSince: ModifiedSinceEnum, +): Date => { + const now = DateTime.now(); + switch (modifiedSince) { + case ModifiedSinceEnum.LAST_90_DAYS: + return now.minus({ days: 90 }).toJSDate(); + case ModifiedSinceEnum.LAST_60_DAYS: + return now.minus({ days: 60 }).toJSDate(); + case ModifiedSinceEnum.LAST_30_DAYS: + return now.minus({ days: 30 }).toJSDate(); + case ModifiedSinceEnum.LAST_15_DAYS: + return now.minus({ days: 15 }).toJSDate(); + } +}; diff --git a/backend/src/fiches/controllers/fiches-action.controller.ts b/backend/src/fiches/controllers/fiches-action.controller.ts new file mode 100644 index 0000000000..6a219b05c8 --- /dev/null +++ b/backend/src/fiches/controllers/fiches-action.controller.ts @@ -0,0 +1,61 @@ +import { createZodDto } from '@anatine/zod-nestjs'; +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { TokenInfo } from '../../auth/decorators/token-info.decorators'; +import { SupabaseJwtPayload } from '../../auth/models/auth.models'; +import { getFichesActionSyntheseSchema } from '../models/get-fiches-action-synthese.response'; +import { getFichesActionFilterRequestSchema } from '../models/get-fiches-actions-filter.request'; +import FichesActionSyntheseService from '../services/fiches-action-synthese.service'; + +/** + * Création des classes de réponse à partir du schema pour générer automatiquement la documentation OpenAPI + */ +export class GetFichesActionSyntheseResponseClass extends createZodDto( + getFichesActionSyntheseSchema, +) {} +export class GetFichesActionFilterRequestClass extends createZodDto( + getFichesActionFilterRequestSchema, +) {} + +@ApiTags('Fiches action') +@Controller('collectivites/:collectivite_id/fiches-action') +export class FichesActionController { + constructor( + private readonly fichesActionSyntheseService: FichesActionSyntheseService, + ) {} + + @Get('synthese') + @ApiOkResponse({ + type: GetFichesActionSyntheseResponseClass, + description: + "Récupération de la sythèse des fiches action d'une collectivité (ex: nombre par statut)", + }) + async getFichesActionSynthese( + @Param('collectivite_id') collectiviteId: number, + @Query() request: GetFichesActionFilterRequestClass, + @TokenInfo() tokenInfo: SupabaseJwtPayload, + ) { + return this.fichesActionSyntheseService.getFichesActionSynthese( + collectiviteId, + request, + tokenInfo, + ); + } + + @Get('') + // TODO: type it for documentation + @ApiOkResponse({ + description: "Récupération des fiches action d'une collectivité", + }) + async getFichesAction( + @Param('collectivite_id') collectiviteId: number, + @Query() request: GetFichesActionFilterRequestClass, + @TokenInfo() tokenInfo: SupabaseJwtPayload, + ) { + return this.fichesActionSyntheseService.getFichesAction( + collectiviteId, + request, + tokenInfo, + ); + } +} diff --git a/backend/src/fiches/fiches-action.module.ts b/backend/src/fiches/fiches-action.module.ts new file mode 100644 index 0000000000..c5a51ea6f7 --- /dev/null +++ b/backend/src/fiches/fiches-action.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { CollectivitesModule } from '../collectivites/collectivites.module'; +import { CommonModule } from '../common/common.module'; +import { FichesActionController } from './controllers/fiches-action.controller'; +import FichesActionSyntheseService from './services/fiches-action-synthese.service'; + +@Module({ + imports: [CommonModule, AuthModule, CollectivitesModule], + providers: [FichesActionSyntheseService], + exports: [FichesActionSyntheseService], + controllers: [FichesActionController], +}) +export class FichesActionModule {} diff --git a/backend/src/fiches/models/axe.table.ts b/backend/src/fiches/models/axe.table.ts new file mode 100644 index 0000000000..11ad2595a1 --- /dev/null +++ b/backend/src/fiches/models/axe.table.ts @@ -0,0 +1,32 @@ +import { InferInsertModel } from 'drizzle-orm'; +import { + AnyPgColumn, + integer, + pgTable, + serial, + text, + timestamp, + uuid, +} from 'drizzle-orm/pg-core'; +import { collectiviteTable } from '../../collectivites/models/collectivite.models'; +import { planActionTypeTable } from './plan-action-type.table'; + +export const axeTable = pgTable('axe', { + id: serial('id').primaryKey(), + nom: text('nom'), + collectivite_id: integer('collectivite_id') + .notNull() + .references(() => collectiviteTable.id), + parent: integer('parent').references((): AnyPgColumn => axeTable.id), + plan: integer('plan').references((): AnyPgColumn => axeTable.id), + type: integer('type').references(() => planActionTypeTable.id), + created_at: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + modified_at: timestamp('modified_at', { withTimezone: true }) + .notNull() + .defaultNow(), + modified_by: uuid('modified_by'), // TODO references auth.uid + panier_id: integer('panier_id'), // TODO references panier +}); +export type CreateAxeType = InferInsertModel; diff --git a/backend/src/fiches/models/fiche-action-axe.table.ts b/backend/src/fiches/models/fiche-action-axe.table.ts new file mode 100644 index 0000000000..3fe9abe079 --- /dev/null +++ b/backend/src/fiches/models/fiche-action-axe.table.ts @@ -0,0 +1,16 @@ +import { integer, pgTable, primaryKey } from 'drizzle-orm/pg-core'; +import { axeTable } from './axe.table'; +import { ficheActionTable } from './fiche-action.table'; + +export const ficheActionAxeTable = pgTable( + 'fiche_action_axe', + { + fiche_id: integer('fiche_id').references(() => ficheActionTable.id), + axe_id: integer('axe_id').references(() => axeTable.id), + }, + (table) => { + return { + pk: primaryKey({ columns: [table.fiche_id, table.axe_id] }), + }; + }, +); diff --git a/backend/src/fiches/models/fiche-action-partenaire-tag.table.ts b/backend/src/fiches/models/fiche-action-partenaire-tag.table.ts new file mode 100644 index 0000000000..65ee5db83e --- /dev/null +++ b/backend/src/fiches/models/fiche-action-partenaire-tag.table.ts @@ -0,0 +1,18 @@ +import { integer, pgTable, primaryKey } from 'drizzle-orm/pg-core'; +import { partenaireTagTable } from '../../taxonomie/models/partenaire-tag.table'; +import { ficheActionTable } from './fiche-action.table'; + +export const ficheActionPartenaireTagTable = pgTable( + 'fiche_action_partenaire_tag', + { + fiche_id: integer('fiche_id').references(() => ficheActionTable.id), + partenaire_tag_id: integer('partenaire_tag_id').references( + () => partenaireTagTable.id, + ), + }, + (table) => { + return { + pk: primaryKey({ columns: [table.fiche_id, table.partenaire_tag_id] }), + }; + }, +); diff --git a/backend/src/fiches/models/fiche-action-pilote.table.ts b/backend/src/fiches/models/fiche-action-pilote.table.ts new file mode 100644 index 0000000000..27e4dc481e --- /dev/null +++ b/backend/src/fiches/models/fiche-action-pilote.table.ts @@ -0,0 +1,24 @@ +import { integer, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; +import { personneTagTable } from '../../taxonomie/models/personne-tag.table'; +import { ficheActionTable } from './fiche-action.table'; + +export const ficheActionPiloteTable = pgTable( + 'fiche_action_pilote', + { + fiche_id: integer('fiche_id').references(() => ficheActionTable.id), + tag_id: integer('tag_id').references(() => personneTagTable.id), + user_id: uuid('user_id'), // references dcp + }, + (table) => { + return { + one_user_per_fiche: uniqueIndex('one_user_per_fiche ').on( + table.fiche_id, + table.user_id, + ), + one_tag_per_fiche: uniqueIndex('one_tag_per_fiche ').on( + table.fiche_id, + table.tag_id, + ), + }; + }, +); diff --git a/backend/src/fiches/models/fiche-action-referent.table.ts b/backend/src/fiches/models/fiche-action-referent.table.ts new file mode 100644 index 0000000000..f180ba4362 --- /dev/null +++ b/backend/src/fiches/models/fiche-action-referent.table.ts @@ -0,0 +1,19 @@ +import { integer, pgTable, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; +import { personneTagTable } from '../../taxonomie/models/personne-tag.table'; +import { ficheActionTable } from './fiche-action.table'; + +export const ficheActionReferentTable = pgTable( + 'fiche_action_referent', + { + fiche_id: integer('fiche_id').references(() => ficheActionTable.id), + tag_id: integer('tag_id').references(() => personneTagTable.id), + user_id: uuid('user_id'), // references dcp + }, + (table) => { + return { + fiche_action_referent_fiche_id_user_id_tag_id_key: uniqueIndex( + 'fiche_action_referent_fiche_id_user_id_tag_id_key ', + ).on(table.fiche_id, table.user_id, table.tag_id), + }; + }, +); diff --git a/backend/src/fiches/models/fiche-action-service.table.ts b/backend/src/fiches/models/fiche-action-service.table.ts new file mode 100644 index 0000000000..b1ed7ed7c1 --- /dev/null +++ b/backend/src/fiches/models/fiche-action-service.table.ts @@ -0,0 +1,18 @@ +import { integer, pgTable, primaryKey } from 'drizzle-orm/pg-core'; +import { serviceTagTable } from '../../taxonomie/models/service-tag.table'; +import { ficheActionTable } from './fiche-action.table'; + +export const ficheActionServiceTagTable = pgTable( + 'fiche_action_service_tag', + { + fiche_id: integer('fiche_id').references(() => ficheActionTable.id), + service_tag_id: integer('service_tag_id').references( + () => serviceTagTable.id, + ), + }, + (table) => { + return { + pk: primaryKey({ columns: [table.fiche_id, table.service_tag_id] }), + }; + }, +); diff --git a/backend/src/fiches/models/fiche-action.table.ts b/backend/src/fiches/models/fiche-action.table.ts new file mode 100644 index 0000000000..bafa5a6ff6 --- /dev/null +++ b/backend/src/fiches/models/fiche-action.table.ts @@ -0,0 +1,130 @@ +import { InferInsertModel, sql } from 'drizzle-orm'; +import { + boolean, + integer, + numeric, + pgEnum, + pgTable, + serial, + text, + timestamp, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; + +export const ficheActionPiliersEciEnum = pgEnum('fiche_action_piliers_eci', [ + 'Approvisionnement durable', + 'Écoconception', + 'Écologie industrielle (et territoriale)', + 'Économie de la fonctionnalité', + 'Consommation responsable', + 'Allongement de la durée d’usage', + 'Recyclage', +]); + +export const ficheActionResultatsAttendusEnum = pgEnum( + 'fiche_action_resultats_attendus', + [ + 'Adaptation au changement climatique', + 'Allongement de la durée d’usage', + 'Amélioration de la qualité de vie', + 'Développement des énergies renouvelables', + 'Efficacité énergétique', + 'Préservation de la biodiversité', + 'Réduction des consommations énergétiques', + 'Réduction des déchets', + 'Réduction des émissions de gaz à effet de serre', + 'Réduction des polluants atmosphériques', + 'Sobriété énergétique', + ], +); + +export enum FicheActionStatutsEnumType { + A_VENIR = 'À venir', + EN_COURS = 'En cours', + REALISE = 'Réalisé', + EN_PAUSE = 'En pause', + ABANDONNE = 'Abandonné', +} + +export const ficheActionStatutsEnum = pgEnum('fiche_action_statuts', [ + FicheActionStatutsEnumType.A_VENIR, + FicheActionStatutsEnumType.EN_COURS, + FicheActionStatutsEnumType.REALISE, + FicheActionStatutsEnumType.EN_PAUSE, + FicheActionStatutsEnumType.ABANDONNE, +]); + +export enum FicheActionCiblesEnumType { + GRAND_PUBLIC_ET_ASSOCIATIONS = 'Grand public et associations', + PUBLIC_SCOLAIRE = 'Public Scolaire', + AUTRES_COLLECTIVITES_DU_TERRITOIRE = 'Autres collectivités du territoire', + ACTEURS_ECONOMIQUES = 'Acteurs économiques', + ACTEURS_ECONOMIQUES_DU_SECTEUR_PRIMAIRE = 'Acteurs économiques du secteur primaire', + ACTEURS_ECONOMIQUES_DU_SECTEUR_SECONDAIRE = 'Acteurs économiques du secteur secondaire', + ACTEURS_ECONOMIQUES_DU_SECTEUR_TERTIAIRE = 'Acteurs économiques du secteur tertiaire', + PARTENAIRES = 'Partenaires', + COLLECTIVITE_ELLE_MEME = 'Collectivité elle-même', + ELUS_LOCAUX = 'Elus locaux', + AGENTS = 'Agents', +} + +export const ficheActionCiblesEnum = pgEnum('fiche_action_cibles', [ + FicheActionCiblesEnumType.GRAND_PUBLIC_ET_ASSOCIATIONS, + FicheActionCiblesEnumType.PUBLIC_SCOLAIRE, + FicheActionCiblesEnumType.AUTRES_COLLECTIVITES_DU_TERRITOIRE, + FicheActionCiblesEnumType.ACTEURS_ECONOMIQUES, + FicheActionCiblesEnumType.ACTEURS_ECONOMIQUES_DU_SECTEUR_PRIMAIRE, + FicheActionCiblesEnumType.ACTEURS_ECONOMIQUES_DU_SECTEUR_SECONDAIRE, + FicheActionCiblesEnumType.ACTEURS_ECONOMIQUES_DU_SECTEUR_TERTIAIRE, + FicheActionCiblesEnumType.PARTENAIRES, + FicheActionCiblesEnumType.COLLECTIVITE_ELLE_MEME, + FicheActionCiblesEnumType.ELUS_LOCAUX, + FicheActionCiblesEnumType.AGENTS, +]); + +export const ficheActionNiveauxPrioriteEnum = pgEnum( + 'fiche_action_niveaux_priorite', + ['Élevé', 'Moyen', 'Bas'], +); + +export const ficheActionTable = pgTable('fiche_action', { + modifiedAt: timestamp('modified_at', { withTimezone: true, mode: 'string' }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + id: serial('id').primaryKey().notNull(), + titre: varchar('titre', { length: 300 }).default('Nouvelle fiche'), + description: varchar('description', { length: 20000 }), + piliersEci: ficheActionPiliersEciEnum('piliers_eci').array(), + objectifs: varchar('objectifs', { length: 10000 }), + resultatsAttendus: + ficheActionResultatsAttendusEnum('resultats_attendus').array(), + cibles: ficheActionCiblesEnum('cibles').array(), + ressources: varchar('ressources', { length: 10000 }), + financements: text('financements'), + budgetPrevisionnel: numeric('budget_previsionnel', { + precision: 12, + scale: 0, + }), + statut: ficheActionStatutsEnum('statut').default( + FicheActionStatutsEnumType.A_VENIR, + ), + niveauPriorite: ficheActionNiveauxPrioriteEnum('niveau_priorite'), + dateDebut: timestamp('date_debut', { withTimezone: true, mode: 'string' }), + dateFinProvisoire: timestamp('date_fin_provisoire', { + withTimezone: true, + mode: 'string', + }), + ameliorationContinue: boolean('amelioration_continue'), + calendrier: varchar('calendrier', { length: 10000 }), + notesComplementaires: varchar('notes_complementaires', { length: 20000 }), + majTermine: boolean('maj_termine'), + collectiviteId: integer('collectivite_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + modifiedBy: uuid('modified_by').default(sql`auth.uid()`), + restreint: boolean('restreint').default(false), +}); +export type FicheActionTableType = typeof ficheActionTable; +export type CreateFicheActionType = InferInsertModel; diff --git a/backend/src/fiches/models/get-fiches-action-synthese.response.ts b/backend/src/fiches/models/get-fiches-action-synthese.response.ts new file mode 100644 index 0000000000..214cb8c479 --- /dev/null +++ b/backend/src/fiches/models/get-fiches-action-synthese.response.ts @@ -0,0 +1,14 @@ +import { extendApi, extendZodWithOpenApi } from '@anatine/zod-openapi'; +import { z } from 'zod'; +import { countSyntheseSchema } from '../../common/models/count-synthese.dto'; +extendZodWithOpenApi(z); + +export const getFichesActionSyntheseSchema = extendApi( + z.object({ + par_statut: countSyntheseSchema, + }), +); + +export type GetFichesActionSyntheseResponseType = z.infer< + typeof getFichesActionSyntheseSchema +>; diff --git a/backend/src/fiches/models/get-fiches-actions-filter.request.ts b/backend/src/fiches/models/get-fiches-actions-filter.request.ts new file mode 100644 index 0000000000..8f24e0dfe0 --- /dev/null +++ b/backend/src/fiches/models/get-fiches-actions-filter.request.ts @@ -0,0 +1,77 @@ +import { extendApi } from '@anatine/zod-openapi'; +import { z } from 'zod'; +import { modifiedSinceSchema } from '../../common/models/modified-since.enum'; +import { FicheActionCiblesEnumType } from './fiche-action.table'; + +export const getFichesActionFilterRequestSchema = extendApi( + z + .object({ + cibles: z + .string() + .transform((value) => value.split(',')) + .pipe(z.nativeEnum(FicheActionCiblesEnumType).array()) + .optional() + .openapi({ + description: 'Liste des cibles séparées par des virgules', + }), + + partenaire_tag_ids: z + .string() + .transform((value) => value.split(',')) + .pipe(z.coerce.number().array()) + .optional() + .openapi({ + description: + 'Liste des identifiants de tags de partenaires séparés par des virgules', + }), + pilote_tag_ids: z + .string() + .transform((value) => value.split(',')) + .pipe(z.coerce.number().array()) + .optional() + .openapi({ + description: + 'Liste des identifiants de tags des personnes pilote séparées par des virgules', + }), + pilote_user_ids: z + .string() + .transform((value) => value.split(',')) + .pipe(z.string().array()) + .optional() + .openapi({ + description: + 'Liste des identifiants des utilisateurs pilote séparées par des virgules', + }), + service_tag_ids: z + .string() + .transform((value) => value.split(',')) + .pipe(z.coerce.number().array()) + .optional() + .openapi({ + description: + 'Liste des identifiants de tags de services séparés par des virgules', + }), + plan_ids: z + .string() + .transform((value) => value.split(',')) + .pipe(z.coerce.number().array()) + .optional() + .openapi({ + description: + "Liste des identifiants des plans d'action séparés par des virgules", + }), + modified_after: z.string().datetime().optional().openapi({ + description: 'Uniquement les fiches modifiées après cette date', + }), + modified_since: modifiedSinceSchema.optional().openapi({ + description: + 'Filtre sur la date de modification en utilisant des valeurs prédéfinies', + }), + }) + .openapi({ + title: 'Filtre de récupération des fiches action', + }), +); +export type GetFichesActionFilterRequestType = z.infer< + typeof getFichesActionFilterRequestSchema +>; diff --git a/backend/src/fiches/models/plan-action-type-category.table.ts b/backend/src/fiches/models/plan-action-type-category.table.ts new file mode 100644 index 0000000000..c29ee7c46c --- /dev/null +++ b/backend/src/fiches/models/plan-action-type-category.table.ts @@ -0,0 +1,8 @@ +import { pgTable, text } from 'drizzle-orm/pg-core'; + +export const planActionTypeCategorieTable = pgTable( + 'plan_action_type_categorie', + { + categorie: text('categorie').primaryKey(), + }, +); diff --git a/backend/src/fiches/models/plan-action-type.table.ts b/backend/src/fiches/models/plan-action-type.table.ts new file mode 100644 index 0000000000..3ee96b6acb --- /dev/null +++ b/backend/src/fiches/models/plan-action-type.table.ts @@ -0,0 +1,21 @@ +import { pgTable, serial, text, uniqueIndex } from 'drizzle-orm/pg-core'; +import { planActionTypeCategorieTable } from './plan-action-type-category.table'; + +export const planActionTypeTable = pgTable( + 'plan_action_type', + { + id: serial('id').primaryKey(), + categorie: text('categorie') + .notNull() + .references(() => planActionTypeCategorieTable.categorie), + type: text('type').notNull(), + detail: text('detail'), + }, + (table) => { + return { + plan_action_type_categorie_type_key: uniqueIndex( + 'plan_action_type_categorie_type_key', + ).on(table.categorie, table.type), + }; + }, +); diff --git a/backend/src/fiches/services/fiches-action-synthese.service.ts b/backend/src/fiches/services/fiches-action-synthese.service.ts new file mode 100644 index 0000000000..2b1b2ff9db --- /dev/null +++ b/backend/src/fiches/services/fiches-action-synthese.service.ts @@ -0,0 +1,306 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + and, + arrayOverlaps, + count, + eq, + getTableColumns, + gte, + or, + sql, + SQL, + SQLWrapper, +} from 'drizzle-orm'; +import { PgColumn } from 'drizzle-orm/pg-core'; +import { NiveauAcces, SupabaseJwtPayload } from '../../auth/models/auth.models'; +import { AuthService } from '../../auth/services/auth.service'; +import { CountSyntheseType } from '../../common/models/count-synthese.dto'; +import { getModifiedSinceDate } from '../../common/models/modified-since.enum'; +import DatabaseService from '../../common/services/database.service'; +import { axeTable } from '../models/axe.table'; +import { ficheActionAxeTable } from '../models/fiche-action-axe.table'; +import { ficheActionPartenaireTagTable } from '../models/fiche-action-partenaire-tag.table'; +import { ficheActionPiloteTable } from '../models/fiche-action-pilote.table'; +import { ficheActionServiceTagTable } from '../models/fiche-action-service.table'; +import { + FicheActionStatutsEnumType, + ficheActionTable, +} from '../models/fiche-action.table'; +import { GetFichesActionSyntheseResponseType } from '../models/get-fiches-action-synthese.response'; +import { GetFichesActionFilterRequestType } from '../models/get-fiches-actions-filter.request'; + +@Injectable() +export default class FichesActionSyntheseService { + private readonly logger = new Logger(FichesActionSyntheseService.name); + + private readonly FICHE_ACTION_PARTENAIRE_TAGS_QUERY_ALIAS = + 'ficheActionPartenaireTags'; + private readonly FICHE_ACTION_PARTENAIRE_TAGS_QUERY_FIELD = + 'partenaire_tag_ids'; + + constructor( + private readonly databaseService: DatabaseService, + private readonly authService: AuthService, + ) {} + + async getFichesActionSynthese( + collectiviteId: number, + filter: GetFichesActionFilterRequestType, + tokenInfo: SupabaseJwtPayload, + ): Promise { + this.logger.log( + `Récupération de la synthese des fiches action pour la collectivité ${collectiviteId}: filtre ${JSON.stringify(filter)}`, + ); + + // Vérification des droits + await this.authService.verifieAccesAuxCollectivites( + tokenInfo, + [collectiviteId], + NiveauAcces.LECTURE, + ); + + const conditions = this.getConditions(collectiviteId, filter); + const statutSynthese = await this.getSynthesePourPropriete( + ficheActionTable.statut, + conditions, + Object.values(FicheActionStatutsEnumType), + ); + + const synthese: GetFichesActionSyntheseResponseType = { + par_statut: statutSynthese, + }; + return synthese; + } + + getFicheActionPartenaireTagsQuery() { + return this.databaseService.db + .select({ + fiche_id: ficheActionPartenaireTagTable.fiche_id, + partenaire_tag_ids: + sql`array_agg(${ficheActionPartenaireTagTable.partenaire_tag_id})`.as( + this.FICHE_ACTION_PARTENAIRE_TAGS_QUERY_FIELD, + ), + }) + .from(ficheActionPartenaireTagTable) + .groupBy(ficheActionPartenaireTagTable.fiche_id) + .as(this.FICHE_ACTION_PARTENAIRE_TAGS_QUERY_ALIAS); + } + + getFicheActionAxesQuery() { + return this.databaseService.db + .select({ + fiche_id: ficheActionAxeTable.fiche_id, + axe_ids: sql`array_agg(${ficheActionAxeTable.axe_id})`.as('axe_ids'), + plan_ids: sql`array_agg(${axeTable.plan})`.as('plan_ids'), + }) + .from(ficheActionAxeTable) + .leftJoin(axeTable, eq(axeTable.id, ficheActionAxeTable.axe_id)) + .groupBy(ficheActionAxeTable.fiche_id) + .as('ficheActionAxes'); + } + + getFicheActionServiceTagsQuery() { + return this.databaseService.db + .select({ + fiche_id: ficheActionServiceTagTable.fiche_id, + service_tag_ids: + sql`array_agg(${ficheActionServiceTagTable.service_tag_id})`.as( + 'service_tag_ids', + ), + }) + .from(ficheActionServiceTagTable) + .groupBy(ficheActionServiceTagTable.fiche_id) + .as('ficheActionServiceTag'); + } + + getFicheActionPilotesQuery() { + return this.databaseService.db + .select({ + fiche_id: ficheActionPiloteTable.fiche_id, + pilote_user_ids: + sql`array_remove(array_agg(${ficheActionPiloteTable.user_id}), NULL)`.as( + 'pilote_user_ids', + ), + pilote_tag_ids: + sql`array_remove(array_agg(${ficheActionPiloteTable.tag_id}), NULL)`.as( + 'pilote_tag_ids', + ), + }) + .from(ficheActionPiloteTable) + .groupBy(ficheActionPiloteTable.fiche_id) + .as('ficheActionPilotes'); + } + + async getFichesAction( + collectiviteId: number, + filter: GetFichesActionFilterRequestType, + tokenInfo: SupabaseJwtPayload, + ): Promise { + this.logger.log( + `Récupération des fiches action pour la collectivité ${collectiviteId}: filtre ${JSON.stringify(filter)}`, + ); + + // Vérification des droits + await this.authService.verifieAccesAuxCollectivites( + tokenInfo, + [collectiviteId], + NiveauAcces.LECTURE, + ); + + const ficheActionPartenaireTags = this.getFicheActionPartenaireTagsQuery(); + const ficheActionPilotes = this.getFicheActionPilotesQuery(); + const ficheActionServiceTags = this.getFicheActionServiceTagsQuery(); + const ficheActionAxes = this.getFicheActionAxesQuery(); + + const conditions = this.getConditions(collectiviteId, filter); + + const fichesActionQuery = this.databaseService.db + .select({ + ...getTableColumns(ficheActionTable), + partenaire_tag_ids: ficheActionPartenaireTags.partenaire_tag_ids, + pilote_tag_ids: ficheActionPilotes.pilote_tag_ids, + pilote_user_ids: ficheActionPilotes.pilote_user_ids, + service_tag_ids: ficheActionServiceTags.service_tag_ids, + axe_ids: ficheActionAxes.axe_ids, + plan_ids: ficheActionAxes.plan_ids, + }) + .from(ficheActionTable) + .leftJoin( + ficheActionPartenaireTags, + eq(ficheActionPartenaireTags.fiche_id, ficheActionTable.id), + ) + .leftJoin( + ficheActionPilotes, + eq(ficheActionPilotes.fiche_id, ficheActionTable.id), + ) + .leftJoin( + ficheActionServiceTags, + eq(ficheActionServiceTags.fiche_id, ficheActionTable.id), + ) + .leftJoin( + ficheActionAxes, + eq(ficheActionAxes.fiche_id, ficheActionTable.id), + ) + .where(and(...conditions)); + + return await fichesActionQuery; + } + + getConditions( + collectiviteId: number, + filter: GetFichesActionFilterRequestType, + ): (SQLWrapper | SQL)[] { + const conditions: (SQLWrapper | SQL)[] = [ + eq(ficheActionTable.collectiviteId, collectiviteId), + ]; + if (filter.cibles?.length) { + conditions.push(arrayOverlaps(ficheActionTable.cibles, filter.cibles)); + } + if (filter.modified_since) { + const modifiedSinceDate = getModifiedSinceDate(filter.modified_since); + conditions.push( + gte(ficheActionTable.modifiedAt, modifiedSinceDate.toISOString()), + ); + } + if (filter.modified_after) { + conditions.push(gte(ficheActionTable.modifiedAt, filter.modified_after)); + } + if (filter.partenaire_tag_ids?.length) { + // Vraiment étrange, probable bug de drizzle, on le peut pas lui donner le tableau directement + const sqlNumberArray = `{${filter.partenaire_tag_ids.join(',')}}`; + conditions.push( + arrayOverlaps(sql`partenaire_tag_ids`, sql`${sqlNumberArray}`), + ); + } + if (filter.service_tag_ids?.length) { + // Vraiment étrange, probable bug de drizzle, on le peut pas lui donner le tableau directement + const sqlNumberArray = `{${filter.service_tag_ids.join(',')}}`; + conditions.push( + arrayOverlaps(sql`service_tag_ids`, sql`${sqlNumberArray}`), + ); + } + if (filter.plan_ids?.length) { + // Vraiment étrange, probable bug de drizzle, on le peut pas lui donner le tableau directement + const sqlNumberArray = `{${filter.plan_ids.join(',')}}`; + conditions.push(arrayOverlaps(sql`plan_ids`, sql`${sqlNumberArray}`)); + } + + const piloteConditions: (SQLWrapper | SQL)[] = []; + if (filter.pilote_user_ids?.length) { + const sqlNumberArray = `{${filter.pilote_user_ids.join(',')}}`; + piloteConditions.push( + arrayOverlaps(sql`pilote_user_ids`, sql`${sqlNumberArray}`), + ); + } + if (filter.pilote_tag_ids?.length) { + const sqlNumberArray = `{${filter.pilote_tag_ids.join(',')}}`; + piloteConditions.push( + arrayOverlaps(sql`pilote_tag_ids`, sql`${sqlNumberArray}`), + ); + } + if (piloteConditions.length) { + if (piloteConditions.length === 1) { + conditions.push(piloteConditions[0]); + } else { + conditions.push(or(...piloteConditions)!); + } + } + + return conditions; + } + + async getSynthesePourPropriete( + propriete: PgColumn, + conditions: (SQLWrapper | SQL)[], + listeValeurs?: string[], + ): Promise { + const ficheActionPartenaireTags = this.getFicheActionPartenaireTagsQuery(); + const ficheActionPilotes = this.getFicheActionPilotesQuery(); + const ficheActionServiceTags = this.getFicheActionServiceTagsQuery(); + const ficheActionAxes = this.getFicheActionAxesQuery(); + + const fichesActionSyntheseQuery = this.databaseService.db + .select({ + valeur: propriete, + count: count(), + }) + .from(ficheActionTable) + .leftJoin( + ficheActionPartenaireTags, + eq(ficheActionPartenaireTags.fiche_id, ficheActionTable.id), + ) + .leftJoin( + ficheActionPilotes, + eq(ficheActionPilotes.fiche_id, ficheActionTable.id), + ) + .leftJoin( + ficheActionServiceTags, + eq(ficheActionServiceTags.fiche_id, ficheActionTable.id), + ) + .leftJoin( + ficheActionAxes, + eq(ficheActionAxes.fiche_id, ficheActionTable.id), + ) + .where(and(...conditions)) + .groupBy(propriete); + const fichesActionSynthese = await fichesActionSyntheseQuery; + + const synthese: CountSyntheseType = {}; + if (listeValeurs) { + listeValeurs.forEach((valeur) => { + synthese[valeur] = { + valeur: valeur, + count: 0, + }; + }); + } + fichesActionSynthese.forEach((syntheseRow) => { + synthese[syntheseRow.valeur] = { + valeur: syntheseRow.valeur, + count: syntheseRow.count, + }; + }); + + return synthese; + } +} diff --git a/backend/src/indicateurs/controllers/indicateurs.controller.ts b/backend/src/indicateurs/controllers/indicateurs.controller.ts index 46ff3d1fd2..7847c5567b 100644 --- a/backend/src/indicateurs/controllers/indicateurs.controller.ts +++ b/backend/src/indicateurs/controllers/indicateurs.controller.ts @@ -1,6 +1,6 @@ import { createZodDto } from '@anatine/zod-nestjs'; import { Body, Controller, Get, Logger, Post, Query } from '@nestjs/common'; -import { ApiCreatedResponse, ApiResponse } from '@nestjs/swagger'; +import { ApiCreatedResponse, ApiResponse, ApiTags } from '@nestjs/swagger'; import { TokenInfo } from '../../auth/decorators/token-info.decorators'; import type { SupabaseJwtPayload } from '../../auth/models/auth.models'; import { @@ -24,6 +24,7 @@ class GetIndicateursValeursResponseClass extends createZodDto( getIndicateursValeursResponseSchema ) {} +@ApiTags('Indicateurs') @Controller('indicateurs') export class IndicateursController { private readonly logger = new Logger(IndicateursController.name); diff --git a/backend/src/referentiels/models/action-definition.table.ts b/backend/src/referentiels/models/action-definition.table.ts new file mode 100644 index 0000000000..993e2b4f39 --- /dev/null +++ b/backend/src/referentiels/models/action-definition.table.ts @@ -0,0 +1,64 @@ +import { InferInsertModel, InferSelectModel, sql } from 'drizzle-orm'; +import { + doublePrecision, + pgEnum, + pgTable, + text, + timestamp, + varchar, +} from 'drizzle-orm/pg-core'; +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; + +// Todo: change it reference another table instead +export const referentielEnum = pgEnum('referentiel', ['eci', 'cae']); +export const actionCategorieEnum = pgEnum('action_categorie', [ + 'bases', + 'mise en œuvre', + 'effets', +]); +export const actionIdVarchar = varchar('action_id', { length: 30 }); +export const actionIdReference = actionIdVarchar.references( + () => actionDefinitionTable.action_id, +); + +export const actionDefinitionTable = pgTable('action_definition', { + modified_at: timestamp('modified_at', { withTimezone: true, mode: 'string' }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + action_id: actionIdVarchar.primaryKey().notNull(), + referentiel: referentielEnum('referentiel').notNull(), + identifiant: text('identifiant').notNull(), + nom: text('nom').notNull(), + description: text('description').notNull(), + contexte: text('contexte').notNull(), + exemples: text('exemples').notNull(), + ressources: text('ressources').notNull(), + reduction_potentiel: text('reduction_potentiel').notNull(), + perimetre_evaluation: text('perimetre_evaluation').notNull(), + preuve: text('preuve'), + points: doublePrecision('points'), + pourcentage: doublePrecision('pourcentage'), + categorie: actionCategorieEnum('categorie'), +}); + +export type ActionDefinitionType = InferSelectModel< + typeof actionDefinitionTable +>; +export type CreateActionDefinitionType = InferInsertModel< + typeof actionDefinitionTable +>; + +export const actionDefinitionSchema = createSelectSchema(actionDefinitionTable); +export const actionDefinitionSeulementIdObligatoireSchema = + actionDefinitionSchema.partial(); +export const createActionDefinitionSchema = createInsertSchema( + actionDefinitionTable, +); + +export type ActionDefinitionAvecParentType = Pick< + ActionDefinitionType, + 'action_id' +> & + Partial & { + parent_action_id: string | null; + }; diff --git a/backend/src/taxonomie/models/partenaire-tag.table.ts b/backend/src/taxonomie/models/partenaire-tag.table.ts new file mode 100644 index 0000000000..8a9e0524bc --- /dev/null +++ b/backend/src/taxonomie/models/partenaire-tag.table.ts @@ -0,0 +1,14 @@ +import { pgTable, uniqueIndex } from 'drizzle-orm/pg-core'; +import { TagBase } from './tag.basetable'; + +export const partenaireTagTable = pgTable( + 'partenaire_tag', + TagBase, + (table) => { + return { + partenaire_tag_nom_collectivite_id_key: uniqueIndex( + 'partenaire_tag_nom_collectivite_id_key', + ).on(table.nom, table.collectivite_id), + }; + }, +); diff --git a/backend/src/taxonomie/models/personne-tag.table.ts b/backend/src/taxonomie/models/personne-tag.table.ts new file mode 100644 index 0000000000..59b312753c --- /dev/null +++ b/backend/src/taxonomie/models/personne-tag.table.ts @@ -0,0 +1,10 @@ +import { pgTable, uniqueIndex } from 'drizzle-orm/pg-core'; +import { TagBase } from './tag.basetable'; + +export const personneTagTable = pgTable('personne_tag', TagBase, (table) => { + return { + personne_tag_nom_collectivite_id_key: uniqueIndex( + 'personne_tag_nom_collectivite_id_key', + ).on(table.nom, table.collectivite_id), + }; +}); diff --git a/backend/src/taxonomie/models/service-tag.table.ts b/backend/src/taxonomie/models/service-tag.table.ts new file mode 100644 index 0000000000..bbaf4c8904 --- /dev/null +++ b/backend/src/taxonomie/models/service-tag.table.ts @@ -0,0 +1,10 @@ +import { pgTable, uniqueIndex } from 'drizzle-orm/pg-core'; +import { TagBase } from './tag.basetable'; + +export const serviceTagTable = pgTable('service_tag', TagBase, (table) => { + return { + service_tag_nom_collectivite_id_key: uniqueIndex( + 'service_tag_nom_collectivite_id_key', + ).on(table.nom, table.collectivite_id), + }; +}); diff --git a/backend/src/taxonomie/models/tag.basetable.ts b/backend/src/taxonomie/models/tag.basetable.ts new file mode 100644 index 0000000000..c007b2e3cc --- /dev/null +++ b/backend/src/taxonomie/models/tag.basetable.ts @@ -0,0 +1,10 @@ +import { integer, serial, text } from 'drizzle-orm/pg-core'; +import { collectiviteTable } from '../../collectivites/models/collectivite.models'; + +export const TagBase = { + id: serial('id').primaryKey(), + nom: text('nom').notNull(), + collectivite_id: integer('collectivite_id') + .notNull() + .references(() => collectiviteTable.id), +};