Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"jobs:recoverMatomoStats": "node dist/src/jobs/recover-matomo-stats.js",
"jobs:recoverNewsletterStats": "node dist/src/jobs/recover-newsletter-stats.js",
"lint": "prettier --check \"**/*.{js,ts,md,json}\" && eslint . --ext .js,.ts",
"run-script": "ts-node --project tsconfig.json",
"prestart": "yarn db:doc",
"start": "NODE_OPTIONS=--max-old-space-size=8192 node ./dist/src/index.js",
"start:worker": "NODE_OPTIONS=--max-old-space-size=8192 node ./dist/src/worker.js",
Expand Down Expand Up @@ -100,7 +99,7 @@
"prisma": "^5.18.0",
"redis-mock": "^0.56.3",
"rimraf": "^6.0.1",
"ts-node": "^10.9.2",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.37.0",
"vitest": "^3.2.4"
Expand Down
8 changes: 8 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import requestIp from 'request-ip'
import { createExpressEndpoints } from '@ts-rest/express'
import { generateOpenApi } from '@ts-rest/open-api'
import cors from 'cors'
import { StatusCodes } from 'http-status-codes'
import path from 'path'
import swaggerUi from 'swagger-ui-express'
import { origin } from './config.js'
Expand All @@ -21,6 +22,7 @@ import northstarRatingsController from './features/northstar-ratings/northstar-r
import organisationController from './features/organisations/organisations.controller.js'
import quizzAnswersController from './features/quizz-answers/quizz-answers.controller.js'
import simulationController from './features/simulations/simulations.controller.js'
import statsController from './features/stats/stats.controller.js'
import usersController from './features/users/users.controller.js'
import logger from './logger.js'
import getNewsletterSubscriptions from './routes/settings/getNewsletterSubscriptions.js'
Expand Down Expand Up @@ -79,9 +81,15 @@ app.use('/northstar-ratings', northstarRatingsController)
app.use('/organisations', organisationController)
app.use('/quizz-answers', quizzAnswersController)
app.use('/simulations', simulationController)
app.use('/stats', statsController)
app.use('/users', usersController)
app.use('/verification-codes', verificationCodeController)

// public routes
app.get('/api/stats', (_, res) =>
res.redirect(StatusCodes.MOVED_PERMANENTLY, '/stats/v1/northstar')
)

createExpressEndpoints(
integrationsApiContract,
integrationsApiController,
Expand Down
10 changes: 10 additions & 0 deletions src/constants/period.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ValueOf } from '../types/types'

export const PERIODS = {
year: 'year',
month: 'month',
week: 'week',
day: 'day',
} as const

export type PERIODS = ValueOf<typeof PERIODS>
203 changes: 203 additions & 0 deletions src/features/stats/__tests__/fetch-northstar-stats.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { faker } from '@faker-js/faker'
import {
MatomoStatsDevice,
MatomoStatsKind,
MatomoStatsSource,
} from '@prisma/client'
import dayjs from 'dayjs'
import { StatusCodes } from 'http-status-codes'
import supertest from 'supertest'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { prisma } from '../../../adapters/prisma/client.js'
import app from '../../../app.js'
import { PERIODS } from '../../../constants/period.js'
import logger from '../../../logger.js'
import type { NorthstarStat } from '../stats.repository.js'
import { upsertStat } from '../stats.repository.js'

describe('Given a NGC user', () => {
const agent = supertest(app)
const url = '/api/stats'

describe('When fetching northstar stats', () => {
test(`Then it returns a ${StatusCodes.MOVED_PERMANENTLY} redirection`, async () => {
const response = await agent
.get(url)
.expect(StatusCodes.MOVED_PERMANENTLY)

expect(response.get('location')).toBe('/stats/v1/northstar')
})
})
})

