feat: implement groups risk overview feature with analytics and routes

This commit is contained in:
Jose Selesan
2026-09-24 20:45:11 -03:00
parent 2e184845e1
commit efde1aaf33
11 changed files with 382 additions and 16 deletions

View File

@@ -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;

View File

@@ -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;
}