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,21 @@
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { GetAttendeeHistory } from './use-case';
const route = new Hono();
route.get('/:groupId/attendees/:attendeeId/history', async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const groupId = c.req.param('groupId');
const attendeeId = c.req.param('attendeeId');
const useCase = new GetAttendeeHistory();
const result = await useCase.execute(groupId, attendeeId, user.id);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,83 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
AttendanceStatus,
AttendeeHistory,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { notFoundResourceProblem } from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { type AnalyticsAccessDb, findGroupForUser } from '../../lib/access';
export const HISTORY_SESSIONS_LIMIT = 20;
type GetAttendeeHistoryDeps = {
db?: AnalyticsAccessDb & Pick<PrismaClient, 'classSession' | 'attendee' | 'attendance'>;
};
export class GetAttendeeHistory {
constructor(private readonly deps: GetAttendeeHistoryDeps = {}) {}
async execute(
groupId: string,
attendeeId: string,
userId: string,
): Promise<Result<AttendeeHistory, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const access = await findGroupForUser(db, groupId, userId);
if (!access.ok) {
return access;
}
const attendee = await db.attendee.findFirst({
where: { id: attendeeId, groupId },
select: { id: true, fullName: true },
});
if (!attendee) {
return err(notFoundResourceProblem('Attendee', attendeeId));
}
const sessions = await db.classSession.findMany({
where: { groupId },
orderBy: { startsAt: 'desc' },
take: HISTORY_SESSIONS_LIMIT,
select: { id: true, startsAt: true },
});
const sessionIds = sessions.map((session) => session.id);
const attendances = sessionIds.length
? await db.attendance.findMany({
where: { attendeeId, classSessionId: { in: sessionIds } },
select: { classSessionId: true, status: true },
})
: [];
const statusBySession = new Map<string, AttendanceStatus>(
attendances.map((record) => [record.classSessionId, record.status]),
);
const orderedSessions = sessions
.slice()
.reverse()
.map((session) => ({
classSessionId: session.id,
startsAt: session.startsAt.toISOString(),
status: statusBySession.get(session.id) ?? null,
}));
const presentCount = attendances.filter((record) => record.status === 'PRESENT').length;
const attendanceRate =
sessions.length > 0 ? Math.round((presentCount / sessions.length) * 100) : 0;
return ok({
attendeeId: attendee.id,
fullName: attendee.fullName,
groupId,
groupName: access.value.name,
attendanceRate,
sessions: orderedSessions,
});
}
}

View File

@@ -0,0 +1,20 @@
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { GetGroupAnalytics } from './use-case';
const route = new Hono();
route.get('/:groupId/analytics', async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const groupId = c.req.param('groupId');
const useCase = new GetGroupAnalytics();
const result = await useCase.execute(groupId, user.id);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,55 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { GroupAnalytics, ProblemDetails, Result } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
import { type AnalyticsAccessDb, findGroupForUser } from '../../lib/access';
export const ANALYTICS_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
type GetGroupAnalyticsDeps = {
db?: AnalyticsAccessDb & Pick<PrismaClient, 'classSession' | 'attendance' | 'slotRelease'>;
now?: Date;
};
export class GetGroupAnalytics {
constructor(private readonly deps: GetGroupAnalyticsDeps = {}) {}
async execute(
groupId: string,
userId: string,
): Promise<Result<GroupAnalytics, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS);
const access = await findGroupForUser(db, groupId, userId);
if (!access.ok) {
return access;
}
const [sessions, attendances, recoveredSlots] = await Promise.all([
db.classSession.findMany({
where: { groupId, startsAt: { gte: since } },
select: { id: true },
}),
db.attendance.findMany({
where: { classSession: { groupId, startsAt: { gte: since } } },
select: { status: true },
}),
db.slotRelease.count({
where: { classSession: { groupId, startsAt: { gte: since } } },
}),
]);
const totalClasses = sessions.length;
const totalMarked = attendances.length;
const totalPresent = attendances.filter((record) => record.status === 'PRESENT').length;
return ok({
attendanceRate: totalMarked > 0 ? Math.round((totalPresent / totalMarked) * 100) : 0,
totalPresent,
totalClasses,
recoveredSlots,
});
}
}

View File