describe('Given a redirected NGC user', () => {
const agent = supertest(app)
const url = '/stats/v1/northstar'

afterEach(() => prisma.matomoStats.deleteMany())

describe('When fetching northstar stats', () => {
describe('And invalid period', () => {
test(`Then it returns a ${StatusCodes.BAD_REQUEST} error`, async () => {
await agent
.get(url)
.query({ periodicity: 'hour' })
.expect(StatusCodes.BAD_REQUEST)
})
})

describe('And invalid since', () => {
test(`Then it returns a ${StatusCodes.BAD_REQUEST} error`, async () => {
await agent
.get(url)
.query({ since: -1 })
.expect(StatusCodes.BAD_REQUEST)
})
})

describe('And no stats', () => {
test(`Then it returns a ${StatusCodes.OK} response with empty stats`, async () => {
const { body } = await agent.get(url).expect(StatusCodes.OK)

expect(body).toEqual({
stats: [],
description: 'Nombre de simulations réalisées',
})
})
})

describe.each(
Object.values(PERIODS).map((periodicity) => ({ periodicity }))
)('And $periodicity stats', ({ periodicity }) => {
let stats: NorthstarStat[]

beforeEach(async () => {
const fivePeriodsAgo = dayjs()
.locale('fr', {
weekStart: 1,
})
.endOf(periodicity)
.subtract(4, periodicity)
.startOf(periodicity)
const lastFivePeriods = new Array(5)
.fill(null)
.map((_, i) => fivePeriodsAgo.add(i, periodicity))

let accumulator = 0

stats = []

for (const period of lastFivePeriods) {
const finishedSimulations = faker.number.int({
min: 1000,
max: 999999,
})

const firstAnswer =
finishedSimulations +
faker.number.int({
min: 100,
max: 9999,
})

const visits =
finishedSimulations *
faker.number.int({
min: 3,
max: 15,
})

const stat = await upsertStat(
{
date: new Date(`${period.format('YYYY-MM-DD')}T00:00:00.000Z`),
device: MatomoStatsDevice.all,
iframe: false,
source: MatomoStatsSource.beta,
kind: MatomoStatsKind.all,
finishedSimulations,
firstAnswer,
visits,
},
{ session: prisma }
)

accumulator += stat.finishedSimulations

stats.push({
date: period.format('YYYY-MM-DD'),
value: accumulator,
})
}
})

test(`Then it returns a ${StatusCodes.OK} response with ${periodicity}ly stats`, async () => {
const { body } = await agent
.get(url)
.query({ periodicity })
.expect(StatusCodes.OK)

expect(body).toEqual({
stats,
description: 'Nombre de simulations réalisées',
})
})

describe(`And since query`, () => {
test(`Then it returns a ${StatusCodes.OK} response with stats since the given periods`, async () => {
const since =
stats.length - faker.number.int({ min: 1, max: stats.length - 1 })

const { body } = await agent
.get(url)
.query({ periodicity, since })
.expect(StatusCodes.OK)

expect(body).toEqual({
stats: stats.slice(stats.length - since),
description: 'Nombre de simulations réalisées',
})
})

test(`Then it returns a ${StatusCodes.OK} response with all stats if since is greater than the number of stats`, async () => {
const since =
stats.length + faker.number.int({ min: 1, max: stats.length })

const { body } = await agent
.get(url)
.query({ periodicity, since })
.expect(StatusCodes.OK)

expect(body).toEqual({
stats,
description: 'Nombre de simulations réalisées',
})
})
})
})

describe('And database failure', () => {
const databaseError = new Error('Something went wrong')

beforeEach(() => {
vi.spyOn(prisma, '$queryRaw').mockRejectedValueOnce(databaseError)
})

afterEach(() => {
vi.spyOn(prisma, '$queryRaw').mockRestore()
})

test(`Then it returns a ${StatusCodes.INTERNAL_SERVER_ERROR} error`, async () => {
await agent.get(url).expect(StatusCodes.INTERNAL_SERVER_ERROR)
})

test(`Then it logs the exception`, async () => {
await agent.get(url)

expect(logger.error).toHaveBeenCalledWith(
'Northstar stats fetch failed',
databaseError
)
})
})
})
})
32 changes: 32 additions & 0 deletions src/features/stats/stats.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import express from 'express'
import { StatusCodes } from 'http-status-codes'
import { validateRequest } from 'zod-express-middleware'
import logger from '../../logger.js'
import { fetchNorthstarStats } from './stats.service.js'
import {
NorthstarStatsFetchQuery,
NorthstarStatsFetchValidator,
} from './stats.validator.js'

const router = express.Router()

/**
* Returns northstar stats for a given period
*/
router
.route('/v1/northstar')
.get(validateRequest(NorthstarStatsFetchValidator), async (req, res) => {
try {
const stats = await fetchNorthstarStats(
NorthstarStatsFetchQuery.parse(req.query)
)

return res.status(StatusCodes.OK).json(stats)
} catch (err) {
logger.error('Northstar stats fetch failed', err)

return res.status(StatusCodes.INTERNAL_SERVER_ERROR).end()
}
})

