feat: add absence notification and analytics features

- Implemented AbsenceNotifyView for notifying absence with session details.
- Created AnalyticsView for displaying group analytics and managing students at risk.
- Added ClassAttendanceView for marking attendance in classes.
- Defined new schemas for analytics and attendance in shared package.
This commit is contained in:
Jose Selesan
2026-09-23 16:24:45 -03:00
parent a363e9e8fc
commit 2e184845e1
59 changed files with 3854 additions and 23 deletions

View File

@@ -0,0 +1,91 @@
import { AttendanceStatus } from '@generated/prisma/client';
import type { NotifyAbsenceResult, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { notFoundResourceProblem, validationProblem } from '@/http/problem-builders';
import type { UnitOfWork } from '@/lib/prisma';
import { type ClassesDb, sessionNotFound } from './helpers';
type RegisterAbsenceDeps = {
db: Pick<ClassesDb, 'classSession' | 'attendee'>;
unitOfWork: UnitOfWork;
};
type RegisterAbsenceInput = {
attendeeId: string;
classSessionId: string;
now: Date;
releaseHours: number;
};
// Núcleo compartido del aviso de ausencia (profesor autenticado y alumno con token):
// - Marca la asistencia como EXCUSED (upsert idempotente).
// - Si la anticipación alcanza `releaseHours`, crea/conserva un SlotRelease para liberar el cupo.
export async function registerAbsence(
deps: RegisterAbsenceDeps,
input: RegisterAbsenceInput,
): Promise<Result<NotifyAbsenceResult, ProblemDetails>> {
const { db, unitOfWork } = deps;
const { attendeeId, classSessionId, now, releaseHours } = input;
const session = await db.classSession.findUnique({ where: { id: classSessionId } });
if (!session) {
return err(sessionNotFound(classSessionId));
}
const attendee = await db.attendee.findUnique({ where: { id: attendeeId } });
if (!attendee) {
return err(notFoundResourceProblem('Attendee', attendeeId));
}
if (attendee.groupId !== session.groupId) {
return err(
validationProblem({ detail: 'El alumno no pertenece al grupo de esta clase.' }),
);
}
const anticipationMs = session.startsAt.getTime() - now.getTime();
const slotReleased = anticipationMs >= releaseHours * 60 * 60 * 1000;
return unitOfWork.executeResult(async (tx) => {
await tx.attendance.upsert({
where: {
attendeeId_classSessionId: {
attendeeId,
classSessionId,
},
},
create: {
attendeeId,
classSessionId,
status: AttendanceStatus.EXCUSED,
markedAt: now,
},
update: {
status: AttendanceStatus.EXCUSED,
markedAt: now,
},
});
if (slotReleased) {
await tx.slotRelease.upsert({
where: {
attendeeId_classSessionId: {
attendeeId,
classSessionId,
},
},
create: {
attendeeId,
classSessionId,
isClaimed: false,
},
update: {},
});
}
return ok({
classSessionId,
notified: true,
slotReleased,
});
});
}