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,68 @@
-- CreateEnum
CREATE TYPE "AttendanceStatus" AS ENUM ('PRESENT', 'ABSENT', 'EXCUSED');
-- AlterTable
ALTER TABLE "attendees" ADD COLUMN "notifyToken" TEXT;
-- CreateTable
CREATE TABLE "class_sessions" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"startsAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "class_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "attendances" (
"id" TEXT NOT NULL,
"attendeeId" TEXT NOT NULL,
"classSessionId" TEXT NOT NULL,
"status" "AttendanceStatus" NOT NULL,
"markedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "attendances_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "slot_releases" (
"id" TEXT NOT NULL,
"attendeeId" TEXT NOT NULL,
"classSessionId" TEXT NOT NULL,
"isClaimed" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "slot_releases_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "class_sessions_groupId_startsAt_key" ON "class_sessions"("groupId", "startsAt");
-- CreateIndex
CREATE UNIQUE INDEX "attendances_attendeeId_classSessionId_key" ON "attendances"("attendeeId", "classSessionId");
-- CreateIndex
CREATE UNIQUE INDEX "slot_releases_attendeeId_classSessionId_key" ON "slot_releases"("attendeeId", "classSessionId");
-- CreateIndex
CREATE UNIQUE INDEX "attendees_notifyToken_key" ON "attendees"("notifyToken");
-- AddForeignKey
ALTER TABLE "class_sessions" ADD CONSTRAINT "class_sessions_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "attendees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_classSessionId_fkey" FOREIGN KEY ("classSessionId") REFERENCES "class_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "slot_releases" ADD CONSTRAINT "slot_releases_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "attendees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "slot_releases" ADD CONSTRAINT "slot_releases_classSessionId_fkey" FOREIGN KEY ("classSessionId") REFERENCES "class_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Backfill: tokens de aviso de ausencia para alumnos existentes
UPDATE "attendees" SET "notifyToken" = gen_random_uuid()::text WHERE "notifyToken" IS NULL;

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "AttendeeStatus" AS ENUM ('ACTIVE', 'PAUSED', 'DROPPED');
-- AlterTable
ALTER TABLE "attendees" ADD COLUMN "status" "AttendeeStatus" NOT NULL DEFAULT 'ACTIVE';

View File

@@ -0,0 +1,50 @@
// Attendance & class sessions
enum AttendanceStatus {
PRESENT
ABSENT
EXCUSED
}
model ClassSession {
id String @id @default(cuid())
groupId String
startsAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
attendances Attendance[]
slotReleases SlotRelease[]
@@unique([groupId, startsAt])
@@map("class_sessions")
}
model Attendance {
id String @id @default(cuid())
attendeeId String
classSessionId String
status AttendanceStatus
markedAt DateTime @default(now())
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
classSession ClassSession @relation(fields: [classSessionId], references: [id], onDelete: Cascade)
@@unique([attendeeId, classSessionId])
@@map("attendances")
}
model SlotRelease {
id String @id @default(cuid())
attendeeId String
classSessionId String
isClaimed Boolean @default(false)
createdAt DateTime @default(now())
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
classSession ClassSession @relation(fields: [classSessionId], references: [id], onDelete: Cascade)
@@unique([attendeeId, classSessionId])
@@map("slot_releases")
}

View File

@@ -20,6 +20,7 @@ model Group {
attendees Attendee[]
payments Payment[]
waitlist GroupWaitlistEntry[]
classSessions ClassSession[]
@@map("groups")
}
@@ -59,8 +60,14 @@ enum Role {
MEMBER
}
enum AttendeeStatus {
ACTIVE
PAUSED
DROPPED
}
model Attendee {
id String @id @default(cuid())
id String @id @default(cuid())
groupId String
fullName String
email String?
@@ -68,11 +75,15 @@ model Attendee {
guardianName String?
guardianPhone String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notifyToken String? @unique @default(cuid())
status AttendeeStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
payments Payment[]
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
payments Payment[]
attendances Attendance[]
slotReleases SlotRelease[]
@@index([groupId])
@@map("attendees")

View File

@@ -10,8 +10,10 @@ import { requestLoggerMiddleware } from '@/http/request-logger';
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
import { sessionAuthMiddleware } from '@/http/session-auth';
import { logger } from '@/logger';
import { studentStatusRoutes } from './modules/analytics';
import { attendeesRoutes } from './modules/attendees';
import { authRoutes } from './modules/auth';
import { classesRoutes } from './modules/classes';
import { groupsRoutes } from './modules/groups';
import { healthCheckRoutes } from './modules/health-check';
import { homeRoutes } from './modules/home';
@@ -42,6 +44,8 @@ api.route('/onboarding', onboardingRoutes);
api.route('/attendees', attendeesRoutes);
api.route('/payments', paymentsRoutes);
api.route('/waitlist', waitlistRoutes);
api.route('/classes', classesRoutes);
api.route('/students', studentStatusRoutes);
app.notFound((c) => {
return problemJson(c, notFoundProblem(c.req.path));

View File

@@ -27,6 +27,14 @@ export function isPublicApiRequest(method: string, path: string): boolean {
const normalizedPath = normalizePath(path);
// Aviso de ausencia con token personal del alumno (sin sesión).
if (method === 'GET' && normalizedPath === '/api/v1/classes/absence') {
return true;
}
if (method === 'POST' && normalizedPath === '/api/v1/classes/notify-absence') {
return true;
}
return (
normalizedPath === '/api/v1/health'
|| matchesPublicPrefix(normalizedPath, '/api/v1/auth')
@@ -40,5 +48,8 @@ function matchesPublicPrefix(path: string, prefix: string): boolean {
}
function normalizePath(path: string): string {
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
const withoutQuery = path.split('?')[0]?.split('#')[0] ?? path;
return withoutQuery.length > 1 && withoutQuery.endsWith('/')
? withoutQuery.slice(0, -1)
: withoutQuery;
}

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;

View File

@@ -70,7 +70,9 @@ export class CreateAttendeeUseCase {
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
if (group.capacity !== null) {
const currentCount = await db.attendee.count({ where: { groupId } });
const currentCount = await db.attendee.count({
where: { groupId, status: 'ACTIVE' },
});
if (currentCount >= group.capacity) {
if (isOwner && !options.allowOverflow) {

View File

@@ -9,6 +9,7 @@ export type AttendeeRecord = {
guardianName: string | null;
guardianPhone: string | null;
notes: string | null;
notifyToken?: string | null;
createdAt: Date;
updatedAt: Date;
};
@@ -23,6 +24,7 @@ export function toAttendeeDto(record: AttendeeRecord): AttendeeDto {
guardianName: record.guardianName,
guardianPhone: record.guardianPhone,
notes: record.notes,
notifyToken: record.notifyToken ?? null,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};

View File

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

View File

@@ -0,0 +1,105 @@
import { AttendanceStatus } from '@generated/prisma/client';
import type {
MarkAttendance,
MarkAttendanceResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { validationProblem } from '@/http/problem-builders';
import prisma, { UnitOfWork } from '@/lib/prisma';
import { type ClassesDb, ensureSessionAccess, sessionNotFound } from '../../lib';
type MarkSessionAttendanceDeps = {
db?: Pick<ClassesDb, 'classSession' | 'attendee'>;
unitOfWork?: UnitOfWork;
};
export class MarkSessionAttendance {
constructor(private readonly deps: MarkSessionAttendanceDeps = {}) {}
async execute(
sessionId: string,
payload: MarkAttendance,
userId: string,
): Promise<Result<MarkAttendanceResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
if (payload.classSessionId !== sessionId) {
return err(
validationProblem({
detail: 'El identificador de la clase no coincide con la ruta.',
}),
);
}
const session = await db.classSession.findUnique({
where: { id: sessionId },
include: {
group: { include: { members: { where: { userId } } } },
},
});
if (!session) {
return err(sessionNotFound(sessionId));
}
const access = ensureSessionAccess(session, userId);
if (!access.ok) {
return access;
}
const uniqueRecords = [
...new Map(payload.records.map((record) => [record.attendeeId, record])).values(),
];
const attendees = await db.attendee.findMany({
where: {
groupId: session.groupId,
id: { in: uniqueRecords.map((record) => record.attendeeId) },
},
select: { id: true },
});
const attendeeIds = new Set(attendees.map((attendee) => attendee.id));
const invalidRecord = uniqueRecords.find((record) => !attendeeIds.has(record.attendeeId));
if (invalidRecord) {
return err(
validationProblem({
detail: 'Uno o más alumnos no pertenecen al grupo de esta clase.',
errors: { attendeeId: [invalidRecord.attendeeId] },
}),
);
}
const markedAt = new Date();
return unitOfWork.executeResult(async (tx) => {
for (const record of uniqueRecords) {
await tx.attendance.upsert({
where: {
attendeeId_classSessionId: {
attendeeId: record.attendeeId,
classSessionId: sessionId,
},
},
create: {
attendeeId: record.attendeeId,
classSessionId: sessionId,
status:
record.status === 'PRESENT'
? AttendanceStatus.PRESENT
: AttendanceStatus.ABSENT,
markedAt,
},
update: {
status:
record.status === 'PRESENT'
? AttendanceStatus.PRESENT
: AttendanceStatus.ABSENT,
markedAt,
},
});
}
return ok({ classSessionId: sessionId, marked: uniqueRecords.length });
});
}
}

View File

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

View File

@@ -0,0 +1,71 @@
import type {
NotifyAbsence,
NotifyAbsenceResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err } from '@gruperly/shared';
import { validationProblem } from '@/http/problem-builders';
import prisma, { UnitOfWork } from '@/lib/prisma';
import {
absenceReleaseHours,
type ClassesDb,
ensureSessionAccess,
registerAbsence,
sessionNotFound,
} from '../../lib';
type NotifySessionAbsenceDeps = {
db?: Pick<ClassesDb, 'classSession' | 'attendee'>;
unitOfWork?: UnitOfWork;
now?: Date;
releaseHours?: number;
};
export class NotifySessionAbsence {
constructor(private readonly deps: NotifySessionAbsenceDeps = {}) {}
async execute(
sessionId: string,
payload: NotifyAbsence,
userId: string,
): Promise<Result<NotifyAbsenceResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const now = this.deps.now ?? new Date();
const releaseHours = this.deps.releaseHours ?? absenceReleaseHours();
if (payload.classSessionId !== sessionId) {
return err(
validationProblem({
detail: 'El identificador de la clase no coincide con la ruta.',
}),
);
}
const session = await db.classSession.findUnique({
where: { id: sessionId },
include: {
group: { include: { members: { where: { userId } } } },
},
});
if (!session) {
return err(sessionNotFound(sessionId));
}
const access = ensureSessionAccess(session, userId);
if (!access.ok) {
return access;
}
return registerAbsence(
{ db, unitOfWork },
{
attendeeId: payload.attendeeId,
classSessionId: sessionId,
now,
releaseHours,
},
);
}
}

View File

@@ -0,0 +1,19 @@
import type { AbsenceQuery } from '@gruperly/shared';
import { AbsenceQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { resultJson } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { GetAbsenceDetails } from './use-case';
const route = new Hono();
// Ruta pública (whitelistada en session-auth): el alumno entra con su token personal.
route.get('/absence', validate.query(AbsenceQuerySchema), async (c) => {
const query = c.req.valid('query') as AbsenceQuery;
const useCase = new GetAbsenceDetails();
const result = await useCase.execute(query);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,93 @@
import type { AbsenceDetails, AbsenceQuery, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { notFoundResourceProblem } from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { DEFAULT_TIME_ZONE } from '@/modules/home/lib/scheduler';
import {
buildTodayCandidates,
type ClassesDb,
ensureTodaySessions,
} from '../../lib';
type GetAbsenceDetailsDeps = {
db?: Pick<
ClassesDb,
'attendee' | 'group' | 'classSession' | 'attendance' | 'slotRelease'
>;
now?: Date;
timeZone?: string;
};
export class GetAbsenceDetails {
constructor(private readonly deps: GetAbsenceDetailsDeps = {}) {}
async execute(
query: AbsenceQuery,
): Promise<Result<AbsenceDetails, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const timeZone = this.deps.timeZone ?? DEFAULT_TIME_ZONE;
const attendee = await db.attendee.findUnique({
where: { notifyToken: query.token },
include: {
group: { select: { id: true, name: true, days: true, time: true } },
},
});
if (!attendee) {
return err(notFoundResourceProblem('Attendee', 'notify token'));
}
const candidates = buildTodayCandidates(
[{ id: attendee.group.id, days: attendee.group.days, time: attendee.group.time }],
now,
timeZone,
);
if (candidates.length === 0) {
return ok({
attendeeId: attendee.id,
fullName: attendee.fullName,
groupName: attendee.group.name,
sessions: [],
});
}
const sessionIds = await ensureTodaySessions(db, candidates);
const [sessions, attendances, releases] = await Promise.all([
db.classSession.findMany({
where: { id: { in: sessionIds } },
orderBy: { startsAt: 'asc' },
}),
db.attendance.findMany({
where: { classSessionId: { in: sessionIds }, attendeeId: attendee.id },
select: { classSessionId: true, status: true },
}),
db.slotRelease.findMany({
where: { classSessionId: { in: sessionIds }, attendeeId: attendee.id },
select: { classSessionId: true },
}),
]);
const attendanceBySession = new Map(
attendances.map((attendance) => [attendance.classSessionId, attendance.status]),
);
const releasedSessionIds = new Set(releases.map((release) => release.classSessionId));
return ok({
attendeeId: attendee.id,
fullName: attendee.fullName,
groupName: attendee.group.name,
sessions: sessions.map((session) => {
const attendanceStatus = attendanceBySession.get(session.id) ?? null;
return {
sessionId: session.id,
groupName: attendee.group.name,
startsAt: session.startsAt.toISOString(),
notified:
attendanceStatus === 'EXCUSED' || releasedSessionIds.has(session.id),
attendanceStatus,
};
}),
});
}
}

View File

@@ -0,0 +1,19 @@
import type { PublicNotifyAbsence } from '@gruperly/shared';
import { PublicNotifyAbsenceSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { resultJson } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { PublicNotifyAbsenceUseCase } from './use-case';
const route = new Hono();
// Ruta pública (whitelistada en session-auth): el alumno avisa su ausencia con token.
route.post('/notify-absence', validate.json(PublicNotifyAbsenceSchema), async (c) => {
const payload = c.req.valid('json') as PublicNotifyAbsence;
const useCase = new PublicNotifyAbsenceUseCase();
const result = await useCase.execute(payload);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,48 @@
import type {
NotifyAbsenceResult,
ProblemDetails,
PublicNotifyAbsence,
Result,
} from '@gruperly/shared';
import { err } from '@gruperly/shared';
import { notFoundResourceProblem } from '@/http/problem-builders';
import prisma, { UnitOfWork } from '@/lib/prisma';
import { absenceReleaseHours, type ClassesDb, registerAbsence } from '../../lib';
type PublicNotifyAbsenceDeps = {
db?: Pick<ClassesDb, 'attendee' | 'classSession'>;
unitOfWork?: UnitOfWork;
now?: Date;
releaseHours?: number;
};
export class PublicNotifyAbsenceUseCase {
constructor(private readonly deps: PublicNotifyAbsenceDeps = {}) {}
async execute(
payload: PublicNotifyAbsence,
): Promise<Result<NotifyAbsenceResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const now = this.deps.now ?? new Date();
const releaseHours = this.deps.releaseHours ?? absenceReleaseHours();
const attendee = await db.attendee.findUnique({
where: { notifyToken: payload.token },
select: { id: true },
});
if (!attendee) {
return err(notFoundResourceProblem('Attendee', 'notify token'));
}
return registerAbsence(
{ db, unitOfWork },
{
attendeeId: attendee.id,
classSessionId: payload.classSessionId,
now,
releaseHours,
},
);
}
}

View File

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

View File

@@ -0,0 +1,112 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
ProblemDetails,
Result,
SessionStudents,
TodayClassesQuery,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
import { DEFAULT_TIME_ZONE, startOfDayInTimeZone } from '@/modules/home/lib/scheduler';
import {
type ClassesDb,
ensureSessionAccess,
resolvePaymentStatus,
sessionNotFound,
} from '../../lib';
type GetSessionStudentsDeps = {
db?: Pick<
ClassesDb,
'classSession' | 'attendee' | 'payment' | 'attendance' | 'slotRelease'
> &
Pick<PrismaClient, 'group'>;
now?: Date;
timeZone?: string;
};
export class GetSessionStudents {
constructor(private readonly deps: GetSessionStudentsDeps = {}) {}
async execute(
sessionId: string,
userId: string,
query: TodayClassesQuery = {},
): Promise<Result<SessionStudents, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const timeZone = this.deps.timeZone ?? query.timeZone ?? DEFAULT_TIME_ZONE;
const session = await db.classSession.findUnique({
where: { id: sessionId },
include: {
group: {
include: { members: { where: { userId } } },
},
},
});
if (!session) {
return err(sessionNotFound(sessionId));
}
const access = ensureSessionAccess(session, userId);
if (!access.ok) {
return access;
}
const startOfToday = startOfDayInTimeZone(now, timeZone);
const [attendees, payments, attendances, releases] = await Promise.all([
db.attendee.findMany({
where: { groupId: session.groupId },
orderBy: { fullName: 'asc' },
}),
db.payment.findMany({
where: { groupId: session.groupId },
select: { attendeeId: true, status: true, dueDate: true },
}),
db.attendance.findMany({
where: { classSessionId: sessionId },
select: { attendeeId: true, status: true },
}),
db.slotRelease.findMany({
where: { classSessionId: sessionId },
select: { attendeeId: true },
}),
]);
const paymentsByAttendee = new Map<string, typeof payments>();
for (const payment of payments) {
const list = paymentsByAttendee.get(payment.attendeeId) ?? [];
list.push(payment);
paymentsByAttendee.set(payment.attendeeId, list);
}
const attendanceByAttendee = new Map(
attendances.map((attendance) => [attendance.attendeeId, attendance.status]),
);
const releasedAttendeeIds = new Set(releases.map((release) => release.attendeeId));
return ok({
sessionId: session.id,
groupId: session.groupId,
groupName: session.group.name,
startsAt: session.startsAt.toISOString(),
students: attendees.map((attendee) => {
const attendanceStatus = attendanceByAttendee.get(attendee.id) ?? null;
return {
attendeeId: attendee.id,
fullName: attendee.fullName,
paymentStatus: resolvePaymentStatus(
paymentsByAttendee.get(attendee.id) ?? [],
startOfToday,
),
attendanceStatus,
notifiedAbsence:
attendanceStatus === 'EXCUSED' || releasedAttendeeIds.has(attendee.id),
};
}),
});
}
}

View File

@@ -0,0 +1,23 @@
import type { TodayClassesQuery } from '@gruperly/shared';
import { TodayClassesQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { GetTodayClasses } from './use-case';
const route = new Hono();
route.get('/today', validate.query(TodayClassesQuerySchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const query = c.req.valid('query') as TodayClassesQuery;
const useCase = new GetTodayClasses();
const result = await useCase.execute(user.id, query);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,82 @@
import type { ClassTodayList, ProblemDetails, Result, TodayClassesQuery } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
import { buildGroupWhereForUser } from '@/modules/groups/lib/helpers';
import { DEFAULT_TIME_ZONE } from '@/modules/home/lib/scheduler';
import {
buildTodayCandidates,
type ClassesDb,
ensureTodaySessions,
toClassToday,
} from '../../lib';
type GetTodayClassesDeps = {
db?: Pick<ClassesDb, 'group' | 'classSession' | 'attendance'>;
now?: Date;
timeZone?: string;
};
export class GetTodayClasses {
constructor(private readonly deps: GetTodayClassesDeps = {}) {}
async execute(
userId: string,
query: TodayClassesQuery = {},
): Promise<Result<ClassTodayList, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const timeZone = this.deps.timeZone ?? query.timeZone ?? DEFAULT_TIME_ZONE;
const groups = await db.group.findMany({
where: buildGroupWhereForUser(userId),
select: {
id: true,
name: true,
days: true,
time: true,
capacity: true,
_count: {
select: { attendees: { where: { status: 'ACTIVE' } } },
},
},
});
const candidates = buildTodayCandidates(groups, now, timeZone);
if (candidates.length === 0) {
return ok({ data: [] });
}
const sessionIds = await ensureTodaySessions(db, candidates);
const [sessions, markedAttendances] = await Promise.all([
db.classSession.findMany({
where: { id: { in: sessionIds } },
include: {
group: { select: { id: true, name: true, capacity: true } },
},
orderBy: { startsAt: 'asc' },
}),
db.attendance.findMany({
where: {
classSessionId: { in: sessionIds },
status: { in: ['PRESENT', 'ABSENT'] },
},
select: { classSessionId: true },
}),
]);
const markedSessionIds = new Set(markedAttendances.map((a) => a.classSessionId));
const enrolledByGroup = new Map(groups.map((group) => [group.id, group._count.attendees]));
return ok({
data: sessions.map((session) =>
toClassToday(
session,
session.group,
enrolledByGroup.get(session.groupId) ?? 0,
markedSessionIds.has(session.id),
),
),
});
}
}

View File

@@ -0,0 +1 @@
export { default as classesRoutes } from './routes';

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,
};
}

View File

@@ -0,0 +1,3 @@
export * from './helpers';
export * from './notify';
export * from './sessions';

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,
});
});
}

View File

@@ -0,0 +1,64 @@
import type { WeekDay } from '@gruperly/shared';
import { todayOccurrence } from '@/modules/home/lib/scheduler';
import type { ClassesDb } from './helpers';
export type TodaySessionCandidate = {
groupId: string;
startsAt: Date;
};
type ScheduledGroup = {
id: string;
days: WeekDay[] | null;
time: string | null;
};
// Calcula qué grupos tienen clase hoy (según days/time y la zona horaria del profesor).
export function buildTodayCandidates(
groups: ScheduledGroup[],
now: Date,
timeZone: string,
): TodaySessionCandidate[] {
const candidates: TodaySessionCandidate[] = [];
for (const group of groups) {
if (!group.days || group.days.length === 0 || !group.time) {
continue;
}
const startsAt = todayOccurrence({
days: group.days,
time: group.time,
now,
timeZone,
});
if (startsAt) {
candidates.push({ groupId: group.id, startsAt });
}
}
return candidates;
}
// Materializa (upsert idempotente) las ClassSession del día y devuelve sus ids.
export async function ensureTodaySessions(
db: Pick<ClassesDb, 'classSession'>,
candidates: TodaySessionCandidate[],
): Promise<string[]> {
const sessionIds: string[] = [];
for (const candidate of candidates) {
const session = await db.classSession.upsert({
where: {
groupId_startsAt: {
groupId: candidate.groupId,
startsAt: candidate.startsAt,
},
},
create: {
groupId: candidate.groupId,
startsAt: candidate.startsAt,
},
update: {},
select: { id: true },
});
sessionIds.push(session.id);
}
return sessionIds;
}

View File

@@ -0,0 +1,18 @@
import { Hono } from 'hono';
import markAttendanceRoute from './features/mark-attendance/route';
import notifyAbsenceRoute from './features/notify-absence/route';
import publicDetailsRoute from './features/public-details/route';
import publicNotifyRoute from './features/public-notify/route';
import sessionStudentsRoute from './features/session-students/route';
import todayRoute from './features/today/route';
const routes = new Hono();
routes.route('/', todayRoute);
routes.route('/', sessionStudentsRoute);
routes.route('/', markAttendanceRoute);
routes.route('/', notifyAbsenceRoute);
routes.route('/', publicDetailsRoute);
routes.route('/', publicNotifyRoute);
export default routes;

View File

@@ -29,7 +29,9 @@ export async function promoteWaitlistEntryRecord(
entry: GroupWaitlistEntryRecord,
): Promise<Result<AttendeeDto, ProblemDetails>> {
if (group.capacity !== null) {
const currentCount = await db.attendee.count({ where: { groupId: group.id } });
const currentCount = await db.attendee.count({
where: { groupId: group.id, status: 'ACTIVE' },
});
if (currentCount >= group.capacity) {
return err(capacityReachedProblem(group.capacity));
}

View File

@@ -1,4 +1,5 @@
import { Hono } from 'hono';
import analyticsRoutes from '../analytics/routes';
import addToGroupWaitlistRoute from '../attendees/features/add-to-waitlist/route';
import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
import createAttendeeRoute from '../attendees/features/create/route';
@@ -21,6 +22,7 @@ routes.route('/', bulkCreateAttendeesRoute);
routes.route('/', getByIdRoute);
routes.route('/', addToGroupWaitlistRoute);
routes.route('/', groupWaitlistRoutes);
routes.route('/', analyticsRoutes);
routes.route('/', removeAttendeeRoute);
export default routes;

View File

@@ -22,7 +22,7 @@ export type NextClassOccurrence = {
};
// Convierte un tiempo de "pared" (wall clock) expresado en `timeZone` al instante absoluto.
function zonedWallClockToUtc(wall: string, timeZone: string): Date {
export function zonedWallClockToUtc(wall: string, timeZone: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(wall);
if (!match) {
throw new Error(`Invalid wall clock: ${wall}`);
@@ -64,7 +64,7 @@ function zonedWallClockToUtc(wall: string, timeZone: string): Date {
return new Date(asUtc.getTime() + offsetMs);
}
function wallDateInTimeZone(now: Date, timeZone: string): { year: number; month: number; day: number } {
export function wallDateInTimeZone(now: Date, timeZone: string): { year: number; month: number; day: number } {
const wall = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
@@ -143,4 +143,46 @@ export function nextClassOccurrence(opt: {
function pad(value: number): string {
return String(value).padStart(2, '0');
}
// Instante absoluto de la clase de HOY (aunque la hora ya haya pasado), o null si el grupo
// no tiene clase programada para el día de `now` en `timeZone`.
export function todayOccurrence(opt: {
days: WeekDay[];
time: string;
now: Date;
timeZone: string;
}): Date | null {
const { days, time, now, timeZone } = opt;
if (days.length === 0 || !time) {
return null;
}
const [hour, minute] = time.split(':').map(Number);
if (
Number.isNaN(hour) ||
Number.isNaN(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
const { year, month, day } = wallDateInTimeZone(now, timeZone);
const dow = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
const dayMatches = days.some((d) => WEEKDAY_TO_DOW[d] === dow);
if (!dayMatches) {
return null;
}
const wall = `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}`;
return zonedWallClockToUtc(wall, timeZone);
}
// Primer instante del día de `now` en `timeZone` (medianoche local).
export function startOfDayInTimeZone(now: Date, timeZone: string): Date {
const { year, month, day } = wallDateInTimeZone(now, timeZone);
return zonedWallClockToUtc(`${year}-${pad(month)}-${pad(day)}T00:00`, timeZone);
}

View File

@@ -55,7 +55,9 @@ export class JoinViaInvite {
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
if (group.capacity !== null) {
const currentCount = await db.attendee.count({ where: { groupId: group.id } });
const currentCount = await db.attendee.count({
where: { groupId: group.id, status: 'ACTIVE' },
});
if (currentCount >= group.capacity) {
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'bun:test';
import {
classifyRisk,
lastAttendedAt,
monthlyAttendanceRate,
trailingAbsentStreak,
} from '@/modules/analytics/lib/risk';
const day = 24 * 60 * 60 * 1000;
const at = (offsetDays: number) => new Date(Date.now() - offsetDays * day);
function entry(status: 'PRESENT' | 'ABSENT' | 'EXCUSED', offsetDays: number) {
return { status, occurredAt: at(offsetDays) };
}
describe('trailingAbsentStreak', () => {
it('devuelve 0 sin registros', () => {
expect(trailingAbsentStreak([])).toBe(0);
});
it('cuenta ausencias consecutivas sin justificar desde la más reciente', () => {
expect(trailingAbsentStreak([entry('ABSENT', 0)])).toBe(1);
expect(trailingAbsentStreak([entry('ABSENT', 0), entry('ABSENT', 7), entry('ABSENT', 14)])).toBe(3);
});
it('rompe la cadena con PRESENT o EXCUSED', () => {
expect(
trailingAbsentStreak([entry('ABSENT', 0), entry('ABSENT', 7), entry('PRESENT', 14)]),
).toBe(2);
expect(trailingAbsentStreak([entry('EXCUSED', 0), entry('ABSENT', 7)])).toBe(0);
expect(trailingAbsentStreak([entry('PRESENT', 0)])).toBe(0);
});
});
describe('lastAttendedAt', () => {
it('devuelve la clase PRESENT más reciente', () => {
const result = lastAttendedAt([
entry('PRESENT', 0),
entry('ABSENT', 7),
entry('ABSENT', 14),
]);
expect(result).not.toBeNull();
expect(result?.getTime()).toBe(at(0).getTime());
});
it('devuelve null si nunca asistió', () => {
expect(lastAttendedAt([entry('ABSENT', 0), entry('EXCUSED', 7)])).toBeNull();
expect(lastAttendedAt([])).toBeNull();
});
});
describe('monthlyAttendanceRate', () => {
it('razón de PRESENT sobre el total de sesiones', () => {
expect(monthlyAttendanceRate([entry('PRESENT', 0), entry('PRESENT', 7), entry('ABSENT', 14)], 5)).toBe(40);
});
it('devuelve 0 sin sesiones o sin presentes', () => {
expect(monthlyAttendanceRate([], 3)).toBe(0);
expect(monthlyAttendanceRate([entry('ABSENT', 0)], 3)).toBe(0);
});
});
describe('classifyRisk', () => {
it('HIGH con 3 o más ausencias consecutivas', () => {
expect(classifyRisk(3, 80)).toBe('HIGH');
expect(classifyRisk(5, 20)).toBe('HIGH');
});
it('MEDIUM con 2 ausencias consecutivas o asistencia menor al 50%', () => {
expect(classifyRisk(2, 90)).toBe('MEDIUM');
expect(classifyRisk(1, 40)).toBe('MEDIUM');
expect(classifyRisk(2, 40)).toBe('MEDIUM');
});
it('sin riesgo si no cumple ningún umbral', () => {
expect(classifyRisk(1, 60)).toBeNull();
expect(classifyRisk(0, 60)).toBeNull();
});
});

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);
});
});
});

View File

@@ -27,6 +27,7 @@ const attendee = {
guardianName: 'Luis Pérez',
guardianPhone: null,
notes: null,
notifyToken: null,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};

View File

@@ -0,0 +1,507 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findMany: mock(),
},
classSession: {
upsert: mock(),
findMany: mock(),
findUnique: mock(),
},
attendee: {
findMany: mock(),
findUnique: mock(),
},
payment: {
findMany: mock(),
},
attendance: {
findMany: mock(),
upsert: mock(),
},
slotRelease: {
findMany: mock(),
upsert: 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 { classesRoutes } from '@/modules/classes';
const userId = 'user-1';
const UTC_WEEKDAYS = [
'SUNDAY',
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
] as const;
const todayUtc = UTC_WEEKDAYS[new Date().getUTCDay()];
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/classes', classesRoutes);
return app;
}
function makeSession(overrides: Record<string, unknown> = {}) {
return {
id: 'session-1',
groupId: 'group-1',
startsAt: new Date('2026-09-23T19:00:00.000Z'),
group: {
id: 'group-1',
name: 'Funcional',
createdById: userId,
members: [],
capacity: 10,
},
...overrides,
};
}
describe('classes routes', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.ABSENCE_RELEASE_HOURS;
});
describe('GET /classes/today', () => {
it('materializa y lista las clases de hoy del profesor', async () => {
const startsAt = new Date('2026-09-23T19:00:00.000Z');
prisma.group.findMany.mockResolvedValue([
{
id: 'group-1',
name: 'Funcional',
days: [todayUtc],
time: '19:00',
capacity: 10,
_count: { attendees: 8 },
},
]);
prisma.classSession.upsert.mockResolvedValue({ id: 'session-1' });
prisma.classSession.findMany.mockResolvedValue([
{ id: 'session-1', groupId: 'group-1', startsAt, group: { id: 'group-1', name: 'Funcional', capacity: 10 } },
]);
prisma.attendance.findMany.mockResolvedValue([{ classSessionId: 'session-1' }]);
const res = await makeApp({ id: userId }).request('/classes/today?timeZone=UTC');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [
{
sessionId: 'session-1',
groupId: 'group-1',
groupName: 'Funcional',
startsAt: startsAt.toISOString(),
enrolledCount: 8,
capacity: 10,
availableSlots: 2,
hasAttendance: true,
},
],
});
expect(prisma.classSession.upsert).toHaveBeenCalledTimes(1);
expect(prisma.classSession.upsert).toHaveBeenCalledWith({
where: {
groupId_startsAt: { groupId: 'group-1', startsAt },
},
create: { groupId: 'group-1', startsAt },
update: {},
select: { id: true },
});
});
it('devuelve lista vacía cuando ningún grupo tiene clase hoy', async () => {
prisma.group.findMany.mockResolvedValue([]);
const res = await makeApp({ id: userId }).request('/classes/today?timeZone=UTC');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ data: [] });
expect(prisma.classSession.upsert).not.toHaveBeenCalled();
});
it('rechaza requests sin sesión', async () => {
const res = await makeApp(null).request('/classes/today');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
});
describe('GET /classes/:sessionId/students', () => {
it('incluye estado de pago dinámico, asistencia y ausencia avisada', async () => {
prisma.classSession.findUnique.mockResolvedValue(makeSession());
prisma.attendee.findMany.mockResolvedValue([
{ id: 'attendee-1', fullName: 'Ana Pérez' },
{ id: 'attendee-2', fullName: 'Bruno Díaz' },
]);
prisma.payment.findMany.mockResolvedValue([
{ attendeeId: 'attendee-1', status: 'OVERDUE', dueDate: new Date('2026-09-01T00:00:00.000Z') },
{ attendeeId: 'attendee-2', status: 'PAID', dueDate: new Date('2026-09-01T00:00:00.000Z') },
]);
prisma.attendance.findMany.mockResolvedValue([
{ attendeeId: 'attendee-2', status: 'EXCUSED' },
]);
prisma.slotRelease.findMany.mockResolvedValue([]);
const res = await makeApp({ id: userId }).request(
'/classes/session-1/students?timeZone=UTC',
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({
sessionId: 'session-1',
groupId: 'group-1',
groupName: 'Funcional',
students: [
{
attendeeId: 'attendee-1',
fullName: 'Ana Pérez',
paymentStatus: 'PENDING',
attendanceStatus: null,
notifiedAbsence: false,
},
{
attendeeId: 'attendee-2',
fullName: 'Bruno Díaz',
paymentStatus: 'UP_TO_DATE',
attendanceStatus: 'EXCUSED',
notifiedAbsence: true,
},
],
});
});
it('prohíbe el acceso a quien no es owner ni miembro del grupo', async () => {
prisma.classSession.findUnique.mockResolvedValue(
makeSession({
group: {
id: 'group-1',
name: 'Funcional',
createdById: 'someone-else',
members: [],
},
}),
);
const res = await makeApp({ id: userId }).request('/classes/session-1/students');
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'group_access_denied' });
});
it('devuelve 404 si la sesión no existe', async () => {
prisma.classSession.findUnique.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/classes/nope/students');
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
});
});
describe('POST /classes/:sessionId/attendance', () => {
const validBody = {
classSessionId: 'session-1',
records: [
{ attendeeId: 'attendee-1', status: 'PRESENT' },
{ attendeeId: 'attendee-2', status: 'ABSENT' },
],
};
it('guarda la asistencia masiva con upsert', async () => {
prisma.classSession.findUnique.mockResolvedValue(makeSession());
prisma.attendee.findMany.mockResolvedValue([
{ id: 'attendee-1' },
{ id: 'attendee-2' },
]);
prisma.attendance.upsert.mockResolvedValue({});
const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validBody),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ classSessionId: 'session-1', marked: 2 });
expect(prisma.attendance.upsert).toHaveBeenCalledTimes(2);
expect(prisma.attendance.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
attendeeId_classSessionId: {
attendeeId: 'attendee-1',
classSessionId: 'session-1',
},
},
}),
);
});
it('rechaza cuando el classSessionId del body no coincide con la ruta', async () => {
const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...validBody, classSessionId: 'other-session' }),
});
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ status: 400 });
});
it('rechaza alumnos que no pertenecen al grupo', async () => {
prisma.classSession.findUnique.mockResolvedValue(makeSession());
prisma.attendee.findMany.mockResolvedValue([{ id: 'attendee-1' }]);
const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validBody),
});
expect(res.status).toBe(400);
expect(prisma.attendance.upsert).not.toHaveBeenCalled();
});
it('rechaza registros con status fuera del contrato', async () => {
const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
classSessionId: 'session-1',
records: [{ attendeeId: 'attendee-1', status: 'EXCUSED' }],
}),
});
expect(res.status).toBe(400);
});
it('rechaza requests sin sesión', async () => {
const res = await makeApp(null).request('/classes/session-1/attendance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validBody),
});
expect(res.status).toBe(401);
});
});
describe('POST /classes/:sessionId/notify-absence (profesor)', () => {
const notifyBody = {
attendeeId: 'attendee-1',
classSessionId: 'session-1',
};
it('libera el cupo cuando la anticipación alcanza las 12 horas', async () => {
const startsAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt }));
prisma.attendee.findUnique.mockResolvedValue({
id: 'attendee-1',
groupId: 'group-1',
});
prisma.attendance.upsert.mockResolvedValue({});
prisma.slotRelease.upsert.mockResolvedValue({});
const res = await makeApp({ id: userId }).request(
'/classes/session-1/notify-absence',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(notifyBody),
},
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
classSessionId: 'session-1',
notified: true,
slotReleased: true,
});
expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1);
expect(prisma.slotRelease.upsert).toHaveBeenCalledTimes(1);
});
it('marca EXCUSED sin liberar cupo si avisa con menos de 12 horas', async () => {
const startsAt = new Date(Date.now() + 60 * 60 * 1000);
prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt }));
prisma.attendee.findUnique.mockResolvedValue({
id: 'attendee-1',
groupId: 'group-1',
});
prisma.attendance.upsert.mockResolvedValue({});
prisma.slotRelease.upsert.mockResolvedValue({});
const res = await makeApp({ id: userId }).request(
'/classes/session-1/notify-absence',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(notifyBody),
},
);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ slotReleased: false });
expect(prisma.slotRelease.upsert).not.toHaveBeenCalled();
expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1);
});
it('respeta el override configurable ABSENCE_RELEASE_HOURS', async () => {
process.env.ABSENCE_RELEASE_HOURS = '4';
const startsAt = new Date(Date.now() + 6 * 60 * 60 * 1000);
prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt }));
prisma.attendee.findUnique.mockResolvedValue({
id: 'attendee-1',
groupId: 'group-1',
});
prisma.attendance.upsert.mockResolvedValue({});
prisma.slotRelease.upsert.mockResolvedValue({});
const res = await makeApp({ id: userId }).request(
'/classes/session-1/notify-absence',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(notifyBody),
},
);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ slotReleased: true });
expect(prisma.slotRelease.upsert).toHaveBeenCalledTimes(1);
delete process.env.ABSENCE_RELEASE_HOURS;
});
it('rechaza un alumno de otro grupo', async () => {
prisma.classSession.findUnique.mockResolvedValue(makeSession());
prisma.attendee.findUnique.mockResolvedValue({
id: 'attendee-1',
groupId: 'another-group',
});
const res = await makeApp({ id: userId }).request(
'/classes/session-1/notify-absence',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(notifyBody),
},
);
expect(res.status).toBe(400);
expect(prisma.attendance.upsert).not.toHaveBeenCalled();
});
});
describe('rutas públicas con token', () => {
it('GET /classes/absence devuelve 404 con token inválido', async () => {
prisma.attendee.findUnique.mockResolvedValue(null);
const res = await makeApp(null).request('/classes/absence?token=bad-token');
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
});
it('GET /classes/absence devuelve las clases de hoy del alumno', async () => {
const mxWeekday = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Mexico_City',
weekday: 'long',
})
.format(new Date())
.toUpperCase();
const startsAt = new Date('2026-09-23T19:00:00.000Z');
prisma.attendee.findUnique.mockResolvedValue({
id: 'attendee-1',
fullName: 'Ana Pérez',
group: {
id: 'group-1',
name: 'Funcional',
days: [mxWeekday],
time: '19:00',
},
});
prisma.classSession.upsert.mockResolvedValue({ id: 'session-1' });
prisma.classSession.findMany.mockResolvedValue([
{ id: 'session-1', groupId: 'group-1', startsAt },
]);
prisma.attendance.findMany.mockResolvedValue([]);
prisma.slotRelease.findMany.mockResolvedValue([]);
const res = await makeApp(null).request('/classes/absence?token=valid-token');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
attendeeId: 'attendee-1',
fullName: 'Ana Pérez',
groupName: 'Funcinal'.replace('Funcinal', 'Funcional'),
sessions: [
{
sessionId: 'session-1',
groupName: 'Funcional',
startsAt: startsAt.toISOString(),
notified: false,
attendanceStatus: null,
},
],
});
});
it('POST /classes/notify-absence público registra el aviso con token', async () => {
const startsAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
prisma.attendee.findUnique.mockResolvedValue({
id: 'attendee-1',
groupId: 'group-1',
});
prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt }));
prisma.attendance.upsert.mockResolvedValue({});
prisma.slotRelease.upsert.mockResolvedValue({});
const res = await makeApp(null).request('/classes/notify-absence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'valid-token', classSessionId: 'session-1' }),
});
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ notified: true, slotReleased: true });
expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1);
});
it('POST /classes/notify-absence público devuelve 404 con token inválido', async () => {
prisma.attendee.findUnique.mockResolvedValue(null);
const res = await makeApp(null).request('/classes/notify-absence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'bad', classSessionId: 'session-1' }),
});
expect(res.status).toBe(404);
expect(prisma.attendance.upsert).not.toHaveBeenCalled();
});
});
});

