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,325 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findUnique: mock(),
},
classSession: {
findMany: mock(),
},
attendee: {
findMany: mock(),
findFirst: mock(),
findUnique: mock(),
update: mock(),
},
attendance: {
findMany: mock(),
},
slotRelease: {
count: 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 prisma from '@/lib/prisma';
import { analyticsRoutes, studentStatusRoutes } 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).toISOString();
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/groups', analyticsRoutes);
app.route('/students', studentStatusRoutes);
return app;
}
function makeGroup(overrides: Record<string, unknown> = {}) {
return {
id: 'group-1',
name: 'Funcional',
createdById: userId,
members: [],
...overrides,
};
}
describe('analytics routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /groups/:groupId/analytics', () => {
it('calcula métricas de asistencia de los últimos 30 días', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.classSession.findMany.mockResolvedValue([{ id: 's-1' }, { id: 's-2' }]);
prisma.attendance.findMany.mockResolvedValue([
{ status: 'PRESENT' },
{ status: 'PRESENT' },
{ status: 'ABSENT' },
{ status: 'EXCUSED' },
]);
prisma.slotRelease.count.mockResolvedValue(2);
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
attendanceRate: 50,
totalPresent: 2,
totalClasses: 2,
recoveredSlots: 2,
});
});
it('devuelve 0 en todas las métricas sin clases en el período', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.classSession.findMany.mockResolvedValue([]);
prisma.attendance.findMany.mockResolvedValue([]);
prisma.slotRelease.count.mockResolvedValue(0);
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
attendanceRate: 0,
totalPresent: 0,
totalClasses: 0,
recoveredSlots: 0,
});
});
it('prohíbe el acceso a quien no es owner ni miembro', async () => {
prisma.group.findUnique.mockResolvedValue(
makeGroup({ createdById: 'someone-else' }),
);
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'group_access_denied' });
});
it('devuelve 404 si el grupo no existe', async () => {
prisma.group.findUnique.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
});
it('rechaza requests sin sesión', async () => {
const res = await makeApp(null).request('/groups/group-1/analytics');
expect(res.status).toBe(401);
});
});
describe('GET /groups/:groupId/students-at-risk', () => {
const sessions = [at(0), at(7), at(14), at(21)];
it('clasifica HIGH y MEDIUM según el historial de asistencia', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.classSession.findMany.mockResolvedValue(sessions.map((startsAt) => ({ id: 's', startsAt })));
prisma.attendee.findMany.mockResolvedValue([
{ id: 'a-1', fullName: 'Ana Pérez', phone: '+521111111111' },
{ id: 'a-2', fullName: 'Bruno Díaz', phone: null },
]);
prisma.attendance.findMany.mockResolvedValue([
{ attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(0)) } },
{ attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(7)) } },
{ attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(14)) } },
{ attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(21)) } },
{ attendeeId: 'a-2', status: 'ABSENT', classSession: { startsAt: new Date(at(0)) } },
{ attendeeId: 'a-2', status: 'ABSENT', classSession: { startsAt: new Date(at(7)) } },
{ attendeeId: 'a-2', status: 'PRESENT', classSession: { startsAt: new Date(at(14)) } },
{ attendeeId: 'a-2', status: 'PRESENT', classSession: { startsAt: new Date(at(21)) } },
]);
const res = await makeApp({ id: userId }).request('/groups/group-1/students-at-risk');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
groupName: 'Funcional',
data: [
{
attendeeId: 'a-1',
fullName: 'Ana Pérez',
phone: '+521111111111',
riskLevel: 'HIGH',
consecutiveAbsences: 3,
lastAttendedAt: at(21),
monthlyAttendanceRate: 25,
},
{
attendeeId: 'a-2',
fullName: 'Bruno Díaz',
phone: null,
riskLevel: 'MEDIUM',
consecutiveAbsences: 2,
lastAttendedAt: at(14),
monthlyAttendanceRate: 50,
},
],
});
});
it('devuelve lista vacía cuando nadie está en riesgo', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.classSession.findMany.mockResolvedValue(sessions.map((startsAt) => ({ id: 's', startsAt })));
prisma.attendee.findMany.mockResolvedValue([
{ id: 'a-1', fullName: 'Ana Pérez', phone: null },
]);
prisma.attendance.findMany.mockResolvedValue([
{ attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(0)) } },
{ attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(7)) } },
]);
const res = await makeApp({ id: userId }).request('/groups/group-1/students-at-risk');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ groupName: 'Funcional', data: [] });
});
});
describe('GET /groups/:groupId/attendees/:attendeeId/history', () => {
it('devuelve el historial de presentismo con su porcentaje', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.attendee.findFirst.mockResolvedValue({ id: 'a-1', fullName: 'Ana Pérez' });
prisma.classSession.findMany.mockResolvedValue([
{ id: 's-2', startsAt: new Date(at(7)) },
{ id: 's-1', startsAt: new Date(at(0)) },
]);
prisma.attendance.findMany.mockResolvedValue([
{ classSessionId: 's-1', status: 'PRESENT' },
{ classSessionId: 's-2', status: 'ABSENT' },
]);
const res = await makeApp({ id: userId }).request(
'/groups/group-1/attendees/a-1/history',
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
attendeeId: 'a-1',
fullName: 'Ana Pérez',
groupId: 'group-1',
groupName: 'Funcional',
attendanceRate: 50,
sessions: [
{ classSessionId: 's-1', startsAt: at(0), status: 'PRESENT' },
{ classSessionId: 's-2', startsAt: at(7), status: 'ABSENT' },
],
});
});
it('devuelve 404 si el alumno no pertenece al grupo', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.attendee.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request(
'/groups/group-1/attendees/nope/history',
);
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
});
});
describe('POST /students/:studentId/status', () => {
function makeAttendee(status: string, overrides: Record<string, unknown> = {}) {
return {
id: 'a-1',
status,
group: { createdById: userId, members: [] },
...overrides,
};
}
it('cambia el estado del alumno', async () => {
prisma.attendee.findUnique.mockResolvedValue(makeAttendee('ACTIVE'));
prisma.attendee.update.mockResolvedValue({ id: 'a-1', status: 'DROPPED' });
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'DROPPED' }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ attendeeId: 'a-1', status: 'DROPPED' });
expect(prisma.attendee.update).toHaveBeenCalledWith({
where: { id: 'a-1' },
data: { status: 'DROPPED' },
select: { id: true, status: true },
});
});
it('es idempotente si el estado no cambia', async () => {
prisma.attendee.findUnique.mockResolvedValue(makeAttendee('PAUSED'));
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'PAUSED' }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ attendeeId: 'a-1', status: 'PAUSED' });
expect(prisma.attendee.update).not.toHaveBeenCalled();
});
it('prohíbe el acceso a quien no es owner ni miembro', async () => {
prisma.attendee.findUnique.mockResolvedValue(
makeAttendee('ACTIVE', { group: { createdById: 'someone-else', members: [] } }),
);
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'PAUSED' }),
});
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'group_access_denied' });
});
it('rechaza estados fuera del contrato', async () => {
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'HOLD' }),
});
expect(res.status).toBe(400);
expect(prisma.attendee.update).not.toHaveBeenCalled();
});
it('rechaza requests sin sesión', async () => {
const res = await makeApp(null).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'ACTIVE' }),
});
expect(res.status).toBe(401);
});
});
});