@@ -0,0 +1,20 @@
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { GetStudentsAtRisk } from './use-case';
const route = new Hono();
route.get('/:groupId/students-at-risk', async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const groupId = c.req.param('groupId');
const useCase = new GetStudentsAtRisk();
const result = await useCase.execute(groupId, user.id);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,122 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
AttendanceStatus,
ProblemDetails,
Result,
StudentAtRisk,
StudentsAtRisk,
} from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
import { type AnalyticsAccessDb, findGroupForUser } from '../../lib/access';
import {
classifyRisk,
lastAttendedAt,
monthlyAttendanceRate,
trailingAbsentStreak,
} from '../../lib/risk';
import { ANALYTICS_WINDOW_MS } from '../get-overview/use-case';
type GetStudentsAtRiskDeps = {
db?: AnalyticsAccessDb & Pick<PrismaClient, 'classSession' | 'attendee' | 'attendance'>;
now?: Date;
};
type AttendanceStreamRow = {
attendeeId: string;
status: AttendanceStatus;
classSession: { startsAt: Date };
};
export class GetStudentsAtRisk {
constructor(private readonly deps: GetStudentsAtRiskDeps = {}) {}
async execute(
groupId: string,
userId: string,
): Promise<Result<StudentsAtRisk, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS);
const access = await findGroupForUser(db, groupId, userId);
if (!access.ok) {
return access;
}
const [windowSessions, attendees, attendances] = await Promise.all([
db.classSession.findMany({
where: { groupId, startsAt: { gte: since } },
select: { id: true },
}),
db.attendee.findMany({
where: { groupId, status: 'ACTIVE' },
select: { id: true, fullName: true, phone: true },
}),
db.attendance.findMany({
where: { attendee: { groupId } },
select: {
attendeeId: true,
status: true,
classSession: { select: { startsAt: true } },
},
orderBy: { classSession: { startsAt: 'desc' } },
}),
]);
const totalWindowSessions = windowSessions.length;
const streamByAttendee = groupAttendanceByAttendee(attendances);
const atRisk: StudentAtRisk[] = [];
for (const attendee of attendees) {
const stream = (streamByAttendee.get(attendee.id) ?? []).map((row) => ({
status: row.status,
occurredAt: row.classSession.startsAt,
}));
const consecutiveAbsences = trailingAbsentStreak(stream);
const attendedAt = lastAttendedAt(stream);
const monthlyRate = monthlyAttendanceRate(
stream.filter((entry) => entry.occurredAt.getTime() >= since.getTime()),
totalWindowSessions,
);
const riskLevel = classifyRisk(consecutiveAbsences, monthlyRate);
if (!riskLevel) {
continue;
}
atRisk.push({
attendeeId: attendee.id,
fullName: attendee.fullName,
phone: attendee.phone,
riskLevel,
consecutiveAbsences,
lastAttendedAt: attendedAt ? attendedAt.toISOString() : null,
monthlyAttendanceRate: monthlyRate,
});
}
atRisk.sort((a, b) => {
if (a.riskLevel !== b.riskLevel) {
return a.riskLevel === 'HIGH' ? -1 : 1;
}
if (a.consecutiveAbsences !== b.consecutiveAbsences) {
return b.consecutiveAbsences - a.consecutiveAbsences;
}
return a.monthlyAttendanceRate - b.monthlyAttendanceRate;
});
return ok({
groupName: access.value.name,
data: atRisk,
});
}
}
function groupAttendanceByAttendee(rows: AttendanceStreamRow[]): Map<string, AttendanceStreamRow[]> {
const byAttendee = new Map<string, AttendanceStreamRow[]>();
for (const row of rows) {
const list = byAttendee.get(row.attendeeId) ?? [];
list.push(row);
byAttendee.set(row.attendeeId, list);
}
return byAttendee;
}

View File