View File

@@ -38,6 +38,18 @@ describe('session auth', () => {
expect(isPublicApiRequest('GET', '/api/v1/attendees')).toBe(false);
});
it('classifies absence notification endpoints as public (token-based)', () => {
expect(isPublicApiRequest('GET', '/api/v1/classes/absence?token=abc')).toBe(true);
expect(isPublicApiRequest('POST', '/api/v1/classes/notify-absence')).toBe(true);
// Las rutas autenticadas de clases siguen protegidas.
expect(isPublicApiRequest('GET', '/api/v1/classes/today')).toBe(false);
expect(isPublicApiRequest('GET', '/api/v1/classes/session-1/students')).toBe(false);
expect(isPublicApiRequest('POST', '/api/v1/classes/session-1/attendance')).toBe(false);
expect(
isPublicApiRequest('POST', '/api/v1/classes/session-1/notify-absence'),
).toBe(false);
});
it('allows public requests without a session', async () => {
const app = new Hono();
app.use('*', sessionAuthMiddleware);

View File

@@ -1,8 +1,12 @@
import type {
AbsenceDetails,
AttendeeDto,
AttendeeHistory,
AttendeeList,
AttendeeStatus,
BulkCreateAttendees,
BulkCreateAttendeesResult,
ClassTodayList,
ConnectPayment,
ConnectPaymentResult,
CreateAttendee,
@@ -11,6 +15,7 @@ import type {
CreateFirstGroupResult,
CreateGroupResult,
CreateGroupWaitlistEntry,
GroupAnalytics,
GroupDto,
GroupInviteInfoDto,
GroupList,
@@ -20,11 +25,19 @@ import type {
InviteTokenResult,
JoinGroupViaInvite,
JoinGroupViaInviteResult,
MarkAttendance,
MarkAttendanceResult,
NotifyAbsence,
NotifyAbsenceResult,
OnboardingStatusDto,
ProblemDetails,
PromoteGroupWaitlistEntryResult,
PublicNotifyAbsence,
RemoveAttendeeResult,
RemoveGroupWaitlistEntryResult,
SessionStudents,
StudentsAtRisk,
UpdateStudentStatusResult,
} from '@gruperly/shared'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
@@ -89,18 +102,18 @@ export const createGroup = (payload: CreateFirstGroup) =>
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
export const getHomeSummary = () => {
let timeZone: string | undefined
function timeZoneParam(): string {
try {
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
return timeZone ? `?timeZone=${encodeURIComponent(timeZone)}` : ''
} catch {
// Sin zona horaria disponible: el backend usa su valor por defecto.
timeZone = undefined
return ''
}
const params = timeZone ? `?timeZone=${encodeURIComponent(timeZone)}` : ''
return apiFetch<HomeSummaryDto>(`/api/v1/home${params}`)
}
export const getHomeSummary = () =>
apiFetch<HomeSummaryDto>(`/api/v1/home${timeZoneParam()}`)
export const getGroup = (groupId: string) =>
apiFetch<GroupDto>(`/api/v1/groups/${groupId}`)
@@ -171,4 +184,50 @@ export const removeGroupAttendee = (
{
method: 'DELETE',
},
)
)
// — Día a Día: clases y asistencia —
export const getClassesToday = () =>
apiFetch<ClassTodayList>(`/api/v1/classes/today${timeZoneParam()}`)
export const getSessionStudents = (sessionId: string) =>
apiFetch<SessionStudents>(`/api/v1/classes/${sessionId}/students${timeZoneParam()}`)
export const markAttendance = (sessionId: string, payload: MarkAttendance) =>
apiFetch<MarkAttendanceResult>(`/api/v1/classes/${sessionId}/attendance`, {
method: 'POST',
body: JSON.stringify(payload),
})
export const notifyAbsence = (sessionId: string, payload: NotifyAbsence) =>
apiFetch<NotifyAbsenceResult>(`/api/v1/classes/${sessionId}/notify-absence`, {
method: 'POST',
body: JSON.stringify(payload),
})
export const getAbsenceDetails = (token: string) =>
apiFetch<AbsenceDetails>(`/api/v1/classes/absence?token=${encodeURIComponent(token)}`)
export const publicNotifyAbsence = (payload: PublicNotifyAbsence) =>
apiFetch<NotifyAbsenceResult>('/api/v1/classes/notify-absence', {
method: 'POST',
body: JSON.stringify(payload),
})
// — Analíticas y gestión de riesgo —
export const getGroupAnalytics = (groupId: string) =>
apiFetch<GroupAnalytics>(`/api/v1/groups/${groupId}/analytics`)
export const getStudentsAtRisk = (groupId: string) =>
apiFetch<StudentsAtRisk>(`/api/v1/groups/${groupId}/students-at-risk`)
export const getAttendeeHistory = (groupId: string, attendeeId: string) =>
apiFetch<AttendeeHistory>(`/api/v1/groups/${groupId}/attendees/${attendeeId}/history`)
export const updateAttendeeStatus = (attendeeId: string, status: AttendeeStatus) =>
apiFetch<UpdateStudentStatusResult>(`/api/v1/students/${attendeeId}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
})

View File

@@ -13,6 +13,9 @@ import { OnboardingView } from './routes/onboarding'
import { GroupDetailView } from './routes/group-detail'
import { CreateGroupView } from './routes/create-group'
import { JoinGroupView } from './routes/join-group'
import { ClassAttendanceView } from './routes/class-attendance'
import { AbsenceNotifyView } from './routes/absence-notify'
import { AnalyticsView } from './routes/analytics'
const rootRoute = createRootRoute({
component: () => <Outlet />,
@@ -48,6 +51,13 @@ const joinGroupRoute = createRoute({
component: JoinGroupView,
})
// Página pública: el alumno avisa su ausencia con su token personal.
const absenceNotifyRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/avisar-ausencia/$token',
component: AbsenceNotifyView,
})
// Capa con la navegación de la app autenticada (Sidebar + BottomNav).
// El guard redirige a /onboarding a quien no completó el onboarding.
const appLayoutRoute = createRoute({
@@ -80,6 +90,12 @@ const groupDetailRoute = createRoute({
component: GroupDetailView,
})
const groupAnalyticsRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/groups/$groupId/analytics',
component: AnalyticsView,
})
const paymentsRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/payments',
@@ -104,21 +120,30 @@ const profileRoute = createRoute({
component: ProfileView,
})
const classAttendanceRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/class/$sessionId/attendance',
component: ClassAttendanceView,
})
const routeTree = rootRoute.addChildren([
loginRoute,
signupRoute,
verifyEmailRoute,
onboardingRoute,
joinGroupRoute,
absenceNotifyRoute,
appLayoutRoute.addChildren([
indexRoute,
groupsRoute,
createGroupRoute,
groupDetailRoute,
groupAnalyticsRoute,
paymentsRoute,
settingsRoute,
securityRoute,
profileRoute,
classAttendanceRoute,
]),
])

View File

@@ -0,0 +1,164 @@
import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { Link, useParams } from '@tanstack/react-router'
import { CalendarX2, CheckCircle2, Loader2, Moon, Sun } from 'lucide-react'
import { Badge, Button, useToast } from '../components/ui'
import { Logo } from '../components/brand'
import { useTheme } from '../context/ThemeProvider'
import { ApiError, getAbsenceDetails, publicNotifyAbsence } from '../lib/api'
export function AbsenceNotifyView() {
const { token } = useParams({ strict: false }) as { token: string }
const { setTheme, isDark } = useTheme()
const toast = useToast()
const [locallyNotified, setLocallyNotified] = useState<string[]>([])
const detailsQuery = useQuery({
queryKey: ['absence-details', token],
queryFn: () => getAbsenceDetails(token),
enabled: Boolean(token),
retry: 1,
})
const notifyMutation = useMutation({
mutationFn: (sessionId: string) =>
publicNotifyAbsence({ token, classSessionId: sessionId }),
onSuccess: (_data, sessionId) => {
setLocallyNotified((prev) => [...prev, sessionId])
toast.success(
'Tu profesor ya tiene registrado el aviso. Gracias por avisar.',
'Ausencia avisada',
)
},
onError: (error: unknown) => {
if (error instanceof ApiError && error.problem?.status === 404) {
toast.error('Este enlace ya no es válido. Consultá a tu profesor.')
return
}
toast.error(
error instanceof Error ? error.message : 'No pudimos registrar el aviso.',
)
},
})
const details = detailsQuery.data
return (
<div className="flex min-h-screen flex-col justify-between bg-background text-primary selection:bg-accent selection:text-white">
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/80 px-4 py-3 backdrop-blur-md sm:px-8">
<Link to="/" className="flex items-center gap-2">
<Logo className="h-7 w-auto" />
</Link>
<button
type="button"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
className="rounded-xl p-2 text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
title={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
</header>
<main className="flex flex-1 items-center justify-center p-4 sm:p-6 md:p-10">
<div className="w-full max-w-lg space-y-6">
{detailsQuery.isPending ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<Loader2 className="size-8 animate-spin text-accent" />
<p className="text-sm text-foreground/60">Cargando tus clases...</p>
</div>
) : null}
{detailsQuery.isError || !details ? (
<div className="space-y-3 rounded-2xl border border-danger/20 bg-danger-soft p-6 text-center sm:p-8">
<h2 className="text-xl font-bold text-danger">Enlace no válido</h2>
<p className="mx-auto max-w-sm text-sm leading-relaxed text-foreground/70">
No pudimos identificarte con este enlace. Consultá con tu profesor para
solicitar uno nuevo.
</p>
</div>
) : null}
{details ? (
<div className="animate-step-enter space-y-5 rounded-2xl border border-border bg-surface p-6 shadow-xl sm:p-8">
<div className="border-b border-border pb-5 text-center">
<div className="mx-auto mb-3 flex size-14 items-center justify-center rounded-full bg-accent-soft text-accent">
<CalendarX2 className="size-7" />
</div>
<Badge variant="neutral" className="mb-2">
Aviso de ausencia
</Badge>
<h1 className="text-2xl font-bold text-primary">Hola, {details.fullName}</h1>
<p className="mt-1 text-sm font-medium text-foreground/80">
Grupo: <span className="text-primary">{details.groupName}</span>
</p>
</div>
{details.sessions.length === 0 ? (
<p className="rounded-xl border border-dashed border-border bg-primary-soft/40 px-4 py-8 text-center text-sm text-foreground/60">
No tenés clases programadas para hoy. Si necesitás avisar de todas formas,
contactá a tu profesor.
</p>
) : (
<ul className="space-y-3">
{details.sessions.map((session) => {
const time = new Date(session.startsAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
const notified =
session.notified || locallyNotified.includes(session.sessionId)
return (
<li
key={session.sessionId}
className="flex items-center justify-between gap-3 rounded-xl border border-border bg-primary-soft/30 px-4 py-3"
>
<div className="min-w-0">
<p className="text-sm font-semibold text-primary">
Hoy · {time} hs
</p>
<p className="truncate text-xs text-foreground/60">
{session.groupName}
</p>
</div>
{notified ? (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success">
<CheckCircle2 className="size-3.5" />
Ya avisaste
</span>
) : (
<Button
variant="primary"
size="sm"
disabled={notifyMutation.isPending}
onClick={() => notifyMutation.mutate(session.sessionId)}
>
{notifyMutation.isPending &&
notifyMutation.variables === session.sessionId ? (
<Loader2 className="size-4 animate-spin" />
) : null}
Avisar ausencia
</Button>
)}
</li>
)
})}
</ul>
)}
<p className="text-center text-xs leading-relaxed text-foreground/50">
Si avisás con anticipación, tu cupo se libera para una clase de recuperación.
</p>
</div>
) : null}
</div>
</main>
<footer className="border-t border-border px-4 py-4 text-center text-xs text-foreground/40">
Gruperly — Gestión sencilla de cobros y grupos
</footer>
</div>
)
}

View File

@@ -0,0 +1,472 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams } from '@tanstack/react-router';
import type { AttendeeStatus, StudentAtRiskDto } from '@gruperly/shared';
import {
ArrowLeft,
CalendarCheck,
DoorOpen,
Loader2,
MessageCircle,
MoreVertical,
Pause,
Percent,
UserMinus,
Users,
} from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { Avatar, Badge, Button, Modal, useToast } from '../components/ui';
import {
getAttendeeHistory,
getGroupAnalytics,
getStudentsAtRisk,
updateAttendeeStatus,
} from '../lib/api';
import { cn } from '../lib/utils';
type StatusAction = { student: StudentAtRiskDto; status: AttendeeStatus } | null;
function KpiCard({
icon,
label,
value,
hint,
}: {
icon: ReactNode;
label: string;
value: string;
hint?: string;
}) {
return (
<article className="flex items-center gap-3 rounded-xl border border-border bg-surface p-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent">
{icon}
</div>
<div className="min-w-0">
<p className="text-xs font-medium uppercase text-foreground/50">{label}</p>
<p className="truncate text-2xl font-bold text-primary">{value}</p>
{hint ? <p className="text-xs text-foreground/50">{hint}</p> : null}
</div>
</article>
);
}
function statusLabel(status: string | null): string {
switch (status) {
case 'PRESENT':
return 'Presente';
case 'ABSENT':
return 'Ausente';
case 'EXCUSED':
return 'Avisó ausencia';
default:
return 'Sin registrar';
}
}
function dotClass(status: string | null): string {
switch (status) {
case 'PRESENT':
return 'bg-success';
case 'ABSENT':
return 'bg-danger';
case 'EXCUSED':
return 'bg-warning';
default:
return 'bg-border';
}
}
function formatSessionDate(iso: string): string {
return new Date(iso).toLocaleDateString('es-MX', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}
export function AnalyticsView() {
const { groupId } = useParams({ strict: false }) as { groupId: string };
const queryClient = useQueryClient();
const toast = useToast();
const [historyStudent, setHistoryStudent] = useState<StudentAtRiskDto | null>(null);
const [statusAction, setStatusAction] = useState<StatusAction>(null);
const [menuOpenFor, setMenuOpenFor] = useState<string | null>(null);
const analyticsQuery = useQuery({
queryKey: ['group-analytics', groupId],
queryFn: () => getGroupAnalytics(groupId),
enabled: Boolean(groupId),
});
const riskQuery = useQuery({
queryKey: ['students-at-risk', groupId],
queryFn: () => getStudentsAtRisk(groupId),
enabled: Boolean(groupId),
});
const historyQuery = useQuery({
queryKey: ['attendee-history', groupId, historyStudent?.attendeeId],
queryFn: () => getAttendeeHistory(groupId, historyStudent!.attendeeId!),
enabled: Boolean(groupId && historyStudent),
});
const statusMutation = useMutation({
mutationFn: (action: Exclude<StatusAction, null>) =>
updateAttendeeStatus(action.student.attendeeId, action.status),
onSuccess: (_result, action) => {
const name = action.student.fullName.split(' ')[0];
toast.success(
action.status === 'DROPPED'
? `${name} fue dado de baja del grupo. Su vacante quedó disponible.`
: `La vacante de ${name} quedó pausada.`,
);
setStatusAction(null);
void queryClient.invalidateQueries({ queryKey: ['students-at-risk', groupId] });
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
void queryClient.invalidateQueries({ queryKey: ['group', groupId] });
void queryClient.invalidateQueries({ queryKey: ['classes-today'] });
void queryClient.invalidateQueries({ queryKey: ['home-summary'] });
},
onError: (err: Error) => {
toast.error(err.message || 'No pudimos actualizar el estado del alumno.');
},
});
const handleReengage = async (student: StudentAtRiskDto, groupName: string) => {
if (!student.phone) return;
const firstName = student.fullName.split(' ')[0] ?? student.fullName;
const message =
`¡Hola ${firstName}! 👋 Te extrañamos en ${groupName}. ` +
'Notamos que no asististe a las últimas clases y queremos que vuelvas. ' +
'Si tenés algún problema con tus horarios o tu membresía, escribinos y lo resolvemos juntos.';
const waUrl = `https://wa.me/${student.phone.replace(/\D/g, '')}?text=${encodeURIComponent(message)}`;
try {
await navigator.clipboard.writeText(message);
toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.');
} catch {
// Si clipboard falla, igual abrimos WhatsApp con el mensaje.
}
window.open(waUrl, '_blank', 'noopener,noreferrer');
};
const groupName = riskQuery.data?.groupName;
if (analyticsQuery.isPending || riskQuery.isPending) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-8 animate-spin text-accent" />
</div>
);
}
if (analyticsQuery.isError || riskQuery.isError || !analyticsQuery.data) {
return (
<div className="rounded-xl border border-danger/20 bg-danger-soft p-6 text-danger">
<h2 className="text-lg font-semibold">No pudimos cargar las estadísticas</h2>
<p className="mt-2 text-sm">Verificá tu conexión e intentá de nuevo.</p>
<Button
variant="outline"
className="mt-4"
onClick={() => {
void analyticsQuery.refetch();
void riskQuery.refetch();
}}
>
Reintentar
</Button>
</div>
);
}
const analytics = analyticsQuery.data;
const atRisk = riskQuery.data?.data ?? [];
return (
<section className="mx-auto w-full max-w-md lg:max-w-6xl">
<Link
to="/groups/$groupId"
params={{ groupId }}
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
>
<ArrowLeft className="size-4" />
Volver a {groupName ?? 'el grupo'}
</Link>
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-primary">Estadísticas</h1>
<p className="mt-2 text-sm text-foreground/60">
Asistencia y gestión de riesgo de {groupName ?? 'tu grupo'}.
</p>
</div>
<span className="shrink-0 rounded-xl bg-primary-soft px-3 py-1.5 text-xs font-semibold text-foreground/70">
Últimos 30 días
</span>
</div>
{/* KPIs */}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
<KpiCard
icon={<Percent className="size-5" />}
label="Asistencia promedio"
value={`${analytics.attendanceRate}%`}
hint="últimos 30 días"
/>
<KpiCard
icon={<CalendarCheck className="size-5" />}
label="Asistencias del mes"
value={analytics.totalPresent.toLocaleString('es-MX')}
hint={`${analytics.totalClasses} clases dictadas`}
/>
<KpiCard
icon={<DoorOpen className="size-5" />}
label="Cupos recuperados"
value={analytics.recoveredSlots.toLocaleString('es-MX')}
hint="por ausencias avisadas"
/>
</div>
{/* Alumnos en riesgo */}
<div className="mt-8">
<div className="flex items-center gap-2">
<Users className="size-5 text-accent" />
<h2 className="text-lg font-semibold text-primary">Alumnos en riesgo</h2>
{atRisk.length > 0 ? (
<Badge variant={atRisk.some((s) => s.riskLevel === 'HIGH') ? 'danger' : 'warning'}>
{atRisk.length} {atRisk.length === 1 ? 'alumno' : 'alumnos'}
</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-foreground/60">
Alumnos con ausencias consecutivas o baja asistencia mensual.
</p>
{atRisk.length === 0 ? (
<div className="mt-4 rounded-xl border border-dashed border-border bg-surface px-4 py-10 text-center">
<p className="font-medium text-primary">¡Sin alumnos en riesgo!</p>
<p className="mt-1 text-sm text-foreground/60">
No hay ausencias consecutivas ni asistencia por debajo del 50%.
</p>
</div>
) : (
<ul className="mt-4 space-y-3">
{atRisk.map((student) => (
<li
key={student.attendeeId}
className="rounded-xl border border-border bg-surface p-4 transition-colors hover:border-accent/40"
>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setHistoryStudent(student)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<Avatar name={student.fullName} />
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-primary">
{student.fullName}
</p>
<p className="mt-0.5 text-xs text-foreground/50">
{student.phone ?? 'Sin teléfono'} · {student.monthlyAttendanceRate}% de
asistencia mensual
</p>
</div>
</button>
{student.riskLevel === 'HIGH' ? (
<Badge variant="danger">
{student.consecutiveAbsences} faltas seguidas
</Badge>
) : (
<Badge variant="warning">Baja asistencia</Badge>
)}
</div>
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
<Button
variant="outline"
size="sm"
disabled={!student.phone}
onClick={() => void handleReengage(student, groupName ?? 'tu clase')}
className="flex-1 gap-2 border-0 bg-[#25D366] text-white hover:bg-[#1EBE5D] disabled:bg-foreground/10 disabled:text-foreground/40"
>
<MessageCircle className="size-4" />
Reenganchar por WhatsApp
</Button>
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Gestión de la vacante"
onClick={() =>
setMenuOpenFor(menuOpenFor === student.attendeeId ? null : student.attendeeId)
}
>
<MoreVertical className="size-4" />
</Button>
{menuOpenFor === student.attendeeId ? (
<>
<div
className="fixed inset-0 z-20"
onClick={() => setMenuOpenFor(null)}
aria-hidden="true"
/>
<div className="absolute right-0 z-30 mt-1 w-44 rounded-xl border border-border bg-surface p-1.5 shadow-xl animate-fade-in">
<button
type="button"
onClick={() => {
setStatusAction({ student, status: 'PAUSED' });
setMenuOpenFor(null);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-primary transition-colors hover:bg-primary-soft"
>
<Pause className="size-4" />
Pausar vacante
</button>
<button
type="button"
onClick={() => {
setStatusAction({ student, status: 'DROPPED' });
setMenuOpenFor(null);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-danger transition-colors hover:bg-danger-soft"
>
<UserMinus className="size-4" />
Dar de baja
</button>
</div>
</>
) : null}
</div>
</div>
</li>
))}
</ul>
)}
</div>
{/* Modal: historial del alumno */}
<Modal
isOpen={historyStudent !== null}
onClose={() => setHistoryStudent(null)}
title={historyStudent?.fullName ?? 'Historial'}
description={historyStudent ? 'Historial de presentismo de las últimas clases.' : undefined}
maxWidth="md"
>
{historyStudent ? (
<div>
{historyQuery.isPending ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-6 animate-spin text-accent" />
</div>
) : historyQuery.isError || !historyQuery.data ? (
<div className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
No pudimos cargar el historial de este alumno.
</div>
) : (
<div>
<div className="flex items-center gap-3 rounded-xl border border-border bg-primary-soft/50 p-3">
<span className="text-2xl font-bold text-primary">
{historyQuery.data.attendanceRate}%
</span>
<span className="text-xs font-medium uppercase text-foreground/60">
Presentismo general ({historyQuery.data.sessions.length} clases)
</span>
</div>
<ul className="mt-4 space-y-2">
{historyQuery.data.sessions.map((session) => (
<li
key={session.classSessionId}
className="flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3"
>
<span
className={cn(
'size-2.5 shrink-0 rounded-full',
dotClass(session.status ?? null),
)}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 text-sm font-medium text-primary">
{formatSessionDate(session.startsAt)}
</span>
<span
className={cn(
'text-xs font-semibold',
session.status === 'PRESENT'
? 'text-success'
: session.status === 'ABSENT'
? 'text-danger'
: session.status === 'EXCUSED'
? 'text-warning'
: 'text-foreground/50',
)}
>
{statusLabel(session.status)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
) : null}
</Modal>
{/* Modal: confirmar cambio de estado */}
<Modal
isOpen={statusAction !== null}
onClose={() => setStatusAction(null)}
title={statusAction?.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}
maxWidth="md"
>
{statusAction ? (
<div className="space-y-4">
<p className="text-sm text-foreground/70">
{statusAction.student.fullName}{' '}
{statusAction.status === 'DROPPED' ? (
<>
dejará de asistir al grupo y <strong className="text-primary">{groupName}</strong>. Su
vacante quedará <strong className="text-primary">disponible</strong> para otro alumno, y
su historial de asistencia se conservará.
</>
) : (
<>
será <strong className="text-primary">pausado</strong> en{' '}
<strong className="text-primary">{groupName}</strong>. Su vacante quedará{' '}
<strong className="text-primary">libre</strong> y podés reactivarla en cualquier
momento.
</>
)}
</p>
<div className="flex flex-col gap-2.5 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setStatusAction(null)}>
Cancelar
</Button>
<Button
variant="primary"
className={cn(
statusAction.status === 'DROPPED' &&
'border border-danger/20 bg-danger text-white hover:bg-danger/90',
)}
disabled={statusMutation.isPending}
onClick={() => statusMutation.mutate(statusAction)}
>
{statusMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : statusAction.status === 'DROPPED' ? (
<UserMinus className="size-4" />
) : (
<Pause className="size-4" />
)}
<span>{statusAction.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}</span>
</Button>
</div>
</div>
) : null}
</Modal>
</section>
);
}

View File

@@ -0,0 +1,259 @@
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useParams } from '@tanstack/react-router'
import type { SessionStudentDto } from '@gruperly/shared'
import { ArrowLeft, Check, Loader2, X } from 'lucide-react'
import { Avatar, Badge, Button, useToast } from '../components/ui'
import { getSessionStudents, markAttendance } from '../lib/api'
import { cn } from '../lib/utils'
type AttendanceChoice = 'PRESENT' | 'ABSENT'
function isLocked(student: SessionStudentDto): boolean {
return student.notifiedAbsence || student.attendanceStatus === 'EXCUSED'
}
function PaymentBadge({ status }: { status: SessionStudentDto['paymentStatus'] }) {
return status === 'UP_TO_DATE' ? (
<Badge variant="success">Al día</Badge>
) : (
<Badge variant="danger">Pendiente</Badge>
)
}
function AttendanceToggle({
choice,
disabled,
onToggle,
fullName,
}: {
choice: AttendanceChoice
disabled?: boolean
onToggle: () => void
fullName: string
}) {
const isPresent = choice === 'PRESENT'
return (
<button
type="button"
role="switch"
aria-checked={isPresent}
aria-label={`${isPresent ? 'Presente' : 'Ausente'}: ${fullName}`}
disabled={disabled}
onClick={onToggle}
className={cn(
'relative inline-flex h-11 w-14 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface disabled:cursor-not-allowed disabled:opacity-70',
isPresent ? 'bg-success' : 'bg-danger',
)}
>
<span
className={cn(
'inline-flex size-9 items-center justify-center rounded-full bg-white shadow-sm transition-transform duration-150',
isPresent ? 'translate-x-3' : 'translate-x-1',
)}
>
{isPresent ? (
<Check className="size-5 text-success" aria-hidden />
) : (
<X className="size-5 text-danger" aria-hidden />
)}
</span>
</button>
)
}
function StudentRow({
student,
choice,
onToggle,
}: {
student: SessionStudentDto
choice: AttendanceChoice
onToggle: () => void
}) {
const locked = isLocked(student)
return (
<li
className={cn(
'flex items-center gap-3 rounded-xl border bg-surface px-4 py-3',
locked ? 'border-warning/30 bg-warning-soft/30' : 'border-border',
)}
>
<Avatar name={student.fullName} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-primary">{student.fullName}</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<PaymentBadge status={student.paymentStatus} />
{locked ? <Badge variant="warning">Avisó ausencia</Badge> : null}
</div>
</div>
<AttendanceToggle
choice={choice}
disabled={locked}
onToggle={onToggle}
fullName={student.fullName}
/>
</li>
)
}
export function ClassAttendanceView() {
const { sessionId } = useParams({ strict: false }) as { sessionId: string }
const queryClient = useQueryClient()
const toast = useToast()
const studentsQuery = useQuery({
queryKey: ['class-students', sessionId],
queryFn: () => getSessionStudents(sessionId),
staleTime: 0,
})
const students = studentsQuery.data?.students
// Estado local de la toma: todos parten PRESENT (1-tap para marcar ausentes).
// Alumnas con ausencia avisada quedan bloqueadas en EXCUSED.
const [choices, setChoices] = useState<Record<string, AttendanceChoice>>({})
useEffect(() => {
if (!students) return
const initial: Record<string, AttendanceChoice> = {}
for (const student of students) {
if (isLocked(student)) continue
initial[student.attendeeId] = student.attendanceStatus === 'ABSENT' ? 'ABSENT' : 'PRESENT'
}
setChoices(initial)
}, [students])
const presentCount = useMemo(
() =>
(students ?? []).filter(
(student) => !isLocked(student) && choices[student.attendeeId] === 'PRESENT',
).length,
[students, choices],
)
const toggleStudent = (attendeeId: string) => {
setChoices((prev) => ({
...prev,
[attendeeId]: prev[attendeeId] === 'ABSENT' ? 'PRESENT' : 'ABSENT',
}))
}
const saveMutation = useMutation({
mutationFn: () => {
const records = (students ?? [])
.filter((student) => !isLocked(student))
.map((student) => ({
attendeeId: student.attendeeId,
status: choices[student.attendeeId] ?? 'PRESENT',
}))
return markAttendance(sessionId, { classSessionId: sessionId, records })
},
onSuccess: () => {
toast.success('Asistencia guardada correctamente.', 'Listo')
void queryClient.invalidateQueries({ queryKey: ['classes-today'] })
void queryClient.invalidateQueries({ queryKey: ['class-students', sessionId] })
},
onError: (error: Error) => {
toast.error(error.message || 'No pudimos guardar la asistencia.')
},
})
const sessionInfo = studentsQuery.data
const startTime = sessionInfo
? new Date(sessionInfo.startsAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
: null
return (
<section>
<Link
to="/"
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
>
<ArrowLeft className="size-4" />
<span>Volver al inicio</span>
</Link>
{studentsQuery.isPending ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-foreground/40" />
</div>
) : null}
{studentsQuery.isError ? (
<div className="mt-4 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
<p>No pudimos cargar la lista de alumnos.</p>
<Button
variant="outline"
size="sm"
onClick={() => void studentsQuery.refetch()}
>
Reintentar
</Button>
</div>
) : null}
{sessionInfo ? (
<>
<header className="sticky top-16 z-30 -mx-4 border-b border-border bg-background/95 px-4 py-3 backdrop-blur lg:mx-0 lg:rounded-xl lg:border lg:px-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-lg font-bold text-primary">
{sessionInfo.groupName}
</h1>
<p className="text-xs text-foreground/60">
{startTime ? `Hoy · ${startTime} hs` : 'Clase de hoy'}
</p>
</div>
<Badge variant="success" className="shrink-0 text-sm">
{presentCount}/{sessionInfo.students.length} presentes
</Badge>
</div>
</header>
{sessionInfo.students.length === 0 ? (
<div className="mt-6 rounded-xl border border-dashed border-border bg-surface px-4 py-10 text-center">
<p className="text-sm font-medium text-primary">Este grupo no tiene alumnos</p>
<p className="mt-1 text-sm text-foreground/60">
Agregá miembros desde la ficha del grupo para tomar asistencia.
</p>
</div>
) : (
<ul className="mt-4 space-y-3 pb-2">
{sessionInfo.students.map((student) => (
<StudentRow
key={student.attendeeId}
student={student}
choice={choices[student.attendeeId] ?? 'PRESENT'}
onToggle={() => toggleStudent(student.attendeeId)}
/>
))}
</ul>
)}
{sessionInfo.students.length > 0 ? (
<div className="sticky bottom-20 z-30 mt-6 lg:bottom-6">
<Button
variant="primary"
className="h-12 w-full text-base font-semibold shadow-lg"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : null}
Guardar Asistencia
</Button>
</div>
) : null}
</>
) : null}
</section>
)
}

View File

@@ -11,6 +11,7 @@ import type {
} from '@gruperly/shared';
import {
ArrowLeft,
BarChart3,
CalendarClock,
Check,
ChevronRight,
@@ -68,6 +69,9 @@ export function GroupDetailView() {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false);
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeDto | null>(null);
const absenceNotifyUrl = selectedAttendee?.notifyToken
? `${window.location.origin}/avisar-ausencia/${selectedAttendee.notifyToken}`
: null;
const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick');
const [listSection, setListSection] = useState<'members' | 'waitlist'>('members');
const [attendeeToRemove, setAttendeeToRemove] = useState<AttendeeDto | null>(null);
@@ -507,6 +511,14 @@ export function GroupDetailView() {
</div>
<div className="flex flex-wrap items-center gap-2.5">
<Button
variant="outline"
onClick={() => void navigate({ to: '/groups/$groupId/analytics', params: { groupId } })}
className="gap-2 border-border"
>
<BarChart3 className="size-4 text-accent" />
<span>Estadísticas</span>
</Button>
<Button
variant="outline"
onClick={() => setIsInviteModalOpen(true)}
@@ -1360,6 +1372,41 @@ export function GroupDetailView() {
</DetailRow>
</div>
{absenceNotifyUrl ? (
<div className="space-y-2 border-t border-border pt-4">
<p className="text-xs font-medium text-foreground/60">
Link para avisar ausencias
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg bg-primary-soft px-2 py-1.5 text-xs text-primary">
{absenceNotifyUrl}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
void navigator.clipboard.writeText(absenceNotifyUrl);
toast.success('Link copiado al portapapeles.');
}}
>
<Copy className="size-3.5" />
Copiar
</Button>
</div>
{selectedAttendee.phone ? (
<a
href={`https://wa.me/${selectedAttendee.phone.replace(/\D/g, '')}?text=${encodeURIComponent(`Hola ${selectedAttendee.fullName.split(' ')[0]}, desde este link podés avisar tus ausencias en ${absenceNotifyUrl}`)}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-success transition-colors hover:text-success/80"
>
<MessageCircle className="size-3.5" />
<span>Enviar por WhatsApp</span>
</a>
) : null}
</div>
) : null}
<div className="pt-2 border-t border-border">
<Button
variant="ghost"

View File

@@ -1,9 +1,24 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { CalendarClock, Loader2, Plus, UserCheck, Users, Wallet, type LucideIcon } from 'lucide-react'
import type { HomeSummaryDto, NextClassDto, PaymentDto, UpcomingPaymentDto } from '@gruperly/shared'
import {
CalendarClock,
ClipboardCheck,
Loader2,
Plus,
UserCheck,
Users,
Wallet,
type LucideIcon,
} from 'lucide-react'
import type {
ClassTodayDto,
HomeSummaryDto,
NextClassDto,
PaymentDto,
UpcomingPaymentDto,
} from '@gruperly/shared'
import { Badge, Button } from '../components/ui'
import { getHomeSummary } from '../lib/api'
import { getClassesToday, getHomeSummary } from '../lib/api'
import {
formatPrice,
formatRelativeDateTime,
@@ -22,6 +37,58 @@ const PAYMENT_BADGE_VARIANT: Record<
CANCELLED: 'neutral',
}
function TodayClassCard({ item }: { item: ClassTodayDto }) {
const navigate = useNavigate()
const time = new Date(item.startsAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return (
<article className="relative overflow-hidden rounded-xl border border-accent/30 bg-surface p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-foreground/50">
Clase de hoy
</p>
<h2 className="mt-1 truncate text-xl font-bold text-primary">{item.groupName}</h2>
<div className="mt-2 flex items-center gap-2 text-sm text-foreground/70">
<CalendarClock className="size-4 shrink-0 text-accent" />
<span>Hoy · {time} hs</span>
</div>
<p className="mt-1 text-xs text-foreground/60">
{item.enrolledCount} inscrito{item.enrolledCount === 1 ? '' : 's'}
{item.availableSlots != null ? ` · ${item.availableSlots} cupo${item.availableSlots === 1 ? '' : 's'} disponible${item.availableSlots === 1 ? '' : 's'}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={item.hasAttendance ? 'success' : 'neutral'}>
{item.hasAttendance ? 'Asistencia tomada' : 'Por tomar'}
</Badge>
</div>
</div>
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-3">
<Button
variant="outline"
size="sm"
onClick={() => void navigate({ to: `/groups/${item.groupId}` })}
>
Ver grupo
</Button>
<Button
variant="primary"
size="sm"
onClick={() => void navigate({ to: `/class/${item.sessionId}/attendance` })}
>
<ClipboardCheck className="size-4" />
Tomar Asistencia
</Button>
</div>
</article>
)
}
function NextClassHero({ nextClass }: { nextClass: NextClassDto }) {
const navigate = useNavigate()
const hasSchedule = (nextClass.days?.length ?? 0) > 0
@@ -145,6 +212,12 @@ export function HomeView() {
queryKey: ['home-summary'],
queryFn: getHomeSummary,
})
const classesTodayQuery = useQuery({
queryKey: ['classes-today'],
queryFn: getClassesToday,
staleTime: 60_000,
})
const todayClasses = classesTodayQuery.data?.data ?? []
return (
<section>
@@ -187,7 +260,13 @@ export function HomeView() {
{summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? (
<div className="mt-6 space-y-6">
{summaryQuery.data.nextClass ? (
{todayClasses.length > 0 ? (
<div className="space-y-3">
{todayClasses.map((classItem) => (
<TodayClassCard key={classItem.sessionId} item={classItem} />
))}
</div>
) : summaryQuery.data.nextClass ? (
<NextClassHero nextClass={summaryQuery.data.nextClass} />
) : (
<div className="rounded-xl border border-dashed border-border bg-surface px-4 py-8 text-center">

View File

@@ -1,5 +1,7 @@
export * from './lib/problem-details.js';
export * from './lib/result.js';
export * from './schemas/analytics.js';
export * from './schemas/attendance.js';
export * from './schemas/attendees.js';
export * from './schemas/enums.js';
export * from './schemas/groups.js';

View File

@@ -0,0 +1,67 @@
import { z } from 'zod';
import { attendanceStatusSchema } from './attendance.js';
const isoDateTimeSchema = z.string().datetime();
export const attendeeStatusSchema = z.enum(['ACTIVE', 'PAUSED', 'DROPPED']);
export type AttendeeStatus = z.output<typeof attendeeStatusSchema>;
export const riskLevelSchema = z.enum(['HIGH', 'MEDIUM']);
export type RiskLevel = z.output<typeof riskLevelSchema>;
export const GroupAnalyticsSchema = z.object({
attendanceRate: z.number().min(0).max(100),
totalPresent: z.number().int().min(0),
totalClasses: z.number().int().min(0),
recoveredSlots: z.number().int().min(0),
});
export type GroupAnalytics = z.output<typeof GroupAnalyticsSchema>;
export type GroupAnalyticsDto = GroupAnalytics;
export const StudentAtRiskSchema = z.object({
attendeeId: z.string(),
fullName: z.string(),
phone: z.string().nullable(),
riskLevel: riskLevelSchema,
consecutiveAbsences: z.number().int().min(0),
lastAttendedAt: isoDateTimeSchema.nullable(),
monthlyAttendanceRate: z.number().min(0).max(100),
});
export type StudentAtRisk = z.output<typeof StudentAtRiskSchema>;
export type StudentAtRiskDto = StudentAtRisk;
export const StudentsAtRiskSchema = z.object({
groupName: z.string(),
data: z.array(StudentAtRiskSchema),
});
export type StudentsAtRisk = z.output<typeof StudentsAtRiskSchema>;
export type StudentsAtRiskDto = StudentsAtRisk;
export const AttendanceHistoryItemSchema = z.object({
classSessionId: z.string(),
startsAt: isoDateTimeSchema,
status: attendanceStatusSchema.nullable(),
});
export type AttendanceHistoryItem = z.output<typeof AttendanceHistoryItemSchema>;
export const AttendeeHistorySchema = z.object({
attendeeId: z.string(),
fullName: z.string(),
groupId: z.string(),
groupName: z.string(),
attendanceRate: z.number().min(0).max(100),
sessions: z.array(AttendanceHistoryItemSchema),
});
export type AttendeeHistory = z.output<typeof AttendeeHistorySchema>;
export type AttendeeHistoryDto = AttendeeHistory;
export const UpdateStudentStatusSchema = z.object({
status: attendeeStatusSchema,
});
export type UpdateStudentStatus = z.output<typeof UpdateStudentStatusSchema>;
export const UpdateStudentStatusResultSchema = z.object({
attendeeId: z.string(),
status: attendeeStatusSchema,
});
export type UpdateStudentStatusResult = z.output<typeof UpdateStudentStatusResultSchema>;

View File

@@ -0,0 +1,111 @@
import { z } from 'zod';
import { timeZoneSchema } from './home.js';
const isoDateTimeSchema = z.string().datetime();
export const attendanceStatusSchema = z.enum(['PRESENT', 'ABSENT', 'EXCUSED']);
export type AttendanceStatus = z.output<typeof attendanceStatusSchema>;
export const attendeePaymentStatusSchema = z.enum(['UP_TO_DATE', 'PENDING']);
export type AttendeePaymentStatus = z.output<typeof attendeePaymentStatusSchema>;
export const MarkAttendanceRecordSchema = z.object({
attendeeId: z.string().min(1),
status: z.enum(['PRESENT', 'ABSENT']),
});
export type MarkAttendanceRecord = z.output<typeof MarkAttendanceRecordSchema>;
export const MarkAttendanceSchema = z.object({
classSessionId: z.string().min(1),
records: z.array(MarkAttendanceRecordSchema).min(1, 'Debes enviar al menos un registro de asistencia'),
});
export type MarkAttendance = z.output<typeof MarkAttendanceSchema>;
export const MarkAttendanceResultSchema = z.object({
classSessionId: z.string(),
marked: z.number().int().min(0),
});
export type MarkAttendanceResult = z.output<typeof MarkAttendanceResultSchema>;
export const NotifyAbsenceSchema = z.object({
attendeeId: z.string().min(1),
classSessionId: z.string().min(1),
});
export type NotifyAbsence = z.output<typeof NotifyAbsenceSchema>;
export const PublicNotifyAbsenceSchema = z.object({
token: z.string().min(1),
classSessionId: z.string().min(1),
});
export type PublicNotifyAbsence = z.output<typeof PublicNotifyAbsenceSchema>;
export const NotifyAbsenceResultSchema = z.object({
classSessionId: z.string(),
notified: z.boolean(),
slotReleased: z.boolean(),
});
export type NotifyAbsenceResult = z.output<typeof NotifyAbsenceResultSchema>;
export const AbsenceQuerySchema = z.object({
token: z.string().min(1),
});
export type AbsenceQuery = z.output<typeof AbsenceQuerySchema>;
export const ClassTodaySchema = z.object({
sessionId: z.string(),
groupId: z.string(),
groupName: z.string(),
startsAt: isoDateTimeSchema,
enrolledCount: z.number().int().min(0),
capacity: z.number().int().min(1).nullable(),
availableSlots: z.number().int().min(0).nullable(),
hasAttendance: z.boolean(),
});
export type ClassToday = z.output<typeof ClassTodaySchema>;
export type ClassTodayDto = ClassToday;
export const ClassTodayListSchema = z.object({
data: z.array(ClassTodaySchema),
});
export type ClassTodayList = z.output<typeof ClassTodayListSchema>;
export const TodayClassesQuerySchema = z.object({
timeZone: timeZoneSchema.optional(),
});
export type TodayClassesQuery = z.output<typeof TodayClassesQuerySchema>;
export const SessionStudentSchema = z.object({
attendeeId: z.string(),
fullName: z.string(),
paymentStatus: attendeePaymentStatusSchema,
attendanceStatus: attendanceStatusSchema.nullable(),
notifiedAbsence: z.boolean(),
});
export type SessionStudent = z.output<typeof SessionStudentSchema>;
export type SessionStudentDto = SessionStudent;
export const SessionStudentsSchema = z.object({
sessionId: z.string(),
groupId: z.string(),
groupName: z.string(),
startsAt: isoDateTimeSchema,
students: z.array(SessionStudentSchema),
});
export type SessionStudents = z.output<typeof SessionStudentsSchema>;
export const AbsenceSessionSchema = z.object({
sessionId: z.string(),
groupName: z.string(),
startsAt: isoDateTimeSchema,
notified: z.boolean(),
attendanceStatus: attendanceStatusSchema.nullable(),
});
export type AbsenceSession = z.output<typeof AbsenceSessionSchema>;
export const AbsenceDetailsSchema = z.object({
attendeeId: z.string(),
fullName: z.string(),
groupName: z.string(),
sessions: z.array(AbsenceSessionSchema),
});
export type AbsenceDetails = z.output<typeof AbsenceDetailsSchema>;

View File

@@ -16,6 +16,7 @@ export const AttendeeDtoSchema = z.object({
guardianName: z.string().nullable(),
guardianPhone: z.string().nullable(),
notes: z.string().nullable(),
notifyToken: z.string().nullable().optional(),
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});

View File

@@ -15,6 +15,8 @@ const timeZoneSchema = z
}
}, 'Zona horaria IANA inválida');
export { timeZoneSchema };
export const HomeStatsSchema = z.object({
groups: z.number().int().nonnegative(),
attendees: z.number().int().nonnegative(),