feat: implement groups risk overview feature with analytics and routes
This commit is contained in:
@@ -10,7 +10,10 @@ import { requestLoggerMiddleware } from '@/http/request-logger';
|
||||
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
|
||||
import { sessionAuthMiddleware } from '@/http/session-auth';
|
||||
import { logger } from '@/logger';
|
||||
import { studentStatusRoutes } from './modules/analytics';
|
||||
import {
|
||||
groupsRiskOverviewRoute,
|
||||
studentStatusRoutes,
|
||||
} from './modules/analytics';
|
||||
import { attendeesRoutes } from './modules/attendees';
|
||||
import { authRoutes } from './modules/auth';
|
||||
import { classesRoutes } from './modules/classes';
|
||||
@@ -46,6 +49,7 @@ api.route('/payments', paymentsRoutes);
|
||||
api.route('/waitlist', waitlistRoutes);
|
||||
api.route('/classes', classesRoutes);
|
||||
api.route('/students', studentStatusRoutes);
|
||||
api.route('/groups-risk', groupsRiskOverviewRoute);
|
||||
|
||||
app.notFound((c) => {
|
||||
return problemJson(c, notFoundProblem(c.req.path));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetGroupsRiskOverview } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/overview', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const useCase = new GetGroupsRiskOverview();
|
||||
const result = await useCase.execute(user.id);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Prisma } from '@generated/prisma/client';
|
||||
import type {
|
||||
AttendanceStatus,
|
||||
GroupRiskLevel,
|
||||
GroupRiskSummary,
|
||||
GroupsRiskOverview,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { ok } from '@gruperly/shared';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { buildGroupWhereForUser } from '@/modules/groups/lib';
|
||||
import {
|
||||
classifyRisk,
|
||||
monthlyAttendanceRate,
|
||||
trailingAbsentStreak,
|
||||
} from '../../lib/risk';
|
||||
import { ANALYTICS_WINDOW_MS } from '../get-overview/use-case';
|
||||
|
||||
type GetGroupsRiskOverviewDeps = {
|
||||
db?: {
|
||||
group: Prisma.GroupDelegate;
|
||||
classSession: Prisma.ClassSessionDelegate;
|
||||
attendee: Prisma.AttendeeDelegate;
|
||||
attendance: Prisma.AttendanceDelegate;
|
||||
};
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
type GroupRow = { id: string; name: string };
|
||||
type AttendeeRow = { id: string; groupId: string };
|
||||
type SessionRow = { id: string; groupId: string };
|
||||
type AttendanceRow = {
|
||||
attendeeId: string;
|
||||
status: AttendanceStatus;
|
||||
classSession: { startsAt: Date };
|
||||
};
|
||||
|
||||
// Resumen de semáforo de riesgo por grupo para la lista de grupos.
|
||||
// Un grupo es HIGH si tiene al menos un alumno en riesgo alto, MEDIUM si
|
||||
// alguno en riesgo moderado y NONE en caso contrario.
|
||||
export class GetGroupsRiskOverview {
|
||||
constructor(private readonly deps: GetGroupsRiskOverviewDeps = {}) {}
|
||||
|
||||
async execute(userId: string): Promise<Result<GroupsRiskOverview, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const now = this.deps.now ?? new Date();
|
||||
const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS);
|
||||
|
||||
const groups = (await db.group.findMany({
|
||||
where: buildGroupWhereForUser(userId) as Prisma.GroupWhereInput,
|
||||
select: { id: true, name: true },
|
||||
})) as GroupRow[];
|
||||
|
||||
if (groups.length === 0) {
|
||||
return ok({ items: [] });
|
||||
}
|
||||
|
||||
const groupIds = groups.map((group) => group.id);
|
||||
const attendeeGroup = new Map<string, string>();
|
||||
const sessionCountByGroup = new Map<string, number>();
|
||||
|
||||
const [attendees, sessions, attendances] = await Promise.all([
|
||||
db.attendee.findMany({
|
||||
where: { groupId: { in: groupIds }, status: 'ACTIVE' },
|
||||
select: { id: true, groupId: true },
|
||||
}) as Promise<AttendeeRow[]>,
|
||||
db.classSession.findMany({
|
||||
where: { groupId: { in: groupIds }, startsAt: { gte: since } },
|
||||
select: { id: true, groupId: true },
|
||||
}) as Promise<SessionRow[]>,
|
||||
db.attendance.findMany({
|
||||
where: { attendee: { groupId: { in: groupIds } } },
|
||||
select: {
|
||||
attendeeId: true,
|
||||
status: true,
|
||||
classSession: { select: { startsAt: true } },
|
||||
},
|
||||
orderBy: { classSession: { startsAt: 'desc' } },
|
||||
}) as Promise<AttendanceRow[]>,
|
||||
]);
|
||||
|
||||
for (const attendee of attendees) {
|
||||
attendeeGroup.set(attendee.id, attendee.groupId);
|
||||
}
|
||||
for (const session of sessions) {
|
||||
sessionCountByGroup.set(session.groupId, (sessionCountByGroup.get(session.groupId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const streamByAttendee = groupAttendanceByAttendee(attendances);
|
||||
|
||||
const worstRiskByGroup = new Map<string, GroupRiskLevel>();
|
||||
for (const attendee of attendees) {
|
||||
const groupId = attendeeGroup.get(attendee.id)!;
|
||||
const stream = (streamByAttendee.get(attendee.id) ?? []).map((row) => ({
|
||||
status: row.status,
|
||||
occurredAt: row.classSession.startsAt,
|
||||
}));
|
||||
const streak = trailingAbsentStreak(stream);
|
||||
const monthlyRate = monthlyAttendanceRate(
|
||||
stream.filter((entry) => entry.occurredAt.getTime() >= since.getTime()),
|
||||
sessionCountByGroup.get(groupId) ?? 0,
|
||||
);
|
||||
const riskLevel = classifyRisk(streak, monthlyRate);
|
||||
if (!riskLevel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = worstRiskByGroup.get(groupId) ?? 'NONE';
|
||||
if (
|
||||
riskLevel === 'HIGH' ||
|
||||
(riskLevel === 'MEDIUM' && current === 'NONE')
|
||||
) {
|
||||
worstRiskByGroup.set(groupId, riskLevel);
|
||||
}
|
||||
}
|
||||
|
||||
const items: GroupRiskSummary[] = groups.map((group) => ({
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
riskLevel: worstRiskByGroup.get(group.id) ?? 'NONE',
|
||||
}));
|
||||
|
||||
return ok({ items });
|
||||
}
|
||||
}
|
||||
|
||||
function groupAttendanceByAttendee(rows: AttendanceRow[]): Map<string, AttendanceRow[]> {
|
||||
const byAttendee = new Map<string, AttendanceRow[]>();
|
||||
for (const row of rows) {
|
||||
const list = byAttendee.get(row.attendeeId) ?? [];
|
||||
list.push(row);
|
||||
byAttendee.set(row.attendeeId, list);
|
||||
}
|
||||
return byAttendee;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as groupsRiskOverviewRoute } from './features/get-groups-risk-overview/route';
|
||||
export { default as studentStatusRoutes } from './features/update-attendee-status/route';
|
||||
export { default as analyticsRoutes } from './routes';
|
||||
114
apps/backend/test/analytics-groups-risk.test.ts
Normal file
114
apps/backend/test/analytics-groups-risk.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const db = {
|
||||
group: {
|
||||
findMany: mock(),
|
||||
},
|
||||
attendee: {
|
||||
findMany: mock(),
|
||||
},
|
||||
classSession: {
|
||||
findMany: mock(),
|
||||
},
|
||||
attendance: {
|
||||
findMany: mock(),
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/lib/prisma', () => ({
|
||||
default: db,
|
||||
getPrismaClient: mock(),
|
||||
UnitOfWork: class {
|
||||
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||
},
|
||||
}));
|
||||
|
||||
import { groupsRiskOverviewRoute } from '@/modules/analytics';
|
||||
|
||||
const userId = 'user-1';
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const at = (offsetDays: number) => new Date(now - offsetDays * DAY_MS);
|
||||
|
||||
function makeApp(userValue: unknown) {
|
||||
const app = new Hono();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('user', userValue as never);
|
||||
await next();
|
||||
});
|
||||
app.route('/groups-risk', groupsRiskOverviewRoute);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('GET /groups-risk/overview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('devuelve items vacíos si el usuario no tiene grupos', async () => {
|
||||
prismaFetchGroupFindMany([]);
|
||||
prismaFetchAttendeeFindMany([]);
|
||||
prismaFetchClassSessionFindMany([]);
|
||||
prismaFetchAttendanceFindMany([]);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups-risk/overview');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('marca HIGH a un grupo con un alumno con 3 ausencias consecutivas', async () => {
|
||||
prismaFetchGroupFindMany([
|
||||
{ id: 'group-1', name: 'Funcional' },
|
||||
{ id: 'group-2', name: 'Yoga' },
|
||||
]);
|
||||
prismaFetchAttendeeFindMany([
|
||||
{ id: 'a-1', groupId: 'group-1' },
|
||||
{ id: 'a-2', groupId: 'group-2' },
|
||||
]);
|
||||
prismaFetchClassSessionFindMany([
|
||||
{ id: 's1', groupId: 'group-1', startsAt: at(-1) },
|
||||
{ id: 's2', groupId: 'group-1', startsAt: at(-2) },
|
||||
{ id: 's3', groupId: 'group-1', startsAt: at(-3) },
|
||||
{ id: 's4', groupId: 'group-2', startsAt: at(-1) },
|
||||
]);
|
||||
prismaFetchAttendanceFindMany([
|
||||
attendance('a-1', 'ABSENT', at(-1)),
|
||||
attendance('a-1', 'ABSENT', at(-2)),
|
||||
attendance('a-1', 'ABSENT', at(-3)),
|
||||
attendance('a-2', 'PRESENT', at(-1)),
|
||||
]);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups-risk/overview');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
items: [
|
||||
{ groupId: 'group-1', groupName: 'Funcional', riskLevel: 'HIGH' },
|
||||
{ groupId: 'group-2', groupName: 'Yoga', riskLevel: 'NONE' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function prismaFetchGroupFindMany(rows: unknown[]) {
|
||||
db.group.findMany.mockResolvedValue(rows);
|
||||
}
|
||||
|
||||
function prismaFetchAttendeeFindMany(rows: unknown[]) {
|
||||
db.attendee.findMany.mockResolvedValue(rows);
|
||||
}
|
||||
|
||||
function prismaFetchClassSessionFindMany(rows: unknown[]) {
|
||||
db.classSession.findMany.mockResolvedValue(rows);
|
||||
}
|
||||
|
||||
function prismaFetchAttendanceFindMany(rows: unknown[]) {
|
||||
db.attendance.findMany.mockResolvedValue(rows);
|
||||
}
|
||||
|
||||
function attendance(attendeeId: string, status: string, startsAt: string) {
|
||||
return { attendeeId, status, classSession: { startsAt } };
|
||||
}
|
||||
@@ -65,7 +65,7 @@ function makeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'session-1',
|
||||
groupId: 'group-1',
|
||||
startsAt: new Date('2026-09-23T19:00:00.000Z'),
|
||||
startsAt: todayAtUtc(),
|
||||
group: {
|
||||
id: 'group-1',
|
||||
name: 'Funcional',
|
||||
@@ -77,6 +77,13 @@ function makeSession(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// Fecha "hoy" a las 19:00 UTC, del día real del runner.
|
||||
function todayAtUtc() {
|
||||
const date = new Date();
|
||||
date.setUTCHours(19, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
describe('classes routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -85,7 +92,7 @@ describe('classes routes', () => {
|
||||
|
||||
describe('GET /classes/today', () => {
|
||||
it('materializa y lista las clases de hoy del profesor', async () => {
|
||||
const startsAt = new Date('2026-09-23T19:00:00.000Z');
|
||||
const startsAt = todayAtUtc();
|
||||
prisma.group.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'group-1',
|
||||
@@ -432,7 +439,7 @@ describe('classes routes', () => {
|
||||
})
|
||||
.format(new Date())
|
||||
.toUpperCase();
|
||||
const startsAt = new Date('2026-09-23T19:00:00.000Z');
|
||||
const startsAt = todayAtUtc();
|
||||
|
||||
prisma.attendee.findUnique.mockResolvedValue({
|
||||
id: 'attendee-1',
|
||||
|
||||
Reference in New Issue
Block a user