@@ -0,0 +1,24 @@
import type { UpdateStudentStatus } from '@gruperly/shared';
import { UpdateStudentStatusSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { UpdateAttendeeStatus } from './use-case';
const route = new Hono();
route.post('/:studentId/status', validate.json(UpdateStudentStatusSchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const studentId = c.req.param('studentId');
const payload = c.req.valid('json') as UpdateStudentStatus;
const useCase = new UpdateAttendeeStatus();
const result = await useCase.execute(studentId, user.id, payload);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,53 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
ProblemDetails,
Result,
UpdateStudentStatus,
UpdateStudentStatusResult,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
import prisma from '@/lib/prisma';
type UpdateAttendeeStatusDeps = {
db?: Pick<PrismaClient, 'attendee'>;
};
export class UpdateAttendeeStatus {
constructor(private readonly deps: UpdateAttendeeStatusDeps = {}) {}
async execute(
studentId: string,
userId: string,
payload: UpdateStudentStatus,
): Promise<Result<UpdateStudentStatusResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const attendee = await db.attendee.findUnique({
where: { id: studentId },
include: { group: { include: { members: { where: { userId } } } } },
});
if (!attendee) {
return err(notFoundResourceProblem('Attendee', studentId));
}
const isOwner = attendee.group.createdById === userId;
const isMember = attendee.group.members.length > 0;
if (!isOwner && !isMember) {
return err(noGroupAccessProblem());
}
if (attendee.status === payload.status) {
return ok({ attendeeId: studentId, status: payload.status });
}
const updated = await db.attendee.update({
where: { id: studentId },
data: { status: payload.status },
select: { id: true, status: true },
});
return ok({ attendeeId: updated.id, status: updated.status });
}
}

View File

@@ -0,0 +1,2 @@
export { default as studentStatusRoutes } from './features/update-attendee-status/route';
export { default as analyticsRoutes } from './routes';

View File

@@ -0,0 +1,37 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
export type AnalyticsAccessDb = Pick<PrismaClient, 'group'>;
type GroupWithAccess = {
id: string;
name: string;
createdById: string;
members: { userId: string }[];
};
// Verifica que el usuario sea owner o miembro del grupo. Devuelve el grupo si tiene acceso.
export async function findGroupForUser(
db: AnalyticsAccessDb,
groupId: string,
userId: string,
): Promise<Result<GroupWithAccess, ProblemDetails>> {
const group = await db.group.findUnique({
where: { id: groupId },
include: { members: { where: { userId } } },
});
if (!group) {
return err(notFoundResourceProblem('Group', groupId));
}
const isOwner = group.createdById === userId;
const isMember = group.members.length > 0;
if (!isOwner && !isMember) {
return err(noGroupAccessProblem());
}
return ok(group);
}

View File

@@ -0,0 +1,56 @@
export type AttendanceStreamEntry = {
status: 'PRESENT' | 'ABSENT' | 'EXCUSED';
occurredAt: Date;
};
export type RiskLevel = 'HIGH' | 'MEDIUM';
// Cuenta las ausencias consecutivas sin justificar desde la clase más reciente.
// Recibe la lista ordenada de más reciente a más antigua; PRESENT y EXCUSED rompen la cadena.
export function trailingAbsentStreak(entries: AttendanceStreamEntry[]): number {
let streak = 0;
for (const entry of entries) {
if (entry.status !== 'ABSENT') {
break;
}
streak += 1;
}
return streak;
}
// Fecha de la última clase a la que el alumno asistió (PRESENT), o null.
export function lastAttendedAt(entries: AttendanceStreamEntry[]): Date | null {
for (const entry of entries) {
if (entry.status === 'PRESENT') {
return entry.occurredAt;
}
}
return null;
}
// Porcentaje de asistencia sobre el total de sesiones del período.
// Las sesiones sin registrar cuentan como no asistidas.
export function monthlyAttendanceRate(
entries: AttendanceStreamEntry[],
totalSessions: number,
): number {
if (totalSessions <= 0) {
return 0;
}
const present = entries.filter((entry) => entry.status === 'PRESENT').length;
return Math.round((present / totalSessions) * 100);
}
// HIGH >= 3 ausencias consecutivas; MEDIUM == 2 consecutivas o asistencia mensual < 50%.
export function classifyRisk(
consecutiveAbsences: number,
monthlyRate: number,
): RiskLevel | null {
if (consecutiveAbsences >= 3) {
return 'HIGH';
}
if (consecutiveAbsences === 2 || monthlyRate < 50) {
return 'MEDIUM';
}
return null;
}

View File

@@ -0,0 +1,12 @@
import { Hono } from 'hono';
import getAttendeeHistoryRoute from './features/get-attendee-history/route';
import getOverviewRoute from './features/get-overview/route';
import getStudentsAtRiskRoute from './features/get-students-at-risk/route';
const routes = new Hono();
routes.route('/', getOverviewRoute);
routes.route('/', getStudentsAtRiskRoute);
routes.route('/', getAttendeeHistoryRoute);
export default routes;