From 2e184845e16b7103be2c63ad0805bba15972fbcb Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Wed, 23 Sep 2026 16:24:45 -0300 Subject: [PATCH] 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. --- .../migration.sql | 68 +++ .../migration.sql | 5 + apps/backend/prisma/models/attendance.prisma | 50 ++ apps/backend/prisma/models/domain.prisma | 21 +- apps/backend/src/app.ts | 4 + apps/backend/src/http/session-auth.ts | 13 +- .../features/get-attendee-history/route.ts | 21 + .../features/get-attendee-history/use-case.ts | 83 +++ .../analytics/features/get-overview/route.ts | 20 + .../features/get-overview/use-case.ts | 55 ++ .../features/get-students-at-risk/route.ts | 20 + .../features/get-students-at-risk/use-case.ts | 122 +++++ .../features/update-attendee-status/route.ts | 24 + .../update-attendee-status/use-case.ts | 53 ++ apps/backend/src/modules/analytics/index.ts | 2 + .../src/modules/analytics/lib/access.ts | 37 ++ .../backend/src/modules/analytics/lib/risk.ts | 56 ++ apps/backend/src/modules/analytics/routes.ts | 12 + .../attendees/features/create/use-case.ts | 4 +- .../src/modules/attendees/lib/helpers.ts | 2 + .../classes/features/mark-attendance/route.ts | 24 + .../features/mark-attendance/use-case.ts | 105 ++++ .../classes/features/notify-absence/route.ts | 24 + .../features/notify-absence/use-case.ts | 71 +++ .../classes/features/public-details/route.ts | 19 + .../features/public-details/use-case.ts | 93 ++++ .../classes/features/public-notify/route.ts | 19 + .../features/public-notify/use-case.ts | 48 ++ .../features/session-students/route.ts | 24 + .../features/session-students/use-case.ts | 112 ++++ .../modules/classes/features/today/route.ts | 23 + .../classes/features/today/use-case.ts | 82 +++ apps/backend/src/modules/classes/index.ts | 1 + .../src/modules/classes/lib/helpers.ts | 92 ++++ apps/backend/src/modules/classes/lib/index.ts | 3 + .../backend/src/modules/classes/lib/notify.ts | 91 ++++ .../src/modules/classes/lib/sessions.ts | 64 +++ apps/backend/src/modules/classes/routes.ts | 18 + .../src/modules/group-waitlist/lib/promote.ts | 4 +- apps/backend/src/modules/groups/routes.ts | 2 + .../backend/src/modules/home/lib/scheduler.ts | 46 +- .../features/join-via-invite/use-case.ts | 4 +- apps/backend/test/analytics-risk.test.ts | 79 +++ apps/backend/test/analytics.test.ts | 325 +++++++++++ apps/backend/test/attendees.test.ts | 1 + apps/backend/test/classes.test.ts | 507 ++++++++++++++++++ apps/backend/test/session-auth.test.ts | 12 + apps/web/src/lib/api.ts | 75 ++- apps/web/src/router.tsx | 25 + apps/web/src/routes/absence-notify.tsx | 164 ++++++ apps/web/src/routes/analytics.tsx | 472 ++++++++++++++++ apps/web/src/routes/class-attendance.tsx | 259 +++++++++ apps/web/src/routes/group-detail.tsx | 47 ++ apps/web/src/routes/home.tsx | 87 ++- packages/shared/src/index.ts | 2 + packages/shared/src/schemas/analytics.ts | 67 +++ packages/shared/src/schemas/attendance.ts | 111 ++++ packages/shared/src/schemas/attendees.ts | 1 + packages/shared/src/schemas/home.ts | 2 + 59 files changed, 3854 insertions(+), 23 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260923120000_add_class_sessions_attendance/migration.sql create mode 100644 apps/backend/prisma/migrations/20260923172527_add_attendee_status/migration.sql create mode 100644 apps/backend/prisma/models/attendance.prisma create mode 100644 apps/backend/src/modules/analytics/features/get-attendee-history/route.ts create mode 100644 apps/backend/src/modules/analytics/features/get-attendee-history/use-case.ts create mode 100644 apps/backend/src/modules/analytics/features/get-overview/route.ts create mode 100644 apps/backend/src/modules/analytics/features/get-overview/use-case.ts create mode 100644 apps/backend/src/modules/analytics/features/get-students-at-risk/route.ts create mode 100644 apps/backend/src/modules/analytics/features/get-students-at-risk/use-case.ts create mode 100644 apps/backend/src/modules/analytics/features/update-attendee-status/route.ts create mode 100644 apps/backend/src/modules/analytics/features/update-attendee-status/use-case.ts create mode 100644 apps/backend/src/modules/analytics/index.ts create mode 100644 apps/backend/src/modules/analytics/lib/access.ts create mode 100644 apps/backend/src/modules/analytics/lib/risk.ts create mode 100644 apps/backend/src/modules/analytics/routes.ts create mode 100644 apps/backend/src/modules/classes/features/mark-attendance/route.ts create mode 100644 apps/backend/src/modules/classes/features/mark-attendance/use-case.ts create mode 100644 apps/backend/src/modules/classes/features/notify-absence/route.ts create mode 100644 apps/backend/src/modules/classes/features/notify-absence/use-case.ts create mode 100644 apps/backend/src/modules/classes/features/public-details/route.ts create mode 100644 apps/backend/src/modules/classes/features/public-details/use-case.ts create mode 100644 apps/backend/src/modules/classes/features/public-notify/route.ts create mode 100644 apps/backend/src/modules/classes/features/public-notify/use-case.ts create mode 100644 apps/backend/src/modules/classes/features/session-students/route.ts create mode 100644 apps/backend/src/modules/classes/features/session-students/use-case.ts create mode 100644 apps/backend/src/modules/classes/features/today/route.ts create mode 100644 apps/backend/src/modules/classes/features/today/use-case.ts create mode 100644 apps/backend/src/modules/classes/index.ts create mode 100644 apps/backend/src/modules/classes/lib/helpers.ts create mode 100644 apps/backend/src/modules/classes/lib/index.ts create mode 100644 apps/backend/src/modules/classes/lib/notify.ts create mode 100644 apps/backend/src/modules/classes/lib/sessions.ts create mode 100644 apps/backend/src/modules/classes/routes.ts create mode 100644 apps/backend/test/analytics-risk.test.ts create mode 100644 apps/backend/test/analytics.test.ts create mode 100644 apps/backend/test/classes.test.ts create mode 100644 apps/web/src/routes/absence-notify.tsx create mode 100644 apps/web/src/routes/analytics.tsx create mode 100644 apps/web/src/routes/class-attendance.tsx create mode 100644 packages/shared/src/schemas/analytics.ts create mode 100644 packages/shared/src/schemas/attendance.ts diff --git a/apps/backend/prisma/migrations/20260923120000_add_class_sessions_attendance/migration.sql b/apps/backend/prisma/migrations/20260923120000_add_class_sessions_attendance/migration.sql new file mode 100644 index 0000000..6384f24 --- /dev/null +++ b/apps/backend/prisma/migrations/20260923120000_add_class_sessions_attendance/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20260923172527_add_attendee_status/migration.sql b/apps/backend/prisma/migrations/20260923172527_add_attendee_status/migration.sql new file mode 100644 index 0000000..66cabe9 --- /dev/null +++ b/apps/backend/prisma/migrations/20260923172527_add_attendee_status/migration.sql @@ -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'; diff --git a/apps/backend/prisma/models/attendance.prisma b/apps/backend/prisma/models/attendance.prisma new file mode 100644 index 0000000..95ddf50 --- /dev/null +++ b/apps/backend/prisma/models/attendance.prisma @@ -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") +} diff --git a/apps/backend/prisma/models/domain.prisma b/apps/backend/prisma/models/domain.prisma index 09a8fdb..713fa71 100644 --- a/apps/backend/prisma/models/domain.prisma +++ b/apps/backend/prisma/models/domain.prisma @@ -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") diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 7d511fb..8e5c5e2 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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)); diff --git a/apps/backend/src/http/session-auth.ts b/apps/backend/src/http/session-auth.ts index b446fe0..def791f 100644 --- a/apps/backend/src/http/session-auth.ts +++ b/apps/backend/src/http/session-auth.ts @@ -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; } \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/features/get-attendee-history/route.ts b/apps/backend/src/modules/analytics/features/get-attendee-history/route.ts new file mode 100644 index 0000000..b023827 --- /dev/null +++ b/apps/backend/src/modules/analytics/features/get-attendee-history/route.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/features/get-attendee-history/use-case.ts b/apps/backend/src/modules/analytics/features/get-attendee-history/use-case.ts new file mode 100644 index 0000000..09a2b85 --- /dev/null +++ b/apps/backend/src/modules/analytics/features/get-attendee-history/use-case.ts @@ -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; +}; + +export class GetAttendeeHistory { + constructor(private readonly deps: GetAttendeeHistoryDeps = {}) {} + + async execute( + groupId: string, + attendeeId: string, + userId: string, + ): Promise> { + 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( + 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, + }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/features/get-overview/route.ts b/apps/backend/src/modules/analytics/features/get-overview/route.ts new file mode 100644 index 0000000..94e4997 --- /dev/null +++ b/apps/backend/src/modules/analytics/features/get-overview/route.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/features/get-overview/use-case.ts b/apps/backend/src/modules/analytics/features/get-overview/use-case.ts new file mode 100644 index 0000000..6d848eb --- /dev/null +++ b/apps/backend/src/modules/analytics/features/get-overview/use-case.ts @@ -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; + now?: Date; +}; + +export class GetGroupAnalytics { + constructor(private readonly deps: GetGroupAnalyticsDeps = {}) {} + + async execute( + groupId: string, + userId: string, + ): Promise> { + 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, + }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/features/get-students-at-risk/route.ts b/apps/backend/src/modules/analytics/features/get-students-at-risk/route.ts new file mode 100644 index 0000000..bcec58c --- /dev/null +++ b/apps/backend/src/modules/analytics/features/get-students-at-risk/route.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/features/get-students-at-risk/use-case.ts b/apps/backend/src/modules/analytics/features/get-students-at-risk/use-case.ts new file mode 100644 index 0000000..29edc96 --- /dev/null +++ b/apps/backend/src/modules/analytics/features/get-students-at-risk/use-case.ts @@ -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; + 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> { + 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 { + const byAttendee = new Map(); + for (const row of rows) { + const list = byAttendee.get(row.attendeeId) ?? []; + list.push(row); + byAttendee.set(row.attendeeId, list); + } + return byAttendee; +} \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/features/update-attendee-status/route.ts b/apps/backend/src/modules/analytics/features/update-attendee-status/route.ts new file mode 100644 index 0000000..d46c4b8 --- /dev/null +++ b/apps/backend/src/modules/analytics/features/update-attendee-status/route.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/features/update-attendee-status/use-case.ts b/apps/backend/src/modules/analytics/features/update-attendee-status/use-case.ts new file mode 100644 index 0000000..30cc3c7 --- /dev/null +++ b/apps/backend/src/modules/analytics/features/update-attendee-status/use-case.ts @@ -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; +}; + +export class UpdateAttendeeStatus { + constructor(private readonly deps: UpdateAttendeeStatusDeps = {}) {} + + async execute( + studentId: string, + userId: string, + payload: UpdateStudentStatus, + ): Promise> { + 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 }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/index.ts b/apps/backend/src/modules/analytics/index.ts new file mode 100644 index 0000000..75c5441 --- /dev/null +++ b/apps/backend/src/modules/analytics/index.ts @@ -0,0 +1,2 @@ +export { default as studentStatusRoutes } from './features/update-attendee-status/route'; +export { default as analyticsRoutes } from './routes'; \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/lib/access.ts b/apps/backend/src/modules/analytics/lib/access.ts new file mode 100644 index 0000000..48c03bf --- /dev/null +++ b/apps/backend/src/modules/analytics/lib/access.ts @@ -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; + +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> { + 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); +} \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/lib/risk.ts b/apps/backend/src/modules/analytics/lib/risk.ts new file mode 100644 index 0000000..dc1a2af --- /dev/null +++ b/apps/backend/src/modules/analytics/lib/risk.ts @@ -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; +} \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/routes.ts b/apps/backend/src/modules/analytics/routes.ts new file mode 100644 index 0000000..7054589 --- /dev/null +++ b/apps/backend/src/modules/analytics/routes.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/attendees/features/create/use-case.ts b/apps/backend/src/modules/attendees/features/create/use-case.ts index da27a42..cf8a656 100644 --- a/apps/backend/src/modules/attendees/features/create/use-case.ts +++ b/apps/backend/src/modules/attendees/features/create/use-case.ts @@ -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) { diff --git a/apps/backend/src/modules/attendees/lib/helpers.ts b/apps/backend/src/modules/attendees/lib/helpers.ts index c7220b0..61c8c95 100644 --- a/apps/backend/src/modules/attendees/lib/helpers.ts +++ b/apps/backend/src/modules/attendees/lib/helpers.ts @@ -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(), }; diff --git a/apps/backend/src/modules/classes/features/mark-attendance/route.ts b/apps/backend/src/modules/classes/features/mark-attendance/route.ts new file mode 100644 index 0000000..b7ad569 --- /dev/null +++ b/apps/backend/src/modules/classes/features/mark-attendance/route.ts @@ -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; diff --git a/apps/backend/src/modules/classes/features/mark-attendance/use-case.ts b/apps/backend/src/modules/classes/features/mark-attendance/use-case.ts new file mode 100644 index 0000000..713a5ba --- /dev/null +++ b/apps/backend/src/modules/classes/features/mark-attendance/use-case.ts @@ -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; + unitOfWork?: UnitOfWork; +}; + +export class MarkSessionAttendance { + constructor(private readonly deps: MarkSessionAttendanceDeps = {}) {} + + async execute( + sessionId: string, + payload: MarkAttendance, + userId: string, + ): Promise> { + 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 }); + }); + } +} diff --git a/apps/backend/src/modules/classes/features/notify-absence/route.ts b/apps/backend/src/modules/classes/features/notify-absence/route.ts new file mode 100644 index 0000000..1304c06 --- /dev/null +++ b/apps/backend/src/modules/classes/features/notify-absence/route.ts @@ -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; diff --git a/apps/backend/src/modules/classes/features/notify-absence/use-case.ts b/apps/backend/src/modules/classes/features/notify-absence/use-case.ts new file mode 100644 index 0000000..6e7835c --- /dev/null +++ b/apps/backend/src/modules/classes/features/notify-absence/use-case.ts @@ -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; + unitOfWork?: UnitOfWork; + now?: Date; + releaseHours?: number; +}; + +export class NotifySessionAbsence { + constructor(private readonly deps: NotifySessionAbsenceDeps = {}) {} + + async execute( + sessionId: string, + payload: NotifyAbsence, + userId: string, + ): Promise> { + 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, + }, + ); + } +} diff --git a/apps/backend/src/modules/classes/features/public-details/route.ts b/apps/backend/src/modules/classes/features/public-details/route.ts new file mode 100644 index 0000000..4f62e3c --- /dev/null +++ b/apps/backend/src/modules/classes/features/public-details/route.ts @@ -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; diff --git a/apps/backend/src/modules/classes/features/public-details/use-case.ts b/apps/backend/src/modules/classes/features/public-details/use-case.ts new file mode 100644 index 0000000..db40339 --- /dev/null +++ b/apps/backend/src/modules/classes/features/public-details/use-case.ts @@ -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> { + 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, + }; + }), + }); + } +} diff --git a/apps/backend/src/modules/classes/features/public-notify/route.ts b/apps/backend/src/modules/classes/features/public-notify/route.ts new file mode 100644 index 0000000..a44c2e2 --- /dev/null +++ b/apps/backend/src/modules/classes/features/public-notify/route.ts @@ -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; diff --git a/apps/backend/src/modules/classes/features/public-notify/use-case.ts b/apps/backend/src/modules/classes/features/public-notify/use-case.ts new file mode 100644 index 0000000..24be0d3 --- /dev/null +++ b/apps/backend/src/modules/classes/features/public-notify/use-case.ts @@ -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; + unitOfWork?: UnitOfWork; + now?: Date; + releaseHours?: number; +}; + +export class PublicNotifyAbsenceUseCase { + constructor(private readonly deps: PublicNotifyAbsenceDeps = {}) {} + + async execute( + payload: PublicNotifyAbsence, + ): Promise> { + 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, + }, + ); + } +} diff --git a/apps/backend/src/modules/classes/features/session-students/route.ts b/apps/backend/src/modules/classes/features/session-students/route.ts new file mode 100644 index 0000000..7cd4f4e --- /dev/null +++ b/apps/backend/src/modules/classes/features/session-students/route.ts @@ -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; diff --git a/apps/backend/src/modules/classes/features/session-students/use-case.ts b/apps/backend/src/modules/classes/features/session-students/use-case.ts new file mode 100644 index 0000000..e7c30a1 --- /dev/null +++ b/apps/backend/src/modules/classes/features/session-students/use-case.ts @@ -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; + now?: Date; + timeZone?: string; +}; + +export class GetSessionStudents { + constructor(private readonly deps: GetSessionStudentsDeps = {}) {} + + async execute( + sessionId: string, + userId: string, + query: TodayClassesQuery = {}, + ): Promise> { + 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(); + 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), + }; + }), + }); + } +} diff --git a/apps/backend/src/modules/classes/features/today/route.ts b/apps/backend/src/modules/classes/features/today/route.ts new file mode 100644 index 0000000..ab79dbf --- /dev/null +++ b/apps/backend/src/modules/classes/features/today/route.ts @@ -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; diff --git a/apps/backend/src/modules/classes/features/today/use-case.ts b/apps/backend/src/modules/classes/features/today/use-case.ts new file mode 100644 index 0000000..8a5bb0d --- /dev/null +++ b/apps/backend/src/modules/classes/features/today/use-case.ts @@ -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; + now?: Date; + timeZone?: string; +}; + +export class GetTodayClasses { + constructor(private readonly deps: GetTodayClassesDeps = {}) {} + + async execute( + userId: string, + query: TodayClassesQuery = {}, + ): Promise> { + 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), + ), + ), + }); + } +} diff --git a/apps/backend/src/modules/classes/index.ts b/apps/backend/src/modules/classes/index.ts new file mode 100644 index 0000000..02b8841 --- /dev/null +++ b/apps/backend/src/modules/classes/index.ts @@ -0,0 +1 @@ +export { default as classesRoutes } from './routes'; diff --git a/apps/backend/src/modules/classes/lib/helpers.ts b/apps/backend/src/modules/classes/lib/helpers.ts new file mode 100644 index 0000000..fcac3fc --- /dev/null +++ b/apps/backend/src/modules/classes/lib/helpers.ts @@ -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 { + 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, + }; +} diff --git a/apps/backend/src/modules/classes/lib/index.ts b/apps/backend/src/modules/classes/lib/index.ts new file mode 100644 index 0000000..d1e2995 --- /dev/null +++ b/apps/backend/src/modules/classes/lib/index.ts @@ -0,0 +1,3 @@ +export * from './helpers'; +export * from './notify'; +export * from './sessions'; diff --git a/apps/backend/src/modules/classes/lib/notify.ts b/apps/backend/src/modules/classes/lib/notify.ts new file mode 100644 index 0000000..a456341 --- /dev/null +++ b/apps/backend/src/modules/classes/lib/notify.ts @@ -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; + 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> { + 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, + }); + }); +} diff --git a/apps/backend/src/modules/classes/lib/sessions.ts b/apps/backend/src/modules/classes/lib/sessions.ts new file mode 100644 index 0000000..6af8e49 --- /dev/null +++ b/apps/backend/src/modules/classes/lib/sessions.ts @@ -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, + candidates: TodaySessionCandidate[], +): Promise { + 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; +} diff --git a/apps/backend/src/modules/classes/routes.ts b/apps/backend/src/modules/classes/routes.ts new file mode 100644 index 0000000..c19b606 --- /dev/null +++ b/apps/backend/src/modules/classes/routes.ts @@ -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; diff --git a/apps/backend/src/modules/group-waitlist/lib/promote.ts b/apps/backend/src/modules/group-waitlist/lib/promote.ts index ee5a158..8bd233d 100644 --- a/apps/backend/src/modules/group-waitlist/lib/promote.ts +++ b/apps/backend/src/modules/group-waitlist/lib/promote.ts @@ -29,7 +29,9 @@ export async function promoteWaitlistEntryRecord( entry: GroupWaitlistEntryRecord, ): Promise> { 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)); } diff --git a/apps/backend/src/modules/groups/routes.ts b/apps/backend/src/modules/groups/routes.ts index bfbc4d6..a490ac8 100644 --- a/apps/backend/src/modules/groups/routes.ts +++ b/apps/backend/src/modules/groups/routes.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/home/lib/scheduler.ts b/apps/backend/src/modules/home/lib/scheduler.ts index b92aed7..a9f4fbf 100644 --- a/apps/backend/src/modules/home/lib/scheduler.ts +++ b/apps/backend/src/modules/home/lib/scheduler.ts @@ -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); } \ No newline at end of file diff --git a/apps/backend/src/modules/invitations/features/join-via-invite/use-case.ts b/apps/backend/src/modules/invitations/features/join-via-invite/use-case.ts index 69a0497..f07fe86 100644 --- a/apps/backend/src/modules/invitations/features/join-via-invite/use-case.ts +++ b/apps/backend/src/modules/invitations/features/join-via-invite/use-case.ts @@ -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({ diff --git a/apps/backend/test/analytics-risk.test.ts b/apps/backend/test/analytics-risk.test.ts new file mode 100644 index 0000000..fa46c92 --- /dev/null +++ b/apps/backend/test/analytics-risk.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/apps/backend/test/analytics.test.ts b/apps/backend/test/analytics.test.ts new file mode 100644 index 0000000..a18c404 --- /dev/null +++ b/apps/backend/test/analytics.test.ts @@ -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) => cb(db)); + execute = mock(async (cb: (tx: unknown) => Promise) => 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 = {}) { + 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 = {}) { + 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); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/test/attendees.test.ts b/apps/backend/test/attendees.test.ts index 60e49d8..ca49895 100644 --- a/apps/backend/test/attendees.test.ts +++ b/apps/backend/test/attendees.test.ts @@ -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'), }; diff --git a/apps/backend/test/classes.test.ts b/apps/backend/test/classes.test.ts new file mode 100644 index 0000000..1856a9b --- /dev/null +++ b/apps/backend/test/classes.test.ts @@ -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) => cb(db)); + execute = mock(async (cb: (tx: unknown) => Promise) => 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 = {}) { + 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(); + }); + }); +}); diff --git a/apps/backend/test/session-auth.test.ts b/apps/backend/test/session-auth.test.ts index 6662b01..931a1d1 100644 --- a/apps/backend/test/session-auth.test.ts +++ b/apps/backend/test/session-auth.test.ts @@ -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); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0de4b2c..f02ac70 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,8 +1,12 @@ import type { + AbsenceDetails, AttendeeDto, + AttendeeHistory, AttendeeList, + AttendeeStatus, BulkCreateAttendees, BulkCreateAttendeesResult, + ClassTodayList, ConnectPayment, ConnectPaymentResult, CreateAttendee, @@ -11,6 +15,7 @@ import type { CreateFirstGroupResult, CreateGroupResult, CreateGroupWaitlistEntry, + GroupAnalytics, GroupDto, GroupInviteInfoDto, GroupList, @@ -20,11 +25,19 @@ import type { InviteTokenResult, JoinGroupViaInvite, JoinGroupViaInviteResult, + MarkAttendance, + MarkAttendanceResult, + NotifyAbsence, + NotifyAbsenceResult, OnboardingStatusDto, ProblemDetails, PromoteGroupWaitlistEntryResult, + PublicNotifyAbsence, RemoveAttendeeResult, RemoveGroupWaitlistEntryResult, + SessionStudents, + StudentsAtRisk, + UpdateStudentStatusResult, } from '@gruperly/shared' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000' @@ -89,18 +102,18 @@ export const createGroup = (payload: CreateFirstGroup) => export const getGroups = () => apiFetch('/api/v1/groups') -export const getHomeSummary = () => { - let timeZone: string | undefined +function timeZoneParam(): string { try { - timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + return timeZone ? `?timeZone=${encodeURIComponent(timeZone)}` : '' } catch { - // Sin zona horaria disponible: el backend usa su valor por defecto. - timeZone = undefined + return '' } - const params = timeZone ? `?timeZone=${encodeURIComponent(timeZone)}` : '' - return apiFetch(`/api/v1/home${params}`) } +export const getHomeSummary = () => + apiFetch(`/api/v1/home${timeZoneParam()}`) + export const getGroup = (groupId: string) => apiFetch(`/api/v1/groups/${groupId}`) @@ -171,4 +184,50 @@ export const removeGroupAttendee = ( { method: 'DELETE', }, - ) \ No newline at end of file + ) + +// — Día a Día: clases y asistencia — + +export const getClassesToday = () => + apiFetch(`/api/v1/classes/today${timeZoneParam()}`) + +export const getSessionStudents = (sessionId: string) => + apiFetch(`/api/v1/classes/${sessionId}/students${timeZoneParam()}`) + +export const markAttendance = (sessionId: string, payload: MarkAttendance) => + apiFetch(`/api/v1/classes/${sessionId}/attendance`, { + method: 'POST', + body: JSON.stringify(payload), + }) + +export const notifyAbsence = (sessionId: string, payload: NotifyAbsence) => + apiFetch(`/api/v1/classes/${sessionId}/notify-absence`, { + method: 'POST', + body: JSON.stringify(payload), + }) + +export const getAbsenceDetails = (token: string) => + apiFetch(`/api/v1/classes/absence?token=${encodeURIComponent(token)}`) + +export const publicNotifyAbsence = (payload: PublicNotifyAbsence) => + apiFetch('/api/v1/classes/notify-absence', { + method: 'POST', + body: JSON.stringify(payload), + }) + +// — Analíticas y gestión de riesgo — + +export const getGroupAnalytics = (groupId: string) => + apiFetch(`/api/v1/groups/${groupId}/analytics`) + +export const getStudentsAtRisk = (groupId: string) => + apiFetch(`/api/v1/groups/${groupId}/students-at-risk`) + +export const getAttendeeHistory = (groupId: string, attendeeId: string) => + apiFetch(`/api/v1/groups/${groupId}/attendees/${attendeeId}/history`) + +export const updateAttendeeStatus = (attendeeId: string, status: AttendeeStatus) => + apiFetch(`/api/v1/students/${attendeeId}/status`, { + method: 'POST', + body: JSON.stringify({ status }), + }) \ No newline at end of file diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 7865cb8..8644dba 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -13,6 +13,9 @@ import { OnboardingView } from './routes/onboarding' import { GroupDetailView } from './routes/group-detail' import { CreateGroupView } from './routes/create-group' import { JoinGroupView } from './routes/join-group' +import { ClassAttendanceView } from './routes/class-attendance' +import { AbsenceNotifyView } from './routes/absence-notify' +import { AnalyticsView } from './routes/analytics' const rootRoute = createRootRoute({ component: () => , @@ -48,6 +51,13 @@ const joinGroupRoute = createRoute({ component: JoinGroupView, }) +// Página pública: el alumno avisa su ausencia con su token personal. +const absenceNotifyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/avisar-ausencia/$token', + component: AbsenceNotifyView, +}) + // Capa con la navegación de la app autenticada (Sidebar + BottomNav). // El guard redirige a /onboarding a quien no completó el onboarding. const appLayoutRoute = createRoute({ @@ -80,6 +90,12 @@ const groupDetailRoute = createRoute({ component: GroupDetailView, }) +const groupAnalyticsRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: '/groups/$groupId/analytics', + component: AnalyticsView, +}) + const paymentsRoute = createRoute({ getParentRoute: () => appLayoutRoute, path: '/payments', @@ -104,21 +120,30 @@ const profileRoute = createRoute({ component: ProfileView, }) +const classAttendanceRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: '/class/$sessionId/attendance', + component: ClassAttendanceView, +}) + const routeTree = rootRoute.addChildren([ loginRoute, signupRoute, verifyEmailRoute, onboardingRoute, joinGroupRoute, + absenceNotifyRoute, appLayoutRoute.addChildren([ indexRoute, groupsRoute, createGroupRoute, groupDetailRoute, + groupAnalyticsRoute, paymentsRoute, settingsRoute, securityRoute, profileRoute, + classAttendanceRoute, ]), ]) diff --git a/apps/web/src/routes/absence-notify.tsx b/apps/web/src/routes/absence-notify.tsx new file mode 100644 index 0000000..034babe --- /dev/null +++ b/apps/web/src/routes/absence-notify.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import { Link, useParams } from '@tanstack/react-router' +import { CalendarX2, CheckCircle2, Loader2, Moon, Sun } from 'lucide-react' +import { Badge, Button, useToast } from '../components/ui' +import { Logo } from '../components/brand' +import { useTheme } from '../context/ThemeProvider' +import { ApiError, getAbsenceDetails, publicNotifyAbsence } from '../lib/api' + +export function AbsenceNotifyView() { + const { token } = useParams({ strict: false }) as { token: string } + const { setTheme, isDark } = useTheme() + const toast = useToast() + + const [locallyNotified, setLocallyNotified] = useState([]) + + const detailsQuery = useQuery({ + queryKey: ['absence-details', token], + queryFn: () => getAbsenceDetails(token), + enabled: Boolean(token), + retry: 1, + }) + + const notifyMutation = useMutation({ + mutationFn: (sessionId: string) => + publicNotifyAbsence({ token, classSessionId: sessionId }), + onSuccess: (_data, sessionId) => { + setLocallyNotified((prev) => [...prev, sessionId]) + toast.success( + 'Tu profesor ya tiene registrado el aviso. Gracias por avisar.', + 'Ausencia avisada', + ) + }, + onError: (error: unknown) => { + if (error instanceof ApiError && error.problem?.status === 404) { + toast.error('Este enlace ya no es válido. Consultá a tu profesor.') + return + } + toast.error( + error instanceof Error ? error.message : 'No pudimos registrar el aviso.', + ) + }, + }) + + const details = detailsQuery.data + + return ( +
+
+ + + + +
+ +
+
+ {detailsQuery.isPending ? ( +
+ +

Cargando tus clases...

+
+ ) : null} + + {detailsQuery.isError || !details ? ( +
+

Enlace no válido

+

+ No pudimos identificarte con este enlace. Consultá con tu profesor para + solicitar uno nuevo. +

+
+ ) : null} + + {details ? ( +
+
+
+ +
+ + Aviso de ausencia + +

Hola, {details.fullName}

+

+ Grupo: {details.groupName} +

+
+ + {details.sessions.length === 0 ? ( +

+ No tenés clases programadas para hoy. Si necesitás avisar de todas formas, + contactá a tu profesor. +

+ ) : ( +
    + {details.sessions.map((session) => { + const time = new Date(session.startsAt).toLocaleTimeString('es-MX', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + const notified = + session.notified || locallyNotified.includes(session.sessionId) + + return ( +
  • +
    +

    + Hoy · {time} hs +

    +

    + {session.groupName} +

    +
    + {notified ? ( + + + Ya avisaste + + ) : ( + + )} +
  • + ) + })} +
+ )} + +

+ Si avisás con anticipación, tu cupo se libera para una clase de recuperación. +

+
+ ) : null} +
+
+ +
+ Gruperly — Gestión sencilla de cobros y grupos +
+
+ ) +} diff --git a/apps/web/src/routes/analytics.tsx b/apps/web/src/routes/analytics.tsx new file mode 100644 index 0000000..a13a87e --- /dev/null +++ b/apps/web/src/routes/analytics.tsx @@ -0,0 +1,472 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, useParams } from '@tanstack/react-router'; +import type { AttendeeStatus, StudentAtRiskDto } from '@gruperly/shared'; +import { + ArrowLeft, + CalendarCheck, + DoorOpen, + Loader2, + MessageCircle, + MoreVertical, + Pause, + Percent, + UserMinus, + Users, +} from 'lucide-react'; +import { useState, type ReactNode } from 'react'; +import { Avatar, Badge, Button, Modal, useToast } from '../components/ui'; +import { + getAttendeeHistory, + getGroupAnalytics, + getStudentsAtRisk, + updateAttendeeStatus, +} from '../lib/api'; +import { cn } from '../lib/utils'; + +type StatusAction = { student: StudentAtRiskDto; status: AttendeeStatus } | null; + +function KpiCard({ + icon, + label, + value, + hint, +}: { + icon: ReactNode; + label: string; + value: string; + hint?: string; +}) { + return ( +
+
+ {icon} +
+
+

{label}

+

{value}

+ {hint ?

{hint}

: null} +
+
+ ); +} + +function statusLabel(status: string | null): string { + switch (status) { + case 'PRESENT': + return 'Presente'; + case 'ABSENT': + return 'Ausente'; + case 'EXCUSED': + return 'Avisó ausencia'; + default: + return 'Sin registrar'; + } +} + +function dotClass(status: string | null): string { + switch (status) { + case 'PRESENT': + return 'bg-success'; + case 'ABSENT': + return 'bg-danger'; + case 'EXCUSED': + return 'bg-warning'; + default: + return 'bg-border'; + } +} + +function formatSessionDate(iso: string): string { + return new Date(iso).toLocaleDateString('es-MX', { + weekday: 'short', + day: 'numeric', + month: 'short', + }); +} + +export function AnalyticsView() { + const { groupId } = useParams({ strict: false }) as { groupId: string }; + const queryClient = useQueryClient(); + const toast = useToast(); + + const [historyStudent, setHistoryStudent] = useState(null); + const [statusAction, setStatusAction] = useState(null); + const [menuOpenFor, setMenuOpenFor] = useState(null); + + const analyticsQuery = useQuery({ + queryKey: ['group-analytics', groupId], + queryFn: () => getGroupAnalytics(groupId), + enabled: Boolean(groupId), + }); + + const riskQuery = useQuery({ + queryKey: ['students-at-risk', groupId], + queryFn: () => getStudentsAtRisk(groupId), + enabled: Boolean(groupId), + }); + + const historyQuery = useQuery({ + queryKey: ['attendee-history', groupId, historyStudent?.attendeeId], + queryFn: () => getAttendeeHistory(groupId, historyStudent!.attendeeId!), + enabled: Boolean(groupId && historyStudent), + }); + + const statusMutation = useMutation({ + mutationFn: (action: Exclude) => + updateAttendeeStatus(action.student.attendeeId, action.status), + onSuccess: (_result, action) => { + const name = action.student.fullName.split(' ')[0]; + toast.success( + action.status === 'DROPPED' + ? `${name} fue dado de baja del grupo. Su vacante quedó disponible.` + : `La vacante de ${name} quedó pausada.`, + ); + setStatusAction(null); + void queryClient.invalidateQueries({ queryKey: ['students-at-risk', groupId] }); + void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }); + void queryClient.invalidateQueries({ queryKey: ['group', groupId] }); + void queryClient.invalidateQueries({ queryKey: ['classes-today'] }); + void queryClient.invalidateQueries({ queryKey: ['home-summary'] }); + }, + onError: (err: Error) => { + toast.error(err.message || 'No pudimos actualizar el estado del alumno.'); + }, + }); + + const handleReengage = async (student: StudentAtRiskDto, groupName: string) => { + if (!student.phone) return; + const firstName = student.fullName.split(' ')[0] ?? student.fullName; + const message = + `¡Hola ${firstName}! 👋 Te extrañamos en ${groupName}. ` + + 'Notamos que no asististe a las últimas clases y queremos que vuelvas. ' + + 'Si tenés algún problema con tus horarios o tu membresía, escribinos y lo resolvemos juntos.'; + const waUrl = `https://wa.me/${student.phone.replace(/\D/g, '')}?text=${encodeURIComponent(message)}`; + try { + await navigator.clipboard.writeText(message); + toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.'); + } catch { + // Si clipboard falla, igual abrimos WhatsApp con el mensaje. + } + window.open(waUrl, '_blank', 'noopener,noreferrer'); + }; + + const groupName = riskQuery.data?.groupName; + + if (analyticsQuery.isPending || riskQuery.isPending) { + return ( +
+ +
+ ); + } + + if (analyticsQuery.isError || riskQuery.isError || !analyticsQuery.data) { + return ( +
+

No pudimos cargar las estadísticas

+

Verificá tu conexión e intentá de nuevo.

+ +
+ ); + } + + const analytics = analyticsQuery.data; + const atRisk = riskQuery.data?.data ?? []; + + return ( +
+ + + Volver a {groupName ?? 'el grupo'} + + +
+
+

Estadísticas

+

+ Asistencia y gestión de riesgo de {groupName ?? 'tu grupo'}. +

+
+ + Últimos 30 días + +
+ + {/* KPIs */} +
+ } + label="Asistencia promedio" + value={`${analytics.attendanceRate}%`} + hint="últimos 30 días" + /> + } + label="Asistencias del mes" + value={analytics.totalPresent.toLocaleString('es-MX')} + hint={`${analytics.totalClasses} clases dictadas`} + /> + } + label="Cupos recuperados" + value={analytics.recoveredSlots.toLocaleString('es-MX')} + hint="por ausencias avisadas" + /> +
+ + {/* Alumnos en riesgo */} +
+
+ +

