Skip to content

Commit e649812

Browse files
author
ledouxm
committed
feat: add stats API and frontend components for statistics overview and admin insights
1 parent 20db827 commit e649812

File tree

5 files changed

+760
-0
lines changed

5 files changed

+760
-0
lines changed

packages/backend/src/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { isDev } from "./envVars";
1818
import { stateReportPlugin } from "./routes/stateReportRoutes";
1919
import { validationPlugin } from "./routes/validationRoutes";
2020
import { adminPlugin } from "./routes/adminRoutes";
21+
import { statsPlugin } from "./routes/statsRoutes";
2122

2223
const debug = makeDebug("fastify");
2324

@@ -75,6 +76,7 @@ export const initFastify = async () => {
7576
await instance.register(validationPlugin);
7677
await instance.register(syncPlugin);
7778
await instance.register(adminPlugin, { prefix: "/admin" });
79+
await instance.register(statsPlugin, { prefix: "/stats" });
7880
},
7981
{ prefix: "/api" },
8082
);
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox";
2+
import { db } from "../db/db";
3+
import { sql } from "kysely";
4+
import { authenticateAdmin } from "./adminMiddleware";
5+
6+
export const statsPlugin: FastifyPluginAsyncTypebox = async (fastify) => {
7+
fastify.get(
8+
"/public",
9+
{
10+
schema: {
11+
querystring: Type.Object({
12+
from: Type.Optional(Type.String()),
13+
to: Type.Optional(Type.String()),
14+
}),
15+
response: {
16+
200: Type.Object({
17+
totalConstats: Type.Number(),
18+
totalReports: Type.Number(),
19+
totalUsers: Type.Number(),
20+
usersWithNoDocuments: Type.Number(),
21+
activeUsersInPeriod: Type.Number(),
22+
periodFrom: Type.String(),
23+
periodTo: Type.String(),
24+
}),
25+
},
26+
},
27+
},
28+
async (request) => {
29+
const now = new Date();
30+
const defaultFrom = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
31+
const periodFrom = request.query.from ?? defaultFrom.toISOString().slice(0, 10);
32+
const periodTo = request.query.to ?? now.toISOString().slice(0, 10);
33+
34+
const [
35+
totalConstatsResult,
36+
totalReportsResult,
37+
totalUsersResult,
38+
usersWithNoDocumentsResult,
39+
activeUsersResult,
40+
] = await Promise.all([
41+
db
42+
.selectFrom("state_report")
43+
.where("disabled", "is not", true)
44+
.select(db.fn.countAll<number>().as("count"))
45+
.executeTakeFirst(),
46+
47+
db
48+
.selectFrom("report")
49+
.where("disabled", "is not", true)
50+
.select(db.fn.countAll<number>().as("count"))
51+
.executeTakeFirst(),
52+
53+
db
54+
.selectFrom("user")
55+
.select(db.fn.countAll<number>().as("count"))
56+
.executeTakeFirst(),
57+
58+
db
59+
.selectFrom("user")
60+
.where((eb) =>
61+
eb.and([
62+
eb.not(
63+
eb.exists(
64+
eb.selectFrom("report").whereRef("report.createdBy", "=", "user.id").select("report.id"),
65+
),
66+
),
67+
eb.not(
68+
eb.exists(
69+
eb
70+
.selectFrom("state_report")
71+
.whereRef("state_report.created_by", "=", "user.id")
72+
.select("state_report.id"),
73+
),
74+
),
75+
]),
76+
)
77+
.select(db.fn.countAll<number>().as("count"))
78+
.executeTakeFirst(),
79+
80+
db
81+
.selectFrom(
82+
sql<{ user_id: string }>`(
83+
SELECT r."createdBy" AS user_id
84+
FROM sent_email se
85+
JOIN report r ON r.id = se.report_id
86+
WHERE se.sent_at::date >= ${periodFrom}::date AND se.sent_at::date <= ${periodTo}::date
87+
UNION
88+
SELECT sr.created_by AS user_id
89+
FROM state_report_sent_email srse
90+
JOIN state_report sr ON sr.id = srse.state_report_id
91+
WHERE srse.sent_at::date >= ${periodFrom}::date AND srse.sent_at::date <= ${periodTo}::date
92+
)`.as("active_users"),
93+
)
94+
.select(db.fn.countAll<number>().as("count"))
95+
.executeTakeFirst(),
96+
]);
97+
98+
return {
99+
totalConstats: Number(totalConstatsResult?.count ?? 0),
100+
totalReports: Number(totalReportsResult?.count ?? 0),
101+
totalUsers: Number(totalUsersResult?.count ?? 0),
102+
usersWithNoDocuments: Number(usersWithNoDocumentsResult?.count ?? 0),
103+
activeUsersInPeriod: Number(activeUsersResult?.count ?? 0),
104+
periodFrom,
105+
periodTo,
106+
};
107+
},
108+
);
109+
110+
fastify.get(
111+
"/admin",
112+
{
113+
schema: {
114+
response: {
115+
200: Type.Object({
116+
constatsByService: Type.Array(
117+
Type.Object({
118+
serviceId: Type.String(),
119+
serviceName: Type.Union([Type.String(), Type.Null()]),
120+
sentConstats: Type.Number(),
121+
}),
122+
),
123+
abandonedConstats: Type.Number(),
124+
}),
125+
},
126+
},
127+
preHandler: [authenticateAdmin],
128+
},
129+
async () => {
130+
const [constatsByService, abandonedResult] = await Promise.all([
131+
db
132+
.selectFrom("service")
133+
.leftJoin("state_report", (join) =>
134+
join
135+
.onRef("state_report.service_id", "=", "service.id")
136+
.on("state_report.alerts_sent", "=", true)
137+
.on("state_report.disabled", "is not", true),
138+
)
139+
.groupBy(["service.id", "service.name"])
140+
.orderBy(sql`COUNT(DISTINCT state_report.id)`, "desc")
141+
.select([
142+
"service.id as serviceId",
143+
"service.name as serviceName",
144+
db.fn.count<number>("state_report.id").distinct().as("sentConstats"),
145+
])
146+
.execute(),
147+
148+
db
149+
.selectFrom("state_report")
150+
.where("alerts_sent", "=", false)
151+
.where("disabled", "is not", true)
152+
.where("created_at", "<", sql<string>`NOW() - INTERVAL '21 days'`)
153+
.select(db.fn.countAll<number>().as("count"))
154+
.executeTakeFirst(),
155+
]);
156+
157+
return {
158+
constatsByService: constatsByService.map((r) => ({
159+
serviceId: r.serviceId,
160+
serviceName: r.serviceName ?? null,
161+
sentConstats: Number(r.sentConstats),
162+
})),
163+
abandonedConstats: Number(abandonedResult?.count ?? 0),
164+
};
165+
},
166+
);
167+
};

