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,92 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
AttendeePaymentStatus,
ClassToday,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err } from '@gruperly/shared';
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
export type ClassesDb = Pick<
PrismaClient,
'group' | 'classSession' | 'attendee' | 'payment' | 'attendance' | 'slotRelease'
>;
type PaymentLike = {
status: 'PENDING' | 'PAID' | 'OVERDUE' | 'CANCELLED';
dueDate: Date;
};
// Estado de cobro calculado en tiempo real (sin columna denormalizada):
// PENDIENTE si hay un pago vencido (OVERDUE) o un PENDING cuyo vencimiento ya pasó.
export function resolvePaymentStatus(
payments: PaymentLike[],
startOfToday: Date,
): AttendeePaymentStatus {
const hasUnpaid = payments.some(
(payment) =>
payment.status === 'OVERDUE'
|| (payment.status === 'PENDING' && payment.dueDate.getTime() < startOfToday.getTime()),
);
return hasUnpaid ? 'PENDING' : 'UP_TO_DATE';
}
export function absenceReleaseHours(): number {
const raw = process.env.ABSENCE_RELEASE_HOURS;
if (raw == null || raw.trim() === '') {
return 12;
}
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 12;
}
export type ClassSessionRecord = {
id: string;
groupId: string;
startsAt: Date;
};
type GroupWithAccess = {
createdById: string;
members: { userId: string }[];
};
// Verifica que el usuario sea owner o miembro del grupo dueño de la sesión.
export function ensureSessionAccess(
session: ClassSessionRecord & { group: GroupWithAccess },
userId: string,
): Result<ClassSessionRecord, ProblemDetails> {
const isOwner = session.group.createdById === userId;
const isMember = session.group.members.length > 0;
if (!isOwner && !isMember) {
return err(noGroupAccessProblem());
}
return {
ok: true,
value: { id: session.id, groupId: session.groupId, startsAt: session.startsAt },
};
}
export function sessionNotFound(sessionId: string): ProblemDetails {
return notFoundResourceProblem('Class session', sessionId);
}
export function toClassToday(
session: { id: string; startsAt: Date },
group: { id: string; name: string; capacity: number | null },
enrolledCount: number,
hasAttendance: boolean,
): ClassToday {
return {
sessionId: session.id,
groupId: group.id,
groupName: group.name,
startsAt: session.startsAt.toISOString(),
enrolledCount,
capacity: group.capacity,
availableSlots:
group.capacity == null ? null : Math.max(0, group.capacity - enrolledCount),
hasAttendance,
};
}