export default router
42 changes: 41 additions & 1 deletion src/features/stats/stats.repository.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type {
import {
MatomoStatsDevice,
MatomoStatsKind,
MatomoStatsSource,
Prisma,
} from '@prisma/client'
import type { ListIds } from '../../adapters/brevo/constant.js'
import type { Session } from '../../adapters/prisma/transaction.js'
import type { PERIODS } from '../../constants/period.js'

export const upsertStat = (
{
Expand Down Expand Up @@ -76,3 +78,41 @@ export const createNewsLetterStats = (
},
})
}

export type NorthstarStat = {
date: string
value: number
}

export const getNorthstarStats = (
{ periodicity }: { periodicity: PERIODS },
{ session }: { session: Session }
) => {
return session.$queryRaw<NorthstarStat[]>(
Prisma.sql`
WITH
period_data AS (
SELECT TO_CHAR(DATE_TRUNC(${periodicity}, date), 'YYYY-MM-DD') AS start_period, SUM("finishedSimulations") AS value
FROM "ngc"."MatomoStats"
WHERE "source" = cast(${MatomoStatsSource.beta} as ngc."MatomoStatsSource") AND "kind" = cast(${MatomoStatsKind.all} as ngc."MatomoStatsKind") AND "referrer" = 'all' AND "device" = cast(${MatomoStatsDevice.all} as ngc."MatomoStatsDevice") AND "iframe" = false
GROUP BY start_period
),
period_intervals AS (
SELECT TO_CHAR(DATE_TRUNC(${periodicity}, interval_start), 'YYYY-MM-DD') as start_period
FROM generate_series(DATE_TRUNC(${periodicity},(
SELECT MIN(date)
FROM "ngc"."MatomoStats"
WHERE "source" = cast(${MatomoStatsSource.beta} as ngc."MatomoStatsSource") AND "kind" = cast(${MatomoStatsKind.all} as ngc."MatomoStatsKind") AND "referrer" = 'all' AND "device" = cast(${MatomoStatsDevice.all} as ngc."MatomoStatsDevice") AND "iframe" = false
)), now(), ('1 ' || ${periodicity})::interval) AS interval_start
)
SELECT
period_intervals.start_period as "date",
(SUM(coalesce(period_data.value, 0)) OVER (ORDER BY period_intervals.start_period))::integer AS "value"
FROM
period_data
RIGHT OUTER JOIN period_intervals on period_intervals.start_period = period_data.start_period
ORDER BY
period_intervals.start_period;
`
)
}
16 changes: 15 additions & 1 deletion src/features/stats/stats.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import { clients } from '../../adapters/matomo/index.js'
import { prisma } from '../../adapters/prisma/client.js'
import { isPrismaErrorUniqueConstraintFailed } from '../../core/typeguards/isPrismaError.js'
import logger from '../../logger.js'
import { createNewsLetterStats, upsertStat } from './stats.repository.js'
import {
createNewsLetterStats,
getNorthstarStats,
upsertStat,
} from './stats.repository.js'
import type { NorthstarStatsFetchQuery } from './stats.validator.js'

const NB_VISITS_MIN = 10

Expand Down Expand Up @@ -262,3 +267,12 @@ export const recoverNewsletterSubscriptions = async (date: string) => {
)
}
}

export const fetchNorthstarStats = async (query: NorthstarStatsFetchQuery) => {
const stats = await getNorthstarStats(query, { session: prisma })

return {
description: 'Nombre de simulations réalisées',
stats: stats.slice(Math.max(stats.length - query.since, 0)),
}
}
17 changes: 17 additions & 0 deletions src/features/stats/stats.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z } from 'zod'
import { PERIODS } from '../../constants/period.js'

export const NorthstarStatsFetchQuery = z
.object({
periodicity: z.nativeEnum(PERIODS).default(PERIODS.month),
since: z.coerce.number().int().positive().default(Number.MAX_VALUE),
})
.strict()

export type NorthstarStatsFetchQuery = z.infer<typeof NorthstarStatsFetchQuery>

export const NorthstarStatsFetchValidator = {
body: z.object({}).strict().optional(),
params: z.object({}).strict().optional(),
query: NorthstarStatsFetchQuery,
}
Loading