packages/frontend/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,15 @@ import { Route as ConstatConstatIdRouteImport } from './routes/constat.$constatI
2525
import { Route as ConstatValidationTokenRouteImport } from './routes/constat-validation.$token'
2626
import { Route as ConstatConstatIdPdfRouteImport } from './routes/constat_.$constatId.pdf'
2727

28+
const StatsLazyRouteImport = createFileRoute('/stats')()
2829
const AdminLazyRouteImport = createFileRoute('/admin')()
2930
const ResetPasswordIndexLazyRouteImport = createFileRoute('/reset-password/')()
3031

32+
const StatsLazyRoute = StatsLazyRouteImport.update({
33+
id: '/stats',
34+
path: '/stats',
35+
getParentRoute: () => rootRouteImport,
36+
} as any).lazy(() => import('./routes/stats.lazy').then((d) => d.Route))
3137
const AdminLazyRoute = AdminLazyRouteImport.update({
3238
id: '/admin',
3339
path: '/admin',
@@ -115,6 +121,7 @@ export interface FileRoutesByFullPath {
115121
'/mentions-legales': typeof MentionsLegalesRoute
116122
'/service': typeof ServiceRoute
117123
'/admin': typeof AdminLazyRoute
124+
'/stats': typeof StatsLazyRoute
118125
'/constat-validation/$token': typeof ConstatValidationTokenRoute
119126
'/constat/$constatId': typeof ConstatConstatIdRoute
120127
'/edit/$reportId': typeof EditReportIdRoute
@@ -132,6 +139,7 @@ export interface FileRoutesByTo {
132139
'/mentions-legales': typeof MentionsLegalesRoute
133140
'/service': typeof ServiceRoute
134141
'/admin': typeof AdminLazyRoute
142+
'/stats': typeof StatsLazyRoute
135143
'/constat-validation/$token': typeof ConstatValidationTokenRoute
136144
'/constat/$constatId': typeof ConstatConstatIdRoute
137145
'/edit/$reportId': typeof EditReportIdRoute
@@ -150,6 +158,7 @@ export interface FileRoutesById {
150158
'/mentions-legales': typeof MentionsLegalesRoute
151159
'/service': typeof ServiceRoute
152160
'/admin': typeof AdminLazyRoute
161+
'/stats': typeof StatsLazyRoute
153162
'/constat-validation/$token': typeof ConstatValidationTokenRoute
154163
'/constat/$constatId': typeof ConstatConstatIdRoute
155164
'/edit/$reportId': typeof EditReportIdRoute
@@ -169,6 +178,7 @@ export interface FileRouteTypes {
169178
| '/mentions-legales'
170179
| '/service'
171180
| '/admin'
181+
| '/stats'
172182
| '/constat-validation/$token'
173183
| '/constat/$constatId'
174184
| '/edit/$reportId'
@@ -186,6 +196,7 @@ export interface FileRouteTypes {
186196
| '/mentions-legales'
187197
| '/service'
188198
| '/admin'
199+
| '/stats'
189200
| '/constat-validation/$token'
190201
| '/constat/$constatId'
191202
| '/edit/$reportId'
@@ -203,6 +214,7 @@ export interface FileRouteTypes {
203214
| '/mentions-legales'
204215
| '/service'
205216
| '/admin'
217+
| '/stats'
206218
| '/constat-validation/$token'
207219
| '/constat/$constatId'
208220
| '/edit/$reportId'
@@ -221,6 +233,7 @@ export interface RootRouteChildren {
221233
MentionsLegalesRoute: typeof MentionsLegalesRoute
222234
ServiceRoute: typeof ServiceRoute
223235
AdminLazyRoute: typeof AdminLazyRoute
236+
StatsLazyRoute: typeof StatsLazyRoute
224237
ConstatValidationTokenRoute: typeof ConstatValidationTokenRoute
225238
ConstatConstatIdRoute: typeof ConstatConstatIdRoute
226239
EditReportIdRoute: typeof EditReportIdRoute
@@ -232,6 +245,13 @@ export interface RootRouteChildren {
232245

233246
declare module '@tanstack/react-router' {
234247
interface FileRoutesByPath {
248+
'/stats': {
249+
id: '/stats'
250+
path: '/stats'
251+
fullPath: '/stats'
252+
preLoaderRoute: typeof StatsLazyRouteImport
253+
parentRoute: typeof rootRouteImport
254+
}
235255
'/admin': {
236256
id: '/admin'
237257
path: '/admin'
@@ -349,6 +369,7 @@ const rootRouteChildren: RootRouteChildren = {
349369
MentionsLegalesRoute: MentionsLegalesRoute,
350370
ServiceRoute: ServiceRoute,
351371
AdminLazyRoute: AdminLazyRoute,
372+
StatsLazyRoute: StatsLazyRoute,
352373
ConstatValidationTokenRoute: ConstatValidationTokenRoute,
353374
ConstatConstatIdRoute: ConstatConstatIdRoute,
354375
EditReportIdRoute: EditReportIdRoute,

0 commit comments

Comments
 (0)