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> { 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(); const sessionCountByGroup = new Map(); const [attendees, sessions, attendances] = await Promise.all([ db.attendee.findMany({ where: { groupId: { in: groupIds }, status: 'ACTIVE' }, select: { id: true, groupId: true }, }) as Promise, db.classSession.findMany({ where: { groupId: { in: groupIds }, startsAt: { gte: since } }, select: { id: true, groupId: true }, }) as Promise, db.attendance.findMany({ where: { attendee: { groupId: { in: groupIds } } }, select: { attendeeId: true, status: true, classSession: { select: { startsAt: true } }, }, orderBy: { classSession: { startsAt: 'desc' } }, }) as Promise, ]); 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(); 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 { const byAttendee = new Map(); for (const row of rows) { const list = byAttendee.get(row.attendeeId) ?? []; list.push(row); byAttendee.set(row.attendeeId, list); } return byAttendee; }