136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
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;
|
|
} |