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:
@@ -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;
|
||||
@@ -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';
|
||||
50
apps/backend/prisma/models/attendance.prisma
Normal file
50
apps/backend/prisma/models/attendance.prisma
Normal 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")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
2
apps/backend/src/modules/analytics/index.ts
Normal file
2
apps/backend/src/modules/analytics/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as studentStatusRoutes } from './features/update-attendee-status/route';
|
||||
export { default as analyticsRoutes } from './routes';
|
||||
37
apps/backend/src/modules/analytics/lib/access.ts
Normal file
37
apps/backend/src/modules/analytics/lib/access.ts
Normal 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);
|
||||
}
|
||||
56
apps/backend/src/modules/analytics/lib/risk.ts
Normal file
56
apps/backend/src/modules/analytics/lib/risk.ts
Normal 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;
|
||||
}
|
||||
12
apps/backend/src/modules/analytics/routes.ts
Normal file
12
apps/backend/src/modules/analytics/routes.ts
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
23
apps/backend/src/modules/classes/features/today/route.ts
Normal file
23
apps/backend/src/modules/classes/features/today/route.ts
Normal 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;
|
||||
82
apps/backend/src/modules/classes/features/today/use-case.ts
Normal file
82
apps/backend/src/modules/classes/features/today/use-case.ts
Normal 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),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
1
apps/backend/src/modules/classes/index.ts
Normal file
1
apps/backend/src/modules/classes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as classesRoutes } from './routes';
|
||||
92
apps/backend/src/modules/classes/lib/helpers.ts
Normal file
92
apps/backend/src/modules/classes/lib/helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
3
apps/backend/src/modules/classes/lib/index.ts
Normal file
3
apps/backend/src/modules/classes/lib/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './helpers';
|
||||
export * from './notify';
|
||||
export * from './sessions';
|
||||
91
apps/backend/src/modules/classes/lib/notify.ts
Normal file
91
apps/backend/src/modules/classes/lib/notify.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
64
apps/backend/src/modules/classes/lib/sessions.ts
Normal file
64
apps/backend/src/modules/classes/lib/sessions.ts
Normal 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;
|
||||
}
|
||||
18
apps/backend/src/modules/classes/routes.ts
Normal file
18
apps/backend/src/modules/classes/routes.ts
Normal 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;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
79
apps/backend/test/analytics-risk.test.ts
Normal file
79
apps/backend/test/analytics-risk.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
325
apps/backend/test/analytics.test.ts
Normal file
325
apps/backend/test/analytics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
507
apps/backend/test/classes.test.ts
Normal file
507
apps/backend/test/classes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user