From c6da3874929311f39f870a2082f6653ead6e7c1f Mon Sep 17 00:00:00 2001 From: Thibaut Dusanter Date: Thu, 5 Dec 2024 21:43:05 +0100 Subject: [PATCH] =?UTF-8?q?feat(backend/referentiels):=20mise=20=C3=A0=20j?= =?UTF-8?q?our=20d'un=20statut=20d'une=20action=20et=20recalcul=20du=20sco?= =?UTF-8?q?re,=20ajout=20de=20tRPC=20et=20des=20tests=20associ=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../models/private-utilisateur-droit.table.ts | 2 +- .../compute-score/compute-score.router.ts | 33 ++++ .../referentiels-scoring.controller.ts | 28 ++- .../models/get-referentiel-scores.request.ts | 18 +- .../src/referentiels/referentiels.module.ts | 12 ++ .../referentiels-scoring-snapshots.service.ts | 29 +-- .../services/referentiels-scoring.service.ts | 24 +++ .../services/referentiels.service.ts | 12 ++ .../snapshots/score-snaphots.router.ts | 84 ++++++++ .../score-snapshots.router.e2e-spec.ts | 138 ++++++++++++++ .../update-action-statut.router.e2e-spec.ts | 180 ++++++++++++++++++ .../update-action-statut.router.ts | 22 +++ .../update-action-statut.service.ts | 108 +++++++++++ backend/src/trpc/trpc.router.ts | 13 +- .../referentiels-scoring.e2e-spec.ts | 59 +++--- 15 files changed, 710 insertions(+), 52 deletions(-) create mode 100644 backend/src/referentiels/compute-score/compute-score.router.ts create mode 100644 backend/src/referentiels/snapshots/score-snaphots.router.ts create mode 100644 backend/src/referentiels/snapshots/score-snapshots.router.e2e-spec.ts create mode 100644 backend/src/referentiels/update-action-statut/update-action-statut.router.e2e-spec.ts create mode 100644 backend/src/referentiels/update-action-statut/update-action-statut.router.ts create mode 100644 backend/src/referentiels/update-action-statut/update-action-statut.service.ts diff --git a/backend/src/auth/models/private-utilisateur-droit.table.ts b/backend/src/auth/models/private-utilisateur-droit.table.ts index 876c7462c9..d1c0ffcf45 100644 --- a/backend/src/auth/models/private-utilisateur-droit.table.ts +++ b/backend/src/auth/models/private-utilisateur-droit.table.ts @@ -3,7 +3,7 @@ import { boolean, integer, pgTable, serial, uuid } from 'drizzle-orm/pg-core'; import { collectiviteTable } from '../../collectivites/models/collectivite.table'; import { createdAt, modifiedAt } from '../../common/models/column.helpers'; import { invitationTable } from './invitation.table'; -import { niveauAccessEnum, NiveauAcces } from './niveau-acces.enum'; +import { NiveauAcces, niveauAccessEnum } from './niveau-acces.enum'; export const utilisateurDroitTable = pgTable('private_utilisateur_droit', { id: serial('id').primaryKey(), diff --git a/backend/src/referentiels/compute-score/compute-score.router.ts b/backend/src/referentiels/compute-score/compute-score.router.ts new file mode 100644 index 0000000000..26b3ae1205 --- /dev/null +++ b/backend/src/referentiels/compute-score/compute-score.router.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import z from 'zod'; +import { TrpcService } from '../../trpc/trpc.service'; +import { getReferentielScoresRequestSchema } from '../models/get-referentiel-scores.request'; +import { ReferentielType } from '../models/referentiel.enum'; +import ReferentielsScoringService from '../services/referentiels-scoring.service'; + +export const computeScoreRequestSchema = z.object({ + referentielId: z.nativeEnum(ReferentielType), + collectiviteId: z.number().int(), + parameters: getReferentielScoresRequestSchema, +}); + +@Injectable() +export class ComputeScoreRouter { + constructor( + private readonly trpc: TrpcService, + private readonly service: ReferentielsScoringService + ) {} + + router = this.trpc.router({ + computeScore: this.trpc.authedProcedure + .input(computeScoreRequestSchema) + .query(({ input, ctx }) => { + return this.service.computeScoreForCollectivite( + input.referentielId, + input.collectiviteId, + input.parameters, + ctx.user + ); + }), + }); +} diff --git a/backend/src/referentiels/controllers/referentiels-scoring.controller.ts b/backend/src/referentiels/controllers/referentiels-scoring.controller.ts index 18436782de..aee1f87d21 100644 --- a/backend/src/referentiels/controllers/referentiels-scoring.controller.ts +++ b/backend/src/referentiels/controllers/referentiels-scoring.controller.ts @@ -149,13 +149,13 @@ export class ReferentielsScoringController { @Get( 'collectivites/:collectivite_id/referentiels/:referentiel_id/score-snapshots' ) - async getReferentielScoreSnapshots( + async listSummary( @Param('collectivite_id') collectiviteId: number, @Param('referentiel_id') referentielId: ReferentielType, @Query() parameters: GetScoreSnapshotsRequestClass, @TokenInfo() tokenInfo: AuthUser ): Promise { - return this.referentielsScoringSnapshotsService.getScoreSnapshots( + return this.referentielsScoringSnapshotsService.listSummary( collectiviteId, referentielId, parameters @@ -171,12 +171,22 @@ export class ReferentielsScoringController { @Param('referentiel_id') referentielId: ReferentielType, @Param('snapshot_ref') snapshotRef: string, @TokenInfo() tokenInfo: AuthenticatedUser - ): Promise { - return this.referentielsScoringSnapshotsService.getFullScoreSnapshot( - collectiviteId, - referentielId, - snapshotRef - ); + ) { + if ( + snapshotRef === + ReferentielsScoringSnapshotsService.SCORE_COURANT_SNAPSHOT_REF + ) { + return this.referentielsScoringService.getOrCreateCurrentScore( + collectiviteId, + referentielId + ); + } else { + return this.referentielsScoringSnapshotsService.get( + collectiviteId, + referentielId, + snapshotRef + ); + } } @Delete( @@ -188,7 +198,7 @@ export class ReferentielsScoringController { @Param('snapshot_ref') snapshotRef: string, @TokenInfo() tokenInfo: AuthUser ): Promise { - return this.referentielsScoringSnapshotsService.deleteScoreSnapshot( + return this.referentielsScoringSnapshotsService.delete( collectiviteId, referentielId, snapshotRef, diff --git a/backend/src/referentiels/models/get-referentiel-scores.request.ts b/backend/src/referentiels/models/get-referentiel-scores.request.ts index e91639a37a..1cbe4c7320 100644 --- a/backend/src/referentiels/models/get-referentiel-scores.request.ts +++ b/backend/src/referentiels/models/get-referentiel-scores.request.ts @@ -14,8 +14,10 @@ export const getReferentielScoresRequestSchema = extendApi( ), avecReferentielsOrigine: z - .enum(['true', 'false']) - .transform((value) => value === 'true') + .union([ + z.enum(['true', 'false']).transform((value) => value === 'true'), + z.boolean(), + ]) .optional() .describe( `Indique si les scores des actions doivent être calculés à partir des avancement dans les référentiels d'origine. Utilisé pour le bac à sable lors de la création de nouveaux référentiels à partir de référentiels existants` @@ -46,8 +48,10 @@ export const getReferentielScoresRequestSchema = extendApi( .optional() .describe(`Année de l'audit pour le jalon`), snapshot: z - .enum(['true', 'false']) - .transform((value) => value === 'true') + .union([ + z.enum(['true', 'false']).transform((value) => value === 'true'), + z.boolean(), + ]) .optional() .describe( `Indique si le score doit être sauvegardé. Si c'est le cas, l'utilisateur doit avoir le droit d'écriture sur la collectivité` @@ -59,8 +63,10 @@ export const getReferentielScoresRequestSchema = extendApi( `Nom du snapshot de score à sauvegarder. Ne peut être défini que pour une date personnalisée, sinon un nom par défaut est utilisé` ), snapshotForceUpdate: z - .enum(['true', 'false']) - .transform((value) => value === 'true') + .union([ + z.enum(['true', 'false']).transform((value) => value === 'true'), + z.boolean(), + ]) .optional() .describe(`Force l'update du snapshot même si il existe déjà`), }) diff --git a/backend/src/referentiels/referentiels.module.ts b/backend/src/referentiels/referentiels.module.ts index c45680d930..dd2652333c 100644 --- a/backend/src/referentiels/referentiels.module.ts +++ b/backend/src/referentiels/referentiels.module.ts @@ -5,12 +5,16 @@ import { CommonModule } from '../common/common.module'; import { ConfigurationModule } from '../config/configuration.module'; import { PersonnalisationsModule } from '../personnalisations/personnalisations.module'; import { SheetModule } from '../spreadsheets/sheet.module'; +import { ComputeScoreRouter } from './compute-score/compute-score.router'; import { ReferentielsScoringController } from './controllers/referentiels-scoring.controller'; import { ReferentielsController } from './controllers/referentiels.controller'; import LabellisationService from './services/labellisation.service'; import ReferentielsScoringSnapshotsService from './services/referentiels-scoring-snapshots.service'; import ReferentielsScoringService from './services/referentiels-scoring.service'; import ReferentielsService from './services/referentiels.service'; +import { ScoreSnapshotsRouter } from './snapshots/score-snaphots.router'; +import { UpdateActionStatutRouter } from './update-action-statut/update-action-statut.router'; +import { UpdateActionStatutService } from './update-action-statut/update-action-statut.service'; @Module({ imports: [ @@ -26,12 +30,20 @@ import ReferentielsService from './services/referentiels.service'; LabellisationService, ReferentielsScoringSnapshotsService, ReferentielsScoringService, + UpdateActionStatutService, + UpdateActionStatutRouter, + ComputeScoreRouter, + ScoreSnapshotsRouter, ], exports: [ ReferentielsService, LabellisationService, ReferentielsScoringSnapshotsService, ReferentielsScoringService, + UpdateActionStatutService, + UpdateActionStatutRouter, + ComputeScoreRouter, + ScoreSnapshotsRouter, ], controllers: [ReferentielsController, ReferentielsScoringController], }) diff --git a/backend/src/referentiels/services/referentiels-scoring-snapshots.service.ts b/backend/src/referentiels/services/referentiels-scoring-snapshots.service.ts index 5abbc08818..b5a4b74a6e 100644 --- a/backend/src/referentiels/services/referentiels-scoring-snapshots.service.ts +++ b/backend/src/referentiels/services/referentiels-scoring-snapshots.service.ts @@ -59,7 +59,7 @@ export default class ReferentielsScoringSnapshotsService { slugifyName(name: string): string { if (name) { - return slugify(name, { + return slugify(name.toLowerCase(), { replacement: '-', remove: /[*+~.()'"!:@]/g, }); @@ -303,7 +303,7 @@ export default class ReferentielsScoringSnapshotsService { let scoreSnapshots: ScoreSnapshotType[] = []; try { if (snapshotForceUpdate) { - const existingSnapshot = await this.getScoreSnapshotInfo( + const existingSnapshot = await this.getSummary( createScoreSnapshot.collectiviteId, createScoreSnapshot.referentielId as ReferentielType, createScoreSnapshot.ref!, @@ -352,7 +352,7 @@ export default class ReferentielsScoringSnapshotsService { return scoreSnapshot; } - async getScoreSnapshots( + async listSummary( collectiviteId: number, referentielId: ReferentielType, parameters: GetScoreSnapshotsRequestType @@ -393,7 +393,7 @@ export default class ReferentielsScoringSnapshotsService { return getScoreSnapshotsResponseType; } - async getScoreSnapshotInfo( + async getSummary( collectiviteId: number, referentielId: ReferentielType, snapshotRef: string, @@ -433,11 +433,12 @@ export default class ReferentielsScoringSnapshotsService { return result.length ? result[0] : null; } - async getFullScoreSnapshot( + async get( collectiviteId: number, referentielId: ReferentielType, - snapshotRef: string - ): Promise { + snapshotRef: string, + doNotThrowIfNotFound?: boolean + ): Promise { const result = (await this.databaseService.db .select() .from(scoreSnapshotTable) @@ -450,9 +451,13 @@ export default class ReferentielsScoringSnapshotsService { )) as ScoreSnapshotType[]; if (!result.length) { - throw new NotFoundException( - `Aucun snapshot de score avec la référence ${snapshotRef} n'a été trouvé pour la collectivité ${collectiviteId} et le referentiel ${referentielId}` - ); + if (!doNotThrowIfNotFound) { + throw new NotFoundException( + `Aucun snapshot de score avec la référence ${snapshotRef} n'a été trouvé pour la collectivité ${collectiviteId} et le referentiel ${referentielId}` + ); + } else { + return null; + } } const fullScores = result[0].referentielScores; @@ -467,7 +472,7 @@ export default class ReferentielsScoringSnapshotsService { return fullScores; } - async deleteScoreSnapshot( + async delete( collectiviteId: number, referentielId: ReferentielType, snapshotRef: string, @@ -479,7 +484,7 @@ export default class ReferentielsScoringSnapshotsService { NiveauAcces.EDITION ); - const snapshotInfo = await this.getScoreSnapshotInfo( + const snapshotInfo = await this.getSummary( collectiviteId, referentielId, snapshotRef diff --git a/backend/src/referentiels/services/referentiels-scoring.service.ts b/backend/src/referentiels/services/referentiels-scoring.service.ts index 547dae54ee..0143acb1af 100644 --- a/backend/src/referentiels/services/referentiels-scoring.service.ts +++ b/backend/src/referentiels/services/referentiels-scoring.service.ts @@ -98,6 +98,30 @@ export default class ReferentielsScoringService { private readonly labellisationService: LabellisationService ) {} + async getOrCreateCurrentScore( + collectiviteId: number, + referentielId: ReferentielType + ) { + let currentScore = await this.referentielsScoringSnapshotsService.get( + collectiviteId, + referentielId, + ReferentielsScoringSnapshotsService.SCORE_COURANT_SNAPSHOT_REF, + true + ); + if (!currentScore) { + currentScore = await this.computeScoreForCollectivite( + referentielId, + collectiviteId, + { + mode: ComputeScoreMode.RECALCUL, + snapshot: true, + snapshotForceUpdate: true, + } + ); + } + return currentScore; + } + async checkCollectiviteAndReferentielWithAccess( collectiviteId: number, referentielId: ReferentielType, diff --git a/backend/src/referentiels/services/referentiels.service.ts b/backend/src/referentiels/services/referentiels.service.ts index 4274749c6a..252491dfef 100644 --- a/backend/src/referentiels/services/referentiels.service.ts +++ b/backend/src/referentiels/services/referentiels.service.ts @@ -396,6 +396,18 @@ export default class ReferentielsService { ); } + getReferentielIdFromActionId(actionId: string): ReferentielType { + const referentielId = actionId.split('_')[0]; + if ( + !Object.values(ReferentielType).includes(referentielId as ReferentielType) + ) { + throw new UnprocessableEntityException( + `Invalid referentiel id ${referentielId} for action ${actionId}` + ); + } + return referentielId as ReferentielType; + } + getLevelFromActionId(actionId: string): number { const level = actionId.split('.').length; if (level === 1) { diff --git a/backend/src/referentiels/snapshots/score-snaphots.router.ts b/backend/src/referentiels/snapshots/score-snaphots.router.ts new file mode 100644 index 0000000000..67ef327643 --- /dev/null +++ b/backend/src/referentiels/snapshots/score-snaphots.router.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import z from 'zod'; +import { TrpcService } from '../../trpc/trpc.service'; +import { ReferentielType } from '../models/referentiel.enum'; +import { ScoreJalon } from '../models/score-jalon.enum'; +import ReferentielsScoringSnapshotsService from '../services/referentiels-scoring-snapshots.service'; +import ReferentielsScoringService from '../services/referentiels-scoring.service'; + +export const getScoreSnapshotInfosTrpcRequestSchema = z.object({ + referentielId: z.nativeEnum(ReferentielType), + collectiviteId: z.number().int(), + parameters: z.object({ + typesJalon: z.nativeEnum(ScoreJalon).array(), + }), +}); + +export const getFullScoreSnapshotTrpcRequestSchema = z.object({ + referentielId: z.nativeEnum(ReferentielType), + collectiviteId: z.number().int(), + snapshotRef: z.string(), +}); + +@Injectable() +export class ScoreSnapshotsRouter { + constructor( + private readonly trpc: TrpcService, + private readonly service: ReferentielsScoringSnapshotsService, + private readonly referentielsScoringService: ReferentielsScoringService + ) {} + + router = this.trpc.router({ + listSummary: this.trpc.authedProcedure + .input(getScoreSnapshotInfosTrpcRequestSchema) + .query(({ input, ctx }) => { + return this.service.listSummary( + input.collectiviteId, + input.referentielId, + input.parameters + ); + }), + getCurrentFullScore: this.trpc.authedProcedure + .input( + z.object({ + referentielId: z.nativeEnum(ReferentielType), + collectiviteId: z.number().int(), + }) + ) + .query(({ input, ctx }) => { + return this.referentielsScoringService.getOrCreateCurrentScore( + input.collectiviteId, + input.referentielId + ); + }), + get: this.trpc.authedProcedure + .input(getFullScoreSnapshotTrpcRequestSchema) + .query(({ input, ctx }) => { + if ( + input.snapshotRef === + ReferentielsScoringSnapshotsService.SCORE_COURANT_SNAPSHOT_REF + ) { + return this.referentielsScoringService.getOrCreateCurrentScore( + input.collectiviteId, + input.referentielId + ); + } else { + return this.service.get( + input.collectiviteId, + input.referentielId, + input.snapshotRef + ); + } + }), + delete: this.trpc.authedProcedure + .input(getFullScoreSnapshotTrpcRequestSchema) + .query(({ input, ctx }) => { + return this.service.delete( + input.collectiviteId, + input.referentielId, + input.snapshotRef, + ctx.user + ); + }), + }); +} diff --git a/backend/src/referentiels/snapshots/score-snapshots.router.e2e-spec.ts b/backend/src/referentiels/snapshots/score-snapshots.router.e2e-spec.ts new file mode 100644 index 0000000000..0f16b15d84 --- /dev/null +++ b/backend/src/referentiels/snapshots/score-snapshots.router.e2e-spec.ts @@ -0,0 +1,138 @@ +import { inferProcedureInput } from '@trpc/server'; +import { DateTime } from 'luxon'; +import { getAuthUser } from '../../../test/auth/auth-utils'; +import { getCollectiviteIdBySiren } from '../../../test/collectivites/collectivites-utils'; +import { getTestRouter } from '../../../test/common/app-utils'; +import { AuthenticatedUser } from '../../auth/models/auth.models'; +import { AppRouter, TrpcRouter } from '../../trpc/trpc.router'; +import { ScoreSnapshotInfoType } from '../models/get-score-snapshots.response'; +import { ReferentielType } from '../models/referentiel.enum'; +import { ScoreJalon } from '../models/score-jalon.enum'; + +type ComputeScoreInput = inferProcedureInput< + AppRouter['referentiels']['scores']['computeScore'] +>; + +describe('ScoreSnapshotsRouter', () => { + let router: TrpcRouter; + let yoloDodoUser: AuthenticatedUser; + let rhoneAggloCollectiviteId: number; + + beforeAll(async () => { + router = await getTestRouter(); + yoloDodoUser = await getAuthUser(); + rhoneAggloCollectiviteId = await getCollectiviteIdBySiren('200072015'); + }); + + test("Création d'un snapshot: not authenticated", async () => { + const caller = router.createCaller({ user: null }); + + const input: ComputeScoreInput = { + referentielId: ReferentielType.CAE, + collectiviteId: 1, + parameters: { + snapshot: true, + snapshotNom: 'test', + }, + }; + // `rejects` is necessary to handle exception in async function + // See https://vitest.dev/api/expect.html#tothrowerror + await expect(() => + caller.referentiels.scores.computeScore(input) + ).rejects.toThrowError(/not authenticated/i); + }); + + test("Création d'un snapshot: not authorized, accès en lecture uniquement", async () => { + const caller = router.createCaller({ user: yoloDodoUser }); + + const input: ComputeScoreInput = { + referentielId: ReferentielType.CAE, + collectiviteId: rhoneAggloCollectiviteId, + parameters: { + snapshot: true, + snapshotNom: 'test', + }, + }; + + // `rejects` is necessary to handle exception in async function + // See https://vitest.dev/api/expect.html#tothrowerror + await expect(() => + caller.referentiels.scores.computeScore(input) + ).rejects.toThrowError(/Droits insuffisants/i); + }); + + test("Création d'un snapshot, liste des snapshots existants suppression", async () => { + const caller = router.createCaller({ user: yoloDodoUser }); + + const input: ComputeScoreInput = { + referentielId: ReferentielType.CAE, + collectiviteId: 1, + parameters: { + snapshot: true, + snapshotNom: 'Test trpc', + snapshotForceUpdate: true, + }, + }; + + const referentielScore = await caller.referentiels.scores.computeScore( + input + ); + expect(referentielScore.snapshot?.ref).toEqual('user-test-trpc'); + + // get the list of snapshots + const responseSnapshotList = + await caller.referentiels.snapshots.listSummary({ + collectiviteId: 1, + referentielId: ReferentielType.CAE, + parameters: { + typesJalon: [ScoreJalon.DATE_PERSONNALISEE], + }, + }); + const foundSnapshot = responseSnapshotList.snapshots.find( + (snapshot) => snapshot.ref === 'user-test-trpc' + ); + const expectedSnapshot: ScoreSnapshotInfoType = { + date: DateTime.fromISO(referentielScore.date).toJSDate(), + nom: 'Test trpc', + ref: 'user-test-trpc', + typeJalon: ScoreJalon.DATE_PERSONNALISEE, + modifiedAt: DateTime.fromISO( + referentielScore.snapshot!.modifiedAt + ).toJSDate(), + createdAt: DateTime.fromISO( + referentielScore.snapshot!.createdAt + ).toJSDate(), + referentielVersion: '1.0.0', + auditId: null, + createdBy: '17440546-f389-4d4f-bfdb-b0c94a1bd0f9', + modifiedBy: '17440546-f389-4d4f-bfdb-b0c94a1bd0f9', + pointFait: 0.36, + pointPasFait: 0.03, + pointPotentiel: 490.9, + pointProgramme: 0.21, + }; + expect(foundSnapshot).toEqual(expectedSnapshot); + + // delete the snapshot + await caller.referentiels.snapshots.delete({ + collectiviteId: 1, + referentielId: ReferentielType.CAE, + snapshotRef: 'user-test-trpc', + }); + + // get the list of snapshots; the snapshot should not be there + const responseSnapshotListAfterDelete = + await caller.referentiels.snapshots.listSummary({ + collectiviteId: 1, + referentielId: ReferentielType.CAE, + parameters: { + typesJalon: [ScoreJalon.DATE_PERSONNALISEE], + }, + }); + const foundSnapshotAfterDelete = + responseSnapshotListAfterDelete.snapshots.find( + (snapshot) => snapshot.ref === 'user-test-trpc' + ); + expect(foundSnapshotAfterDelete).toBeUndefined(); + }); +}); diff --git a/backend/src/referentiels/update-action-statut/update-action-statut.router.e2e-spec.ts b/backend/src/referentiels/update-action-statut/update-action-statut.router.e2e-spec.ts new file mode 100644 index 0000000000..7f324382a4 --- /dev/null +++ b/backend/src/referentiels/update-action-statut/update-action-statut.router.e2e-spec.ts @@ -0,0 +1,180 @@ +import { inferProcedureInput } from '@trpc/server'; +import { getAuthUser } from '../../../test/auth/auth-utils'; +import { getCollectiviteIdBySiren } from '../../../test/collectivites/collectivites-utils'; +import { getTestRouter } from '../../../test/common/app-utils'; +import { AppRouter, TrpcRouter } from '../../trpc/trpc.router'; +import { ActionScoreType } from '../models/action-score.dto'; +import { ReferentielType } from '../models/referentiel.enum'; +import { AuthenticatedUser } from './../../auth/models/auth.models'; + +type Input = inferProcedureInput< + AppRouter['referentiels']['actions']['updateStatut'] +>; + +describe('UpdateActionStatutRouter', () => { + let router: TrpcRouter; + let yoloDodoUser: AuthenticatedUser; + let rhoneAggloCollectiviteId: number; + + beforeAll(async () => { + router = await getTestRouter(); + yoloDodoUser = await getAuthUser(); + rhoneAggloCollectiviteId = await getCollectiviteIdBySiren('200072015'); + }); + + test('not authenticated', async () => { + const caller = router.createCaller({ user: null }); + + const input: Input = { + referentielId: ReferentielType.CAE, + actionStatut: { + collectiviteId: 1, + actionId: 'cae_1.1.1.2', + avancement: 'detaille', + avancementDetaille: [1, 0, 0], + concerne: true, + }, + }; + + // `rejects` is necessary to handle exception in async function + // See https://vitest.dev/api/expect.html#tothrowerror + await expect(() => + caller.referentiels.actions.updateStatut(input) + ).rejects.toThrowError(/not authenticated/i); + }); + + test('not authorized: accès en lecture uniquement', async () => { + const caller = router.createCaller({ user: yoloDodoUser }); + + const input: Input = { + referentielId: ReferentielType.CAE, + actionStatut: { + collectiviteId: rhoneAggloCollectiviteId, + actionId: 'cae_1.1.1.2', + avancement: 'detaille', + avancementDetaille: [1, 0, 0], + concerne: true, + }, + }; + + // `rejects` is necessary to handle exception in async function + // See https://vitest.dev/api/expect.html#tothrowerror + await expect(() => + caller.referentiels.actions.updateStatut(input) + ).rejects.toThrowError(/Droits insuffisants/i); + }); + + test('Action inexistante', async () => { + const caller = router.createCaller({ user: yoloDodoUser }); + + const input: Input = { + referentielId: ReferentielType.CAE, + actionStatut: { + collectiviteId: 1, + actionId: 'cae_1.1.1.11', + avancement: 'detaille', + avancementDetaille: [1, 0, 0], + concerne: true, + }, + }; + + // `rejects` is necessary to handle exception in async function + // See https://vitest.dev/api/expect.html#tothrowerror + await expect(() => + caller.referentiels.actions.updateStatut(input) + ).rejects.toThrowError( + "L'action cae_1.1.1.11 n'existe pas pour le referentiel cae" + ); + }); + + test("Mise à jour du score courant lors de la mise à jour du statut d'une action", async () => { + const caller = router.createCaller({ user: yoloDodoUser }); + + const input: Input = { + referentielId: ReferentielType.CAE, + actionStatut: { + collectiviteId: 1, + actionId: 'cae_1.1.1.1.2', + avancement: 'detaille', + avancementDetaille: [1, 0, 0], + concerne: true, + }, + }; + + const currentFullScoreStatutUpdateResponse = + await caller.referentiels.actions.updateStatut(input); + + const expectedCaeRootScoreAfterStatutUpdate: ActionScoreType = { + actionId: 'cae', + etoiles: 1, + pointReferentiel: 500, + pointPotentiel: 490.9, + pointPotentielPerso: null, + pointFait: 0.6, + pointPasFait: 0, + pointNonRenseigne: 490.3, + pointProgramme: 0, + concerne: true, + completedTachesCount: 2, + totalTachesCount: 1111, + faitTachesAvancement: 2, + programmeTachesAvancement: 0, + pasFaitTachesAvancement: 0, + pasConcerneTachesAvancement: 0, + desactive: false, + renseigne: false, + }; + expect(currentFullScoreStatutUpdateResponse.scores.score).toEqual( + expectedCaeRootScoreAfterStatutUpdate + ); + + // Check that the current score has been correctly updated & saved + const currentFullCurentScore = + await caller.referentiels.snapshots.getCurrentFullScore({ + referentielId: ReferentielType.CAE, + collectiviteId: 1, + }); + expect(currentFullCurentScore.scores.score).toEqual( + expectedCaeRootScoreAfterStatutUpdate + ); + + // Restore the previous state + const actionNonFaite: Input = { + referentielId: ReferentielType.CAE, + actionStatut: { + collectiviteId: 1, + actionId: 'cae_1.1.1.1.2', + avancement: 'detaille', + avancementDetaille: [0.2, 0.7, 0.1], + concerne: true, + }, + }; + + const currentFullScoreStatutRestoreResponse = + await caller.referentiels.actions.updateStatut(actionNonFaite); + + const expectedCaeRootScoreAfterStatutRestore: ActionScoreType = { + actionId: 'cae', + etoiles: 1, + pointReferentiel: 500, + pointPotentiel: 490.9, + pointPotentielPerso: null, + pointFait: 0.36, + pointPasFait: 0.03, + pointNonRenseigne: 490.3, + pointProgramme: 0.21, + concerne: true, + completedTachesCount: 2, + totalTachesCount: 1111, + faitTachesAvancement: 1.2, + programmeTachesAvancement: 0.7, + pasFaitTachesAvancement: 0.1, + pasConcerneTachesAvancement: 0, + desactive: false, + renseigne: false, + }; + expect(currentFullScoreStatutRestoreResponse.scores.score).toEqual( + expectedCaeRootScoreAfterStatutRestore + ); + }); +}); diff --git a/backend/src/referentiels/update-action-statut/update-action-statut.router.ts b/backend/src/referentiels/update-action-statut/update-action-statut.router.ts new file mode 100644 index 0000000000..c5ae56e61f --- /dev/null +++ b/backend/src/referentiels/update-action-statut/update-action-statut.router.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '../../trpc/trpc.service'; +import { + UpdateActionStatutService, + upsertActionStatutRequestSchema, +} from './update-action-statut.service'; + +@Injectable() +export class UpdateActionStatutRouter { + constructor( + private readonly trpc: TrpcService, + private readonly service: UpdateActionStatutService + ) {} + + router = this.trpc.router({ + updateStatut: this.trpc.authedProcedure + .input(upsertActionStatutRequestSchema) + .query(({ input, ctx }) => { + return this.service.upsertActionStatut(input, ctx.user); + }), + }); +} diff --git a/backend/src/referentiels/update-action-statut/update-action-statut.service.ts b/backend/src/referentiels/update-action-statut/update-action-statut.service.ts new file mode 100644 index 0000000000..bf12177f5f --- /dev/null +++ b/backend/src/referentiels/update-action-statut/update-action-statut.service.ts @@ -0,0 +1,108 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { sql } from 'drizzle-orm'; +import { PostgresError } from 'postgres'; +import z from 'zod'; +import { AuthenticatedUser } from '../../auth/models/auth.models'; +import { NiveauAcces } from '../../auth/models/niveau-acces.enum'; +import { AuthService } from '../../auth/services/auth.service'; +import { PgIntegrityConstraintViolation } from '../../common/models/postgresql-error-codes.enum'; +import DatabaseService from '../../common/services/database.service'; +import { getErrorWithCode } from '../../common/services/errors.helper'; +import { + actionStatutTable, + createActionStatutSchema, +} from '../models/action-statut.table'; +import { ComputeScoreMode } from '../models/compute-scores-mode.enum'; +import { GetReferentielScoresRequestType } from '../models/get-referentiel-scores.request'; +import ReferentielsScoringService from '../services/referentiels-scoring.service'; +import ReferentielsService from '../services/referentiels.service'; + +export const upsertActionStatutRequestSchema = z.object({ + actionStatut: createActionStatutSchema, +}); +export type UpsertActionStatutRequest = z.infer< + typeof upsertActionStatutRequestSchema +>; + +@Injectable() +export class UpdateActionStatutService { + private readonly logger = new Logger(UpdateActionStatutService.name); + + constructor( + private readonly databaseService: DatabaseService, + private readonly authService: AuthService, + private readonly referentielService: ReferentielsService, + private readonly referentielScoringService: ReferentielsScoringService + ) {} + + async upsertActionStatut( + request: UpsertActionStatutRequest, + user: AuthenticatedUser + ) { + // Check user access + await this.authService.verifieAccesAuxCollectivites( + user, + [request.actionStatut.collectiviteId], + NiveauAcces.EDITION + ); + + const referentielId = this.referentielService.getReferentielIdFromActionId( + request.actionStatut.actionId + ); + + request.actionStatut.modifiedBy = user?.id; + try { + await this.databaseService.db + .insert(actionStatutTable) + .values(request.actionStatut) + .onConflictDoUpdate({ + target: [ + actionStatutTable.collectiviteId, + actionStatutTable.actionId, + ], + set: { + avancement: sql.raw( + `excluded.${actionStatutTable.avancement.name}` + ), + avancementDetaille: sql.raw( + `excluded.${actionStatutTable.avancementDetaille.name}` + ), + concerne: sql.raw(`excluded.${actionStatutTable.concerne.name}`), + modifiedBy: sql.raw( + `excluded.${actionStatutTable.modifiedBy.name}` + ), + }, + }); + } catch (error) { + const errorWithCode = getErrorWithCode(error); + if ( + errorWithCode.code === + PgIntegrityConstraintViolation.ForeignKeyViolation + ) { + this.logger.error(error); + + const postgresError = error as PostgresError; + if (postgresError.constraint_name === 'action_statut_action_id_fkey') { + throw new NotFoundException( + `L'action ${request.actionStatut.actionId} n'existe pas pour le referentiel ${referentielId}` + ); + } + } + throw error; + } + + // TODO: support for different response format depending on the front + const parameters: GetReferentielScoresRequestType = { + snapshot: true, + snapshotForceUpdate: true, + mode: ComputeScoreMode.RECALCUL, + }; + // TODO: once the route is used by the front, we need to remove the trigger + return this.referentielScoringService.computeScoreForCollectivite( + referentielId, + request.actionStatut.collectiviteId, + parameters, + undefined // No need to check user access, it has already been done + ); + } +} diff --git a/backend/src/trpc/trpc.router.ts b/backend/src/trpc/trpc.router.ts index 922d11f4fd..5279f7a2c2 100644 --- a/backend/src/trpc/trpc.router.ts +++ b/backend/src/trpc/trpc.router.ts @@ -8,6 +8,9 @@ import { CountByStatutRouter } from '../fiches/count-by-statut/count-by-statut.r import { FicheActionEtapeRouter } from '../fiches/fiche-action-etape/fiche-action-etape.router'; import { IndicateurFiltreRouter } from '../indicateurs/indicateur-filtre/indicateur-filtre.router'; import { TrajectoiresRouter } from '../indicateurs/routers/trajectoires.router'; +import { ComputeScoreRouter } from '../referentiels/compute-score/compute-score.router'; +import { ScoreSnapshotsRouter } from '../referentiels/snapshots/score-snaphots.router'; +import { UpdateActionStatutRouter } from '../referentiels/update-action-statut/update-action-statut.router'; import { GetCategoriesByCollectiviteRouter } from '../taxonomie/routers/get-categories-by-collectivite.router'; import { createContext, TrpcService } from './trpc.service'; @@ -25,7 +28,10 @@ export class TrpcRouter { private readonly ficheActionEtapeRouter: FicheActionEtapeRouter, private readonly indicateurFiltreRouter: IndicateurFiltreRouter, private readonly bulkEditRouter: BulkEditRouter, - private readonly membresRouter: CollectiviteMembresRouter + private readonly membresRouter: CollectiviteMembresRouter, + private readonly updateActionStatutRouter: UpdateActionStatutRouter, + private readonly scoreSnapshotsRouter: ScoreSnapshotsRouter, + private readonly computeScoreRouter: ComputeScoreRouter ) {} appRouter = this.trpc.router({ @@ -47,6 +53,11 @@ export class TrpcRouter { tags: { categories: this.getCategoriesByCollectiviteRouter.router, }, + referentiels: { + actions: this.updateActionStatutRouter.router, + snapshots: this.scoreSnapshotsRouter.router, + scores: this.computeScoreRouter.router, + }, }); createCaller = this.trpc.createCallerFactory(this.appRouter); diff --git a/backend/test/referentiels/referentiels-scoring.e2e-spec.ts b/backend/test/referentiels/referentiels-scoring.e2e-spec.ts index f5093d1eda..a4d52918e9 100644 --- a/backend/test/referentiels/referentiels-scoring.e2e-spec.ts +++ b/backend/test/referentiels/referentiels-scoring.e2e-spec.ts @@ -228,7 +228,7 @@ describe('Referentiels scoring routes', () => { const responseSnapshotScoreCourantCreation = await request( app.getHttpServer() ) - .get(`/collectivites/1/referentiels/cae/scores?snapshot=true`) + .get(`/collectivites/1/referentiels/cae/score-snapshots/score-courant`) .set('Authorization', `Bearer ${yoloDodoToken}`) .expect(200); const getReferentielScoresCourantResponseType: GetReferentielScoresResponseType = @@ -237,39 +237,23 @@ describe('Referentiels scoring routes', () => { 'score-courant' ); - const responseSnapshotCreation = await request(app.getHttpServer()) + const responseCurrentSnapshotList = await request(app.getHttpServer()) .get( - `/collectivites/1/referentiels/cae/scores?snapshotNom=test%20à%20accent&snapshotForceUpdate=true` + `/collectivites/1/referentiels/cae/score-snapshots?typesJalon=score_courant` ) .set('Authorization', `Bearer ${yoloDodoToken}`) .expect(200); - const getReferentielScoresResponseType: GetReferentielScoresResponseType = - responseSnapshotCreation.body as GetReferentielScoresResponseType; - expect(getReferentielScoresResponseType.snapshot?.ref).toBe( - 'user-test-a-accent' - ); - - const responseSnapshotList = await request(app.getHttpServer()) - .get(`/collectivites/1/referentiels/cae/score-snapshots`) - .set('Authorization', `Bearer ${yoloDodoToken}`) - .expect(200); - const expectedSnapshotList: GetScoreSnapshotsResponseType = { + const expectedCurrentSnapshotList: GetScoreSnapshotsResponseType = { collectiviteId: 1, referentielId: ReferentielType.CAE, - typesJalon: [ - ScoreJalon.PRE_AUDIT, - ScoreJalon.POST_AUDIT, - ScoreJalon.DATE_PERSONNALISEE, - ScoreJalon.SCORE_COURANT, - ScoreJalon.VISITE_ANNUELLE, - ], + typesJalon: [ScoreJalon.SCORE_COURANT], snapshots: [ { auditId: null, createdAt: getReferentielScoresCourantResponseType.snapshot! .createdAt as unknown as Date, - createdBy: '17440546-f389-4d4f-bfdb-b0c94a1bd0f9', - modifiedBy: '17440546-f389-4d4f-bfdb-b0c94a1bd0f9', + createdBy: null, + modifiedBy: null, date: getReferentielScoresCourantResponseType.date as unknown as Date, modifiedAt: getReferentielScoresCourantResponseType.snapshot! .modifiedAt as unknown as Date, @@ -282,6 +266,35 @@ describe('Referentiels scoring routes', () => { referentielVersion: '1.0.0', typeJalon: ScoreJalon.SCORE_COURANT, }, + ], + }; + expect(responseCurrentSnapshotList.body).toEqual( + expectedCurrentSnapshotList + ); + + const responseSnapshotCreation = await request(app.getHttpServer()) + .get( + `/collectivites/1/referentiels/cae/scores?snapshotNom=test%20à%20accent&snapshotForceUpdate=true` + ) + .set('Authorization', `Bearer ${yoloDodoToken}`) + .expect(200); + const getReferentielScoresResponseType: GetReferentielScoresResponseType = + responseSnapshotCreation.body as GetReferentielScoresResponseType; + expect(getReferentielScoresResponseType.snapshot?.ref).toBe( + 'user-test-a-accent' + ); + + const responseSnapshotList = await request(app.getHttpServer()) + .get( + `/collectivites/1/referentiels/cae/score-snapshots?typesJalon=date_personnalisee` + ) + .set('Authorization', `Bearer ${yoloDodoToken}`) + .expect(200); + const expectedSnapshotList: GetScoreSnapshotsResponseType = { + collectiviteId: 1, + referentielId: ReferentielType.CAE, + typesJalon: [ScoreJalon.DATE_PERSONNALISEE], + snapshots: [ { date: getReferentielScoresResponseType.date as unknown as Date, nom: 'test à accent',