Alumnos en riesgo

+ {atRisk.length > 0 ? ( + s.riskLevel === 'HIGH') ? 'danger' : 'warning'}> + {atRisk.length} {atRisk.length === 1 ? 'alumno' : 'alumnos'} + + ) : null} +
+

+ Alumnos con ausencias consecutivas o baja asistencia mensual. +

+ + {atRisk.length === 0 ? ( +
+

¡Sin alumnos en riesgo!

+

+ No hay ausencias consecutivas ni asistencia por debajo del 50%. +

+
+ ) : ( +
    + {atRisk.map((student) => ( +
  • +
    + + {student.riskLevel === 'HIGH' ? ( + + {student.consecutiveAbsences} faltas seguidas + + ) : ( + Baja asistencia + )} +
    + +
    + + +
    + + + {menuOpenFor === student.attendeeId ? ( + <> +
    setMenuOpenFor(null)} + aria-hidden="true" + /> +
    + + +
    + + ) : null} +
    +
    +
  • + ))} +
+ )} +
+ + {/* Modal: historial del alumno */} + setHistoryStudent(null)} + title={historyStudent?.fullName ?? 'Historial'} + description={historyStudent ? 'Historial de presentismo de las últimas clases.' : undefined} + maxWidth="md" + > + {historyStudent ? ( +
+ {historyQuery.isPending ? ( +
+ +
+ ) : historyQuery.isError || !historyQuery.data ? ( +
+ No pudimos cargar el historial de este alumno. +
+ ) : ( +
+
+ + {historyQuery.data.attendanceRate}% + + + Presentismo general ({historyQuery.data.sessions.length} clases) + +
+ +
    + {historyQuery.data.sessions.map((session) => ( +
  • +
  • + ))} +
+
+ )} +
+ ) : null} +
+ + {/* Modal: confirmar cambio de estado */} + setStatusAction(null)} + title={statusAction?.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'} + maxWidth="md" + > + {statusAction ? ( +
+

+ {statusAction.student.fullName}{' '} + {statusAction.status === 'DROPPED' ? ( + <> + dejará de asistir al grupo y {groupName}. Su + vacante quedará disponible para otro alumno, y + su historial de asistencia se conservará. + + ) : ( + <> + será pausado en{' '} + {groupName}. Su vacante quedará{' '} + libre y podés reactivarla en cualquier + momento. + + )} +

+
+ + +
+
+ ) : null} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/routes/class-attendance.tsx b/apps/web/src/routes/class-attendance.tsx new file mode 100644 index 0000000..a29af96 --- /dev/null +++ b/apps/web/src/routes/class-attendance.tsx @@ -0,0 +1,259 @@ +import { useEffect, useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link, useParams } from '@tanstack/react-router' +import type { SessionStudentDto } from '@gruperly/shared' +import { ArrowLeft, Check, Loader2, X } from 'lucide-react' +import { Avatar, Badge, Button, useToast } from '../components/ui' +import { getSessionStudents, markAttendance } from '../lib/api' +import { cn } from '../lib/utils' + +type AttendanceChoice = 'PRESENT' | 'ABSENT' + +function isLocked(student: SessionStudentDto): boolean { + return student.notifiedAbsence || student.attendanceStatus === 'EXCUSED' +} + +function PaymentBadge({ status }: { status: SessionStudentDto['paymentStatus'] }) { + return status === 'UP_TO_DATE' ? ( + Al día + ) : ( + Pendiente + ) +} + +function AttendanceToggle({ + choice, + disabled, + onToggle, + fullName, +}: { + choice: AttendanceChoice + disabled?: boolean + onToggle: () => void + fullName: string +}) { + const isPresent = choice === 'PRESENT' + + return ( + + ) +} + +function StudentRow({ + student, + choice, + onToggle, +}: { + student: SessionStudentDto + choice: AttendanceChoice + onToggle: () => void +}) { + const locked = isLocked(student) + + return ( +
  • + +
    +

    {student.fullName}

    +
    + + {locked ? Avisó ausencia : null} +
    +
    + +
  • + ) +} + +export function ClassAttendanceView() { + const { sessionId } = useParams({ strict: false }) as { sessionId: string } + const queryClient = useQueryClient() + const toast = useToast() + + const studentsQuery = useQuery({ + queryKey: ['class-students', sessionId], + queryFn: () => getSessionStudents(sessionId), + staleTime: 0, + }) + + const students = studentsQuery.data?.students + + // Estado local de la toma: todos parten PRESENT (1-tap para marcar ausentes). + // Alumnas con ausencia avisada quedan bloqueadas en EXCUSED. + const [choices, setChoices] = useState>({}) + + useEffect(() => { + if (!students) return + const initial: Record = {} + for (const student of students) { + if (isLocked(student)) continue + initial[student.attendeeId] = student.attendanceStatus === 'ABSENT' ? 'ABSENT' : 'PRESENT' + } + setChoices(initial) + }, [students]) + + const presentCount = useMemo( + () => + (students ?? []).filter( + (student) => !isLocked(student) && choices[student.attendeeId] === 'PRESENT', + ).length, + [students, choices], + ) + + const toggleStudent = (attendeeId: string) => { + setChoices((prev) => ({ + ...prev, + [attendeeId]: prev[attendeeId] === 'ABSENT' ? 'PRESENT' : 'ABSENT', + })) + } + + const saveMutation = useMutation({ + mutationFn: () => { + const records = (students ?? []) + .filter((student) => !isLocked(student)) + .map((student) => ({ + attendeeId: student.attendeeId, + status: choices[student.attendeeId] ?? 'PRESENT', + })) + return markAttendance(sessionId, { classSessionId: sessionId, records }) + }, + onSuccess: () => { + toast.success('Asistencia guardada correctamente.', 'Listo') + void queryClient.invalidateQueries({ queryKey: ['classes-today'] }) + void queryClient.invalidateQueries({ queryKey: ['class-students', sessionId] }) + }, + onError: (error: Error) => { + toast.error(error.message || 'No pudimos guardar la asistencia.') + }, + }) + + const sessionInfo = studentsQuery.data + const startTime = sessionInfo + ? new Date(sessionInfo.startsAt).toLocaleTimeString('es-MX', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + : null + + return ( +
    + + + Volver al inicio + + + {studentsQuery.isPending ? ( +
    + +
    + ) : null} + + {studentsQuery.isError ? ( +
    +

    No pudimos cargar la lista de alumnos.

    + +
    + ) : null} + + {sessionInfo ? ( + <> +
    +
    +
    +

    + {sessionInfo.groupName} +

    +

    + {startTime ? `Hoy · ${startTime} hs` : 'Clase de hoy'} +

    +
    + + {presentCount}/{sessionInfo.students.length} presentes + +
    +
    + + {sessionInfo.students.length === 0 ? ( +
    +

    Este grupo no tiene alumnos

    +

    + Agregá miembros desde la ficha del grupo para tomar asistencia. +

    +
    + ) : ( +
      + {sessionInfo.students.map((student) => ( + toggleStudent(student.attendeeId)} + /> + ))} +
    + )} + + {sessionInfo.students.length > 0 ? ( +
    + +
    + ) : null} + + ) : null} +
    + ) +} diff --git a/apps/web/src/routes/group-detail.tsx b/apps/web/src/routes/group-detail.tsx index 28de332..4c9278d 100644 --- a/apps/web/src/routes/group-detail.tsx +++ b/apps/web/src/routes/group-detail.tsx @@ -11,6 +11,7 @@ import type { } from '@gruperly/shared'; import { ArrowLeft, + BarChart3, CalendarClock, Check, ChevronRight, @@ -68,6 +69,9 @@ export function GroupDetailView() { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false); const [selectedAttendee, setSelectedAttendee] = useState(null); + const absenceNotifyUrl = selectedAttendee?.notifyToken + ? `${window.location.origin}/avisar-ausencia/${selectedAttendee.notifyToken}` + : null; const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick'); const [listSection, setListSection] = useState<'members' | 'waitlist'>('members'); const [attendeeToRemove, setAttendeeToRemove] = useState(null); @@ -507,6 +511,14 @@ export function GroupDetailView() {
    +
    + {absenceNotifyUrl ? ( +
    +

    + Link para avisar ausencias +

    +
    + + {absenceNotifyUrl} + + +
    + {selectedAttendee.phone ? ( + + + Enviar por WhatsApp + + ) : null} +
    + ) : null} +
    + +
    + + ) +} + function NextClassHero({ nextClass }: { nextClass: NextClassDto }) { const navigate = useNavigate() const hasSchedule = (nextClass.days?.length ?? 0) > 0 @@ -145,6 +212,12 @@ export function HomeView() { queryKey: ['home-summary'], queryFn: getHomeSummary, }) + const classesTodayQuery = useQuery({ + queryKey: ['classes-today'], + queryFn: getClassesToday, + staleTime: 60_000, + }) + const todayClasses = classesTodayQuery.data?.data ?? [] return (
    @@ -187,7 +260,13 @@ export function HomeView() { {summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? (
    - {summaryQuery.data.nextClass ? ( + {todayClasses.length > 0 ? ( +
    + {todayClasses.map((classItem) => ( + + ))} +
    + ) : summaryQuery.data.nextClass ? ( ) : (
    diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 224ef49..5573f7a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,7 @@ export * from './lib/problem-details.js'; export * from './lib/result.js'; +export * from './schemas/analytics.js'; +export * from './schemas/attendance.js'; export * from './schemas/attendees.js'; export * from './schemas/enums.js'; export * from './schemas/groups.js'; diff --git a/packages/shared/src/schemas/analytics.ts b/packages/shared/src/schemas/analytics.ts new file mode 100644 index 0000000..d04d7de --- /dev/null +++ b/packages/shared/src/schemas/analytics.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; +import { attendanceStatusSchema } from './attendance.js'; + +const isoDateTimeSchema = z.string().datetime(); + +export const attendeeStatusSchema = z.enum(['ACTIVE', 'PAUSED', 'DROPPED']); +export type AttendeeStatus = z.output; + +export const riskLevelSchema = z.enum(['HIGH', 'MEDIUM']); +export type RiskLevel = z.output; + +export const GroupAnalyticsSchema = z.object({ + attendanceRate: z.number().min(0).max(100), + totalPresent: z.number().int().min(0), + totalClasses: z.number().int().min(0), + recoveredSlots: z.number().int().min(0), +}); +export type GroupAnalytics = z.output; +export type GroupAnalyticsDto = GroupAnalytics; + +export const StudentAtRiskSchema = z.object({ + attendeeId: z.string(), + fullName: z.string(), + phone: z.string().nullable(), + riskLevel: riskLevelSchema, + consecutiveAbsences: z.number().int().min(0), + lastAttendedAt: isoDateTimeSchema.nullable(), + monthlyAttendanceRate: z.number().min(0).max(100), +}); +export type StudentAtRisk = z.output; +export type StudentAtRiskDto = StudentAtRisk; + +export const StudentsAtRiskSchema = z.object({ + groupName: z.string(), + data: z.array(StudentAtRiskSchema), +}); +export type StudentsAtRisk = z.output; +export type StudentsAtRiskDto = StudentsAtRisk; + +export const AttendanceHistoryItemSchema = z.object({ + classSessionId: z.string(), + startsAt: isoDateTimeSchema, + status: attendanceStatusSchema.nullable(), +}); +export type AttendanceHistoryItem = z.output; + +export const AttendeeHistorySchema = z.object({ + attendeeId: z.string(), + fullName: z.string(), + groupId: z.string(), + groupName: z.string(), + attendanceRate: z.number().min(0).max(100), + sessions: z.array(AttendanceHistoryItemSchema), +}); +export type AttendeeHistory = z.output; +export type AttendeeHistoryDto = AttendeeHistory; + +export const UpdateStudentStatusSchema = z.object({ + status: attendeeStatusSchema, +}); +export type UpdateStudentStatus = z.output; + +export const UpdateStudentStatusResultSchema = z.object({ + attendeeId: z.string(), + status: attendeeStatusSchema, +}); +export type UpdateStudentStatusResult = z.output; \ No newline at end of file diff --git a/packages/shared/src/schemas/attendance.ts b/packages/shared/src/schemas/attendance.ts new file mode 100644 index 0000000..3e5e376 --- /dev/null +++ b/packages/shared/src/schemas/attendance.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; +import { timeZoneSchema } from './home.js'; + +const isoDateTimeSchema = z.string().datetime(); + +export const attendanceStatusSchema = z.enum(['PRESENT', 'ABSENT', 'EXCUSED']); +export type AttendanceStatus = z.output; + +export const attendeePaymentStatusSchema = z.enum(['UP_TO_DATE', 'PENDING']); +export type AttendeePaymentStatus = z.output; + +export const MarkAttendanceRecordSchema = z.object({ + attendeeId: z.string().min(1), + status: z.enum(['PRESENT', 'ABSENT']), +}); +export type MarkAttendanceRecord = z.output; + +export const MarkAttendanceSchema = z.object({ + classSessionId: z.string().min(1), + records: z.array(MarkAttendanceRecordSchema).min(1, 'Debes enviar al menos un registro de asistencia'), +}); +export type MarkAttendance = z.output; + +export const MarkAttendanceResultSchema = z.object({ + classSessionId: z.string(), + marked: z.number().int().min(0), +}); +export type MarkAttendanceResult = z.output; + +export const NotifyAbsenceSchema = z.object({ + attendeeId: z.string().min(1), + classSessionId: z.string().min(1), +}); +export type NotifyAbsence = z.output; + +export const PublicNotifyAbsenceSchema = z.object({ + token: z.string().min(1), + classSessionId: z.string().min(1), +}); +export type PublicNotifyAbsence = z.output; + +export const NotifyAbsenceResultSchema = z.object({ + classSessionId: z.string(), + notified: z.boolean(), + slotReleased: z.boolean(), +}); +export type NotifyAbsenceResult = z.output; + +export const AbsenceQuerySchema = z.object({ + token: z.string().min(1), +}); +export type AbsenceQuery = z.output; + +export const ClassTodaySchema = z.object({ + sessionId: z.string(), + groupId: z.string(), + groupName: z.string(), + startsAt: isoDateTimeSchema, + enrolledCount: z.number().int().min(0), + capacity: z.number().int().min(1).nullable(), + availableSlots: z.number().int().min(0).nullable(), + hasAttendance: z.boolean(), +}); +export type ClassToday = z.output; +export type ClassTodayDto = ClassToday; + +export const ClassTodayListSchema = z.object({ + data: z.array(ClassTodaySchema), +}); +export type ClassTodayList = z.output; + +export const TodayClassesQuerySchema = z.object({ + timeZone: timeZoneSchema.optional(), +}); +export type TodayClassesQuery = z.output; + +export const SessionStudentSchema = z.object({ + attendeeId: z.string(), + fullName: z.string(), + paymentStatus: attendeePaymentStatusSchema, + attendanceStatus: attendanceStatusSchema.nullable(), + notifiedAbsence: z.boolean(), +}); +export type SessionStudent = z.output; +export type SessionStudentDto = SessionStudent; + +export const SessionStudentsSchema = z.object({ + sessionId: z.string(), + groupId: z.string(), + groupName: z.string(), + startsAt: isoDateTimeSchema, + students: z.array(SessionStudentSchema), +}); +export type SessionStudents = z.output; + +export const AbsenceSessionSchema = z.object({ + sessionId: z.string(), + groupName: z.string(), + startsAt: isoDateTimeSchema, + notified: z.boolean(), + attendanceStatus: attendanceStatusSchema.nullable(), +}); +export type AbsenceSession = z.output; + +export const AbsenceDetailsSchema = z.object({ + attendeeId: z.string(), + fullName: z.string(), + groupName: z.string(), + sessions: z.array(AbsenceSessionSchema), +}); +export type AbsenceDetails = z.output; diff --git a/packages/shared/src/schemas/attendees.ts b/packages/shared/src/schemas/attendees.ts index 9886c9d..5265dbe 100644 --- a/packages/shared/src/schemas/attendees.ts +++ b/packages/shared/src/schemas/attendees.ts @@ -16,6 +16,7 @@ export const AttendeeDtoSchema = z.object({ guardianName: z.string().nullable(), guardianPhone: z.string().nullable(), notes: z.string().nullable(), + notifyToken: z.string().nullable().optional(), createdAt: isoDateTimeSchema, updatedAt: isoDateTimeSchema, }); diff --git a/packages/shared/src/schemas/home.ts b/packages/shared/src/schemas/home.ts index dcada4f..4af7e4b 100644 --- a/packages/shared/src/schemas/home.ts +++ b/packages/shared/src/schemas/home.ts @@ -15,6 +15,8 @@ const timeZoneSchema = z } }, 'Zona horaria IANA inválida'); +export { timeZoneSchema }; + export const HomeStatsSchema = z.object({ groups: z.number().int().nonnegative(), attendees: z.number().int().nonnegative(),