From 3a5e7a1ad8b2b1f74a9ccb7149c849ac040eb81d Mon Sep 17 00:00:00 2001 From: Paul Souche Date: Wed, 10 Sep 2025 16:13:07 +0200 Subject: [PATCH] :sparkles: poll stats the good way (NGC-2329) --- src/core/deep-merge-sum.ts | 26 ------- src/core/deep-merge.ts | 37 +++++++++ .../fetch-organisation-public-poll.spec.ts | 24 +++++- .../organisations/organisations.repository.ts | 76 +++++++++++++++---- .../organisations/organisations.service.ts | 23 +++++- .../__tests__/compute-poll-stats.spec.ts | 2 +- .../simulations/simulations.service.ts | 2 +- 7 files changed, 144 insertions(+), 46 deletions(-) delete mode 100644 src/core/deep-merge-sum.ts create mode 100644 src/core/deep-merge.ts diff --git a/src/core/deep-merge-sum.ts b/src/core/deep-merge-sum.ts deleted file mode 100644 index 42549f8..0000000 --- a/src/core/deep-merge-sum.ts +++ /dev/null @@ -1,26 +0,0 @@ -type RecordToDeepMergeSum = { - [key: string]: number | RecordToDeepMergeSum | undefined -} - -export const deepMergeSum = ( - record1: RecordToDeepMergeSum, - record2: RecordToDeepMergeSum -) => - Object.entries(record2).reduce( - (acc, [key, value]): RecordToDeepMergeSum => { - if (typeof value === 'number') { - acc[key] = acc[key] || 0 - if (typeof acc[key] === 'number') { - acc[key] += value - } - } else if (typeof value === 'object') { - acc[key] = acc[key] || {} - if (typeof acc[key] === 'object') { - acc[key] = deepMergeSum(acc[key], value) - } - } - - return acc - }, - { ...record1 } - ) diff --git a/src/core/deep-merge.ts b/src/core/deep-merge.ts new file mode 100644 index 0000000..0a3af38 --- /dev/null +++ b/src/core/deep-merge.ts @@ -0,0 +1,37 @@ +type RecordToDeepMerge = { + [key: string]: number | RecordToDeepMerge | undefined +} + +const deepMerge = ( + record1: RecordToDeepMerge, + record2: RecordToDeepMerge, + operation: (value1: number, value2: number) => number +) => + Object.entries(record2).reduce( + (acc, [key, value]): RecordToDeepMerge => { + if (typeof value === 'number') { + acc[key] = acc[key] || 0 + if (typeof acc[key] === 'number') { + acc[key] = operation(acc[key], value) + } + } else if (typeof value === 'object') { + acc[key] = acc[key] || {} + if (typeof acc[key] === 'object') { + acc[key] = deepMerge(acc[key], value, operation) + } + } + + return acc + }, + { ...record1 } + ) + +export const deepMergeSum = ( + record1: RecordToDeepMerge, + record2: RecordToDeepMerge +) => deepMerge(record1, record2, (a, b) => +(a + b).toPrecision(12)) + +export const deepMergeSubstract = ( + record1: RecordToDeepMerge, + record2: RecordToDeepMerge +) => deepMerge(record1, record2, (a, b) => +(a - b).toPrecision(12)) diff --git a/src/features/organisations/__tests__/fetch-organisation-public-poll.spec.ts b/src/features/organisations/__tests__/fetch-organisation-public-poll.spec.ts index 1c8ef16..ef5f8ce 100644 --- a/src/features/organisations/__tests__/fetch-organisation-public-poll.spec.ts +++ b/src/features/organisations/__tests__/fetch-organisation-public-poll.spec.ts @@ -6,9 +6,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { prisma } from '../../../adapters/prisma/client.js' import * as prismaTransactionAdapter from '../../../adapters/prisma/transaction.js' import app from '../../../app.js' +import { deepMergeSubstract, deepMergeSum } from '../../../core/deep-merge.js' import logger from '../../../logger.js' import { login } from '../../authentication/__tests__/fixtures/login.fixture.js' import { COOKIE_NAME } from '../../authentication/authentication.service.js' +import type { ComputedResultSchema } from '../../simulations/simulations.validator.js' import { createOrganisation, createOrganisationPoll, @@ -111,10 +113,12 @@ describe('Given a NGC user', () => { }) describe('And he did participate to the poll', () => { + let computedResults: ComputedResultSchema let userId: string beforeEach(async () => { ;({ + computedResults, user: { id: userId }, } = await createOrganisationPollSimulation({ agent, @@ -141,7 +145,12 @@ describe('Given a NGC user', () => { finished: 1, hasParticipated: true, }, - computedResults: expect.any(Object), + computedResults, + userComputedResults: computedResults, + otherComputedResults: deepMergeSubstract( + computedResults, + computedResults + ), funFacts: Object.fromEntries( Object.entries(modelFunFacts).map(([k]) => [ k, @@ -174,7 +183,12 @@ describe('Given a NGC user', () => { finished: 1, hasParticipated: true, }, - computedResults: expect.any(Object), + computedResults, + userComputedResults: computedResults, + otherComputedResults: deepMergeSubstract( + computedResults, + computedResults + ), funFacts: Object.fromEntries( Object.entries(modelFunFacts).map(([k]) => [ k, @@ -395,7 +409,11 @@ describe('Given a NGC user', () => { finished: 3, hasParticipated: false, }, - computedResults: expect.any(Object), + computedResults: simulations.reduce( + (acc, { computedResults }) => + deepMergeSum(acc, computedResults), + {} + ), funFacts: Object.fromEntries( Object.entries(modelFunFacts).map(([k]) => [ k, diff --git a/src/features/organisations/organisations.repository.ts b/src/features/organisations/organisations.repository.ts index 188dde6..bc39ff7 100644 --- a/src/features/organisations/organisations.repository.ts +++ b/src/features/organisations/organisations.repository.ts @@ -1,5 +1,6 @@ import type { FunFacts } from '@incubateur-ademe/nosgestesclimat' import type { Prisma } from '@prisma/client' +import type { JsonValue } from '@prisma/client/runtime/library' import type { Request } from 'express' import slugify from 'slugify' import { @@ -9,10 +10,8 @@ import { defaultVerifiedUserSelection, } from '../../adapters/prisma/selection.js' import type { Session } from '../../adapters/prisma/transaction.js' -import type { - ComputedResultSchema, - SimulationParams, -} from '../simulations/simulations.validator.js' +import type { SimulationParams } from '../simulations/simulations.validator.js' +import { ComputedResultSchema } from '../simulations/simulations.validator.js' import type { OrganisationCreateDto, OrganisationParams, @@ -329,14 +328,27 @@ export const fetchUserOrganisation = ( const findUniquePollSlug = findModelUniqueSlug('poll') +type SimulationsInfo = { + count: number + finished: number +} & ( + | { + hasParticipated: false + } + | { + hasParticipated: true + userComputedResults: ComputedResultSchema + } +) + const fetchPollSimulationsInfo = async ( { poll: { id }, user: { userId }, }: { poll: { id: string }; user: { userId: string } }, { session }: { session: Session } -) => { - const [count, finished, userCount] = await Promise.all([ +): Promise => { + const [count, finished, userSimulation] = await Promise.all([ session.simulationPoll.count({ where: { pollId: id, @@ -350,7 +362,7 @@ const fetchPollSimulationsInfo = async ( }, }, }), - session.simulationPoll.count({ + session.simulationPoll.findFirst({ where: { pollId: id, simulation: { @@ -359,13 +371,51 @@ const fetchPollSimulationsInfo = async ( }, }, }, + select: { + simulation: { + select: { + computedResults: true, + }, + }, + }, + orderBy: { + simulation: { + createdAt: 'desc', + }, + }, }), ]) + const userComputedResults = ComputedResultSchema.safeParse( + userSimulation?.simulation.computedResults + ) + return { count, finished, - hasParticipated: !!userCount, + ...(userComputedResults.success + ? { + hasParticipated: true, + userComputedResults: userComputedResults.data, + } + : { + hasParticipated: false, + }), + } +} + +const sanitizePollComputedResults = ({ + computedResults: rawComputedResults, + ...poll +}: T): Omit & { + computedResults: ComputedResultSchema | null +} => { + const computedResults = ComputedResultSchema.safeParse(rawComputedResults) + return { + ...poll, + ...(computedResults.success + ? { computedResults: computedResults.data } + : { computedResults: null }), } } @@ -428,9 +478,9 @@ export const createOrganisationPoll = async ( ) return { + poll: sanitizePollComputedResults(poll), simulationsInfos, organisation, - poll, } } @@ -519,9 +569,9 @@ export const updateOrganisationPoll = async ( ) return { + poll: sanitizePollComputedResults(poll), simulationsInfos, organisation, - poll, } } @@ -564,7 +614,7 @@ export const fetchOrganisationPolls = async ( organisation, polls: await Promise.all( polls.map(async (poll) => ({ - poll, + poll: sanitizePollComputedResults(poll), simulationsInfos: await fetchPollSimulationsInfo( { poll, user }, { session } @@ -604,7 +654,7 @@ export const fetchOrganisationPoll = async ( return { simulationsInfos, organisation, - poll, + poll: sanitizePollComputedResults(poll), } } @@ -644,9 +694,9 @@ export const fetchOrganisationPublicPoll = async ( ) return { + poll: sanitizePollComputedResults(poll), simulationsInfos, organisation, - poll, } } diff --git a/src/features/organisations/organisations.service.ts b/src/features/organisations/organisations.service.ts index 4b5a17f..dfd2ee9 100644 --- a/src/features/organisations/organisations.service.ts +++ b/src/features/organisations/organisations.service.ts @@ -12,6 +12,7 @@ import type { Session } from '../../adapters/prisma/transaction.js' import { transaction } from '../../adapters/prisma/transaction.js' import { client } from '../../adapters/scaleway/client.js' import { config } from '../../config.js' +import { deepMergeSubstract } from '../../core/deep-merge.js' import { EntityNotFoundException } from '../../core/errors/EntityNotFoundException.js' import { ForbiddenException } from '../../core/errors/ForbiddenException.js' import { EventBus } from '../../core/event-bus/event-bus.js' @@ -269,7 +270,8 @@ const isOrganisationAdmin = ( const pollToDto = ({ poll: { organisationId: _1, computeRealTimeStats: _2, ...poll }, - simulationsInfos: simulations, + simulationsInfos: { count, finished, hasParticipated }, + simulationsInfos, organisation, user, }: { @@ -293,7 +295,24 @@ const pollToDto = ({ defaultAdditionalQuestions: poll.defaultAdditionalQuestions?.map( ({ type }) => type ), - simulations, + simulations: { + count, + finished, + hasParticipated, + }, + ...(simulationsInfos.hasParticipated + ? { + userComputedResults: simulationsInfos.userComputedResults, + ...(poll.computedResults + ? { + otherComputedResults: deepMergeSubstract( + poll.computedResults, + simulationsInfos.userComputedResults + ), + } + : {}), + } + : {}), }) export const createPoll = async ({ diff --git a/src/features/simulations/__tests__/compute-poll-stats.spec.ts b/src/features/simulations/__tests__/compute-poll-stats.spec.ts index 60f4e4a..2dbdb75 100644 --- a/src/features/simulations/__tests__/compute-poll-stats.spec.ts +++ b/src/features/simulations/__tests__/compute-poll-stats.spec.ts @@ -127,7 +127,7 @@ describe('Given a poll participation', () => { const cache = JSON.parse(rawCache!) expect(cache).toEqual({ - computedResults: expect.any(Object), + computedResults: event.attributes.simulation.computedResults, simulationCount: 1, funFactValues: Object.fromEntries( Object.entries(modelFunFacts).map(([_, v]) => [v, expect.any(Number)]) diff --git a/src/features/simulations/simulations.service.ts b/src/features/simulations/simulations.service.ts index 1173e48..4e05486 100644 --- a/src/features/simulations/simulations.service.ts +++ b/src/features/simulations/simulations.service.ts @@ -14,7 +14,7 @@ import type { Session } from '../../adapters/prisma/transaction.js' import { transaction } from '../../adapters/prisma/transaction.js' import { redis } from '../../adapters/redis/client.js' import { KEYS } from '../../adapters/redis/constant.js' -import { deepMergeSum } from '../../core/deep-merge-sum.js' +import { deepMergeSum } from '../../core/deep-merge.js' import { EntityNotFoundException } from '../../core/errors/EntityNotFoundException.js' import { ForbiddenException } from '../../core/errors/ForbiddenException.js' import { EventBus } from '../../core/event-bus/event-bus.js'