Compare commits

12 Commits

Author SHA1 Message Date
Jose Selesan
e736ed3547 Updated spanish routes to english 2026-09-25 10:10:28 -03:00
Jose Selesan
94c353f4f2 Added routes 2026-09-25 10:05:56 -03:00
Jose Selesan
336e1af523 Migrate to file based routing on frontendf 2026-09-25 10:05:47 -03:00
Jose Selesan
efde1aaf33 feat: implement groups risk overview feature with analytics and routes 2026-09-24 20:45:11 -03:00
Jose Selesan
2e184845e1 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.
2026-09-23 16:24:45 -03:00
a363e9e8fc Merge pull request 'feat(home): implement home summary feature with routes and use case' (#4) from feat/home-screen into development
Reviewed-on: #4
2026-09-23 13:35:59 +00:00
Jose Selesan
f1e0fc5aae feat(home): implement home summary feature with routes and use case
- Added home routes and corresponding route handler for fetching home summary.
- Created GetHomeSummary use case to aggregate user data including groups, attendees, and upcoming payments.
- Introduced HomeSummaryDto and related schemas for data validation.
- Implemented nextClassOccurrence function to determine the next class based on user timezone.
- Added tests for home summary functionality and next class occurrence logic.
2026-09-23 10:35:24 -03:00
17dca1ad89 Merge pull request 'feat/waiting-list' (#3) from feat/waiting-list into development
Reviewed-on: #3
2026-09-23 12:36:46 +00:00
Jose Selesan
c1b99804b3 Removed "Back" link in create group page 2026-09-23 09:28:29 -03:00
Jose Selesan
5572ef0869 feat(groups): implement group creation functionality and refactor related components
- Removed organization dependency from group creation logic.
- Introduced new route and use-case for creating groups.
- Refactored form handling for group creation into a reusable GroupForm component.
- Updated API calls to support new group creation endpoint.
- Adjusted tests to reflect changes in group creation logic and validation.
- Removed obsolete organizations route and related components.
- Updated breadcrumb navigation and routing for group management.
2026-09-23 09:19:52 -03:00
Jose Selesan
fc30927b8b feat: manage waitlist and remove group members
- Add group-waitlist module (list/promote/remove entries)
- Remove attendee with optional atomic promotion from waitlist
- Block removal when attendee has payments (attendee_has_payments)
- Waitlist detail modal matching members UI; optimistic add-to-waitlist
- Responsive tweaks for the members/waitlist tabs
2026-09-22 17:04:41 -03:00
Jose Selesan
785d54df7e feat: add group waitlist functionality
- Introduced GroupWaitlistEntry model in Prisma schema to manage waitlisted users for groups.
- Implemented API route to add users to the group waitlist with validation.
- Enhanced attendee creation logic to handle group capacity and waitlisting scenarios.
- Added new problem builders for handling waitlist-related errors.
- Updated frontend to support waitlist interactions, including modals for capacity warnings.
- Created tests for waitlist functionality, ensuring proper handling of full groups and existing waitlist entries.
2026-09-22 16:28:54 -03:00
199 changed files with 10590 additions and 2406 deletions

View File

@@ -31,7 +31,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
- `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`. - `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
- `src/logger.ts` — pino + pino-pretty. - `src/logger.ts` — pino + pino-pretty.
- Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `attendees`, `payments`, `waitlist` (listados paginados `{ data, pagination }`). - Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `attendees`, `payments`, `waitlist` (listados paginados `{ data, pagination }`).
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`); el backend llama a `/api/v1/groups/from-organization` (`src/routes/organizations.tsx`). - `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`).
- `packages/shared` — Esquemas Zod (v3.24) + tipos + `Result` + Problem Details. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo); se resuelve vía el symlink de bun en `node_modules` (`@gruperly/shared` no está en `paths` de los tsconfig). El `paths` de los tsconfig solo mapea `@/*` → `src/*` y `@generated/*` → `generated/*`. - `packages/shared` — Esquemas Zod (v3.24) + tipos + `Result` + Problem Details. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo); se resuelve vía el symlink de bun en `node_modules` (`@gruperly/shared` no está en `paths` de los tsconfig). El `paths` de los tsconfig solo mapea `@/*` → `src/*` y `@generated/*` → `generated/*`.
- `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete). - `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete).
@@ -48,10 +48,12 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
- **Base de datos**: todas las tablas usan **snake_case** vía `@@map` en `prisma/models/*.prisma` (p. ej. `User` → `users`, `WaitlistEntry` → `waitlist_entries`). Al agregar un modelo nuevo, incluir siempre `@@map("nombre_tabla")`. - **Base de datos**: todas las tablas usan **snake_case** vía `@@map` en `prisma/models/*.prisma` (p. ej. `User` → `users`, `WaitlistEntry` → `waitlist_entries`). Al agregar un modelo nuevo, incluir siempre `@@map("nombre_tabla")`.
- **API** responde errores en **Problem Details RFC 7807** (`application/problem+json`) y resultados como `{ data, pagination }`; los "use cases" devuelven `Result` y nunca lanzan excepciones de dominio. - **API** responde errores en **Problem Details RFC 7807** (`application/problem+json`) y resultados como `{ data, pagination }`; los "use cases" devuelven `Result` y nunca lanzan excepciones de dominio.
- **Tailwind v4** con tokens en `@theme` dentro de `apps/web/src/index.css` (p. ej. `--color-accent: #1e90ff`). Se usan como clases auto-generadas: `text-accent`, `bg-success-soft`, etc. Radius por defecto `0.75rem` (`rounded-xl`). - **Tailwind v4** con tokens en `@theme` dentro de `apps/web/src/index.css` (p. ej. `--color-accent: #1e90ff`). Se usan como clases auto-generadas: `text-accent`, `bg-success-soft`, etc. Radius por defecto `0.75rem` (`rounded-xl`).
- **Router NO es file-based** (aunque `stack.md` lo diga): las rutas se declaran manualmente en `apps/web/src/router.tsx` con `createRoute` + `addChildren` y se registran vía module augmentation. **Cada vista nueva debe añadirse ahí.** - **Router ES file-based** (TanStack Router + `@tanstack/router-plugin`): las rutas viven en `apps/web/src/routes/` y solo contienen definiciones (`createFileRoute` + `component`), sin lógica de UI. `src/router.tsx` solo crea el router con el `routeTree` importado de `src/routeTree.gen.ts` (**archivo generado por el plugin en build/dev, versionado en git**: si agregás/renombrás una ruta, corré `bun --filter @gruperly/web build` para regenerarlo). Las rutas autenticadas cuelgan del layout pathless `_authenticated.tsx` (`AppLayoutGuard` + `RootLayout` con `<Outlet />`).
- **Vistas en `apps/web/src/features/<feature>/`**: un componente por archivo, nombre de archivo en PascalCase igual al componente, imports relativos (sin alias). El estado compartido padre-hijo vive en un `*Provider.tsx` del feature (contexto + queries/mutations de React Query) que se monta en el route file; los componentes hijos consumen `use<Feature>()` y las forms locales mantienen su propio estado.
- Nav (Inicio/Grupos/Cobros/Ajustes) vive en `apps/web/src/components/layout/nav-items.ts`; es la fuente única para `BottomNav` (móvil) y `Sidebar` (desktop) — no dupliques la lista. - Nav (Inicio/Grupos/Cobros/Ajustes) vive en `apps/web/src/components/layout/nav-items.ts`; es la fuente única para `BottomNav` (móvil) y `Sidebar` (desktop) — no dupliques la lista.
- **UI**: primitivos propios en `apps/web/src/components/ui/` (avatar, button, badge) más helper `cn()` en `src/lib/utils.ts` (clsx + tailwind-merge). Aunque `stack.md` mencione Shadcn, **todavía no está instalado** (sin Radix); úsalos directos. - **UI**: primitivos propios en `apps/web/src/components/ui/` (avatar, button, badge) más helper `cn()` en `src/lib/utils.ts` (clsx + tailwind-merge). Aunque `stack.md` mencione Shadcn, **todavía no está instalado** (sin Radix); úsalos directos.
- Layout mobile-first: `RootLayout` usa columna `max-w-md` en móvil y dos columnas (Sidebar + contenido `max-w-6xl`) en `lg+`. - Layout mobile-first: `RootLayout` usa columna `max-w-md` en móvil y dos columnas (Sidebar + contenido `max-w-6xl`) en `lg+`.
- **Sin "volver atrás" en mobile**: en ninguna vista se muestra un link/botón de navegación a la página anterior cuando se ve en mobile (se apoya en el gesto de back del dispositivo). Los links de "volver" solo en desktop: usar `hidden ... lg:inline-flex` (p. ej. `create-group`, `group-detail`) o `hidden lg:flex` (`Breadcrumb`). No confundir con botones "Volver" de pasos dentro de un wizard/formulario: esos sí se mantienen.
## Fuentes de contexto ## Fuentes de contexto

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "group_waitlist_entries" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT,
"notes" TEXT,
"status" "WaitlistStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "group_waitlist_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "group_waitlist_entries_groupId_idx" ON "group_waitlist_entries"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "group_waitlist_entries_groupId_phone_key" ON "group_waitlist_entries"("groupId", "phone");
-- AddForeignKey
ALTER TABLE "group_waitlist_entries" ADD CONSTRAINT "group_waitlist_entries_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,8 @@ model Group {
members GroupMember[] members GroupMember[]
attendees Attendee[] attendees Attendee[]
payments Payment[] payments Payment[]
waitlist GroupWaitlistEntry[]
classSessions ClassSession[]
@@map("groups") @@map("groups")
} }
@@ -58,8 +60,14 @@ enum Role {
MEMBER MEMBER
} }
enum AttendeeStatus {
ACTIVE
PAUSED
DROPPED
}
model Attendee { model Attendee {
id String @id @default(cuid()) id String @id @default(cuid())
groupId String groupId String
fullName String fullName String
email String? email String?
@@ -67,11 +75,15 @@ model Attendee {
guardianName String? guardianName String?
guardianPhone String? guardianPhone String?
notes String? notes String?
createdAt DateTime @default(now()) notifyToken String? @unique @default(cuid())
updatedAt DateTime @updatedAt status AttendeeStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
payments Payment[] payments Payment[]
attendances Attendance[]
slotReleases SlotRelease[]
@@index([groupId]) @@index([groupId])
@@map("attendees") @@map("attendees")
@@ -120,6 +132,24 @@ model WaitlistEntry {
@@map("waitlist_entries") @@map("waitlist_entries")
} }
model GroupWaitlistEntry {
id String @id @default(cuid())
groupId String
fullName String
phone String
email String?
notes String?
status WaitlistStatus @default(PENDING) // PENDING, INVITED, JOINED, DECLINED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([groupId, phone])
@@index([groupId])
@@map("group_waitlist_entries")
}
enum WaitlistStatus { enum WaitlistStatus {
PENDING PENDING
INVITED INVITED

View File

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

View File

@@ -47,23 +47,6 @@ export function forbiddenProblem(params: {
}; };
} }
export function organizationNotFoundProblem(id: string): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/organization-not-found`,
title: 'Not Found',
status: 404,
detail: `Organization ${id} was not found.`,
code: 'organization_not_found',
};
}
export function groupOwnerRequiredProblem(): ProblemDetails {
return forbiddenProblem({
detail: 'Only the organization owner can create the group.',
code: 'group_owner_required',
});
}
export function databaseUnavailableProblem(params?: { export function databaseUnavailableProblem(params?: {
instance?: string; instance?: string;
}): ProblemDetails { }): ProblemDetails {
@@ -100,4 +83,27 @@ export function onboardingAlreadyCompletedProblem(): ProblemDetails {
detail: 'Ya completaste el onboarding de Gruperly.', detail: 'Ya completaste el onboarding de Gruperly.',
code: 'onboarding_already_completed', code: 'onboarding_already_completed',
}); });
}
export function capacityReachedProblem(capacity: number | null): ProblemDetails {
return conflictProblem({
detail: capacity
? `El grupo alcanzó su cupo máximo de ${capacity} miembros.`
: 'El grupo alcanzó su cupo máximo de miembros.',
code: 'group_capacity_reached',
});
}
export function alreadyWaitlistedProblem(): ProblemDetails {
return conflictProblem({
detail: 'Este número de teléfono ya está en la lista de espera del grupo.',
code: 'already_waitlisted',
});
}
export function attendeeHasPaymentsProblem(): ProblemDetails {
return conflictProblem({
detail: 'Este miembro tiene cobros asociados. Gestiona o cancela sus cobros antes de quitarlo del grupo.',
code: 'attendee_has_payments',
});
} }

View File

@@ -27,6 +27,14 @@ export function isPublicApiRequest(method: string, path: string): boolean {
const normalizedPath = normalizePath(path); 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 ( return (
normalizedPath === '/api/v1/health' normalizedPath === '/api/v1/health'
|| matchesPublicPrefix(normalizedPath, '/api/v1/auth') || matchesPublicPrefix(normalizedPath, '/api/v1/auth')
@@ -40,5 +48,8 @@ function matchesPublicPrefix(path: string, prefix: string): boolean {
} }
function normalizePath(path: string): string { function normalizePath(path: string): string {
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; const withoutQuery = path.split('?')[0]?.split('#')[0] ?? path;
return withoutQuery.length > 1 && withoutQuery.endsWith('/')
? withoutQuery.slice(0, -1)
: withoutQuery;
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,136 @@
import { Prisma } from '@generated/prisma/client';
import type {
AttendanceStatus,
GroupRiskLevel,
GroupRiskSummary,
GroupsRiskOverview,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
import { buildGroupWhereForUser } from '@/modules/groups/lib';
import {
classifyRisk,
monthlyAttendanceRate,
trailingAbsentStreak,
} from '../../lib/risk';
import { ANALYTICS_WINDOW_MS } from '../get-overview/use-case';
type GetGroupsRiskOverviewDeps = {
db?: {
group: Prisma.GroupDelegate;
classSession: Prisma.ClassSessionDelegate;
attendee: Prisma.AttendeeDelegate;
attendance: Prisma.AttendanceDelegate;
};
now?: Date;
};
type GroupRow = { id: string; name: string };
type AttendeeRow = { id: string; groupId: string };
type SessionRow = { id: string; groupId: string };
type AttendanceRow = {
attendeeId: string;
status: AttendanceStatus;
classSession: { startsAt: Date };
};
// Resumen de semáforo de riesgo por grupo para la lista de grupos.
// Un grupo es HIGH si tiene al menos un alumno en riesgo alto, MEDIUM si
// alguno en riesgo moderado y NONE en caso contrario.
export class GetGroupsRiskOverview {
constructor(private readonly deps: GetGroupsRiskOverviewDeps = {}) {}
async execute(userId: string): Promise<Result<GroupsRiskOverview, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS);
const groups = (await db.group.findMany({
where: buildGroupWhereForUser(userId) as Prisma.GroupWhereInput,
select: { id: true, name: true },
})) as GroupRow[];
if (groups.length === 0) {
return ok({ items: [] });
}
const groupIds = groups.map((group) => group.id);
const attendeeGroup = new Map<string, string>();
const sessionCountByGroup = new Map<string, number>();
const [attendees, sessions, attendances] = await Promise.all([
db.attendee.findMany({
where: { groupId: { in: groupIds }, status: 'ACTIVE' },
select: { id: true, groupId: true },
}) as Promise<AttendeeRow[]>,
db.classSession.findMany({
where: { groupId: { in: groupIds }, startsAt: { gte: since } },
select: { id: true, groupId: true },
}) as Promise<SessionRow[]>,
db.attendance.findMany({
where: { attendee: { groupId: { in: groupIds } } },
select: {
attendeeId: true,
status: true,
classSession: { select: { startsAt: true } },
},
orderBy: { classSession: { startsAt: 'desc' } },
}) as Promise<AttendanceRow[]>,
]);
for (const attendee of attendees) {
attendeeGroup.set(attendee.id, attendee.groupId);
}
for (const session of sessions) {
sessionCountByGroup.set(session.groupId, (sessionCountByGroup.get(session.groupId) ?? 0) + 1);
}
const streamByAttendee = groupAttendanceByAttendee(attendances);
const worstRiskByGroup = new Map<string, GroupRiskLevel>();
for (const attendee of attendees) {
const groupId = attendeeGroup.get(attendee.id)!;
const stream = (streamByAttendee.get(attendee.id) ?? []).map((row) => ({
status: row.status,
occurredAt: row.classSession.startsAt,
}));
const streak = trailingAbsentStreak(stream);
const monthlyRate = monthlyAttendanceRate(
stream.filter((entry) => entry.occurredAt.getTime() >= since.getTime()),
sessionCountByGroup.get(groupId) ?? 0,
);
const riskLevel = classifyRisk(streak, monthlyRate);
if (!riskLevel) {
continue;
}
const current = worstRiskByGroup.get(groupId) ?? 'NONE';
if (
riskLevel === 'HIGH' ||
(riskLevel === 'MEDIUM' && current === 'NONE')
) {
worstRiskByGroup.set(groupId, riskLevel);
}
}
const items: GroupRiskSummary[] = groups.map((group) => ({
groupId: group.id,
groupName: group.name,
riskLevel: worstRiskByGroup.get(group.id) ?? 'NONE',
}));
return ok({ items });
}
}
function groupAttendanceByAttendee(rows: AttendanceRow[]): Map<string, AttendanceRow[]> {
const byAttendee = new Map<string, AttendanceRow[]>();
for (const row of rows) {
const list = byAttendee.get(row.attendeeId) ?? [];
list.push(row);
byAttendee.set(row.attendeeId, list);
}
return byAttendee;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export { default as groupsRiskOverviewRoute } from './features/get-groups-risk-overview/route';
export { default as studentStatusRoutes } from './features/update-attendee-status/route';
export { default as analyticsRoutes } from './routes';

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,112 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { CreateGroupWaitlistEntry, GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import {
alreadyWaitlistedProblem,
conflictProblem,
noGroupAccessProblem,
notFoundResourceProblem,
} from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { normalizePhone } from '../../lib/helpers';
type AddToGroupWaitlistDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
};
type GroupWaitlistRecord = {
id: string;
groupId: string;
fullName: string;
phone: string;
email: string | null;
notes: string | null;
status: GroupWaitlistEntryDto['status'];
createdAt: Date;
updatedAt: Date;
};
function toGroupWaitlistEntryDto(record: GroupWaitlistRecord): GroupWaitlistEntryDto {
return {
id: record.id,
groupId: record.groupId,
fullName: record.fullName,
phone: record.phone,
email: record.email,
notes: record.notes,
status: record.status,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
export class AddToGroupWaitlist {
constructor(private readonly deps: AddToGroupWaitlistDeps = {}) {}
async execute(
groupId: string,
userId: string,
payload: CreateGroupWaitlistEntry,
): Promise<Result<GroupWaitlistEntryDto, ProblemDetails>> {
const db = this.deps.db ?? prisma;
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());
}
const phone = normalizePhone(payload.phone);
const existingAttendee = await db.attendee.findFirst({
where: {
groupId,
OR: [
{ phone },
{ phone: payload.phone.trim() },
],
},
});
if (existingAttendee) {
return err(
conflictProblem({
detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.',
code: 'attendee_already_registered',
}),
);
}
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
where: { groupId, phone },
});
if (existingWaitlistEntry) {
return err(alreadyWaitlistedProblem());
}
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
const entry = await db.groupWaitlistEntry.create({
data: {
groupId,
fullName,
phone,
email: payload.email?.trim() || null,
notes: payload.notes?.trim() || null,
},
});
return ok(toGroupWaitlistEntryDto(entry as unknown as GroupWaitlistRecord));
}
}

View File

@@ -13,8 +13,9 @@ route.post('/:groupId/attendees', validate.json(CreateAttendeeSchema), async (c)
} }
const groupId = c.req.param('groupId'); const groupId = c.req.param('groupId');
const body = c.req.valid('json') as CreateAttendee; const body = c.req.valid('json') as CreateAttendee;
const allowOverflow = c.req.query('allowOverflow') === 'true';
const useCase = new CreateAttendeeUseCase(); const useCase = new CreateAttendeeUseCase();
const result = await useCase.execute(groupId, user.id, body); const result = await useCase.execute(groupId, user.id, body, { allowOverflow });
return resultJson(c, result, { status: 201 }); return resultJson(c, result, { status: 201 });
}); });

View File

@@ -1,12 +1,22 @@
import type { PrismaClient } from '@generated/prisma/client'; import type { PrismaClient } from '@generated/prisma/client';
import type { AttendeeDto, CreateAttendee, ProblemDetails, Result } from '@gruperly/shared'; import type { CreateAttendee, CreateAttendeeResult, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared'; import { err, ok } from '@gruperly/shared';
import { conflictProblem, noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders'; import {
alreadyWaitlistedProblem,
capacityReachedProblem,
conflictProblem,
noGroupAccessProblem,
notFoundResourceProblem,
} from '@/http/problem-builders';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers'; import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
type CreateAttendeeDeps = { type CreateAttendeeDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee'>; db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
};
type CreateAttendeeOptions = {
allowOverflow?: boolean;
}; };
export class CreateAttendeeUseCase { export class CreateAttendeeUseCase {
@@ -16,7 +26,8 @@ export class CreateAttendeeUseCase {
groupId: string, groupId: string,
userId: string, userId: string,
payload: CreateAttendee, payload: CreateAttendee,
): Promise<Result<AttendeeDto, ProblemDetails>> { options: CreateAttendeeOptions = {},
): Promise<Result<CreateAttendeeResult, ProblemDetails>> {
const db = this.deps.db ?? prisma; const db = this.deps.db ?? prisma;
const group = await db.group.findUnique({ const group = await db.group.findUnique({
@@ -57,6 +68,44 @@ export class CreateAttendeeUseCase {
} }
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim(); const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
if (group.capacity !== null) {
const currentCount = await db.attendee.count({
where: { groupId, status: 'ACTIVE' },
});
if (currentCount >= group.capacity) {
if (isOwner && !options.allowOverflow) {
return err(capacityReachedProblem(group.capacity));
}
if (!isOwner) {
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
where: { groupId, phone },
});
if (existingWaitlistEntry) {
return err(alreadyWaitlistedProblem());
}
await db.groupWaitlistEntry.create({
data: {
groupId,
fullName,
phone,
email: payload.email?.trim() || null,
notes: payload.notes?.trim() || null,
},
});
return ok({
outcome: 'waitlisted',
message: `${fullName} fue agregado a la lista de espera del grupo.`,
});
}
}
}
const attendee = await db.attendee.create({ const attendee = await db.attendee.create({
data: { data: {
groupId, groupId,
@@ -67,6 +116,9 @@ export class CreateAttendeeUseCase {
}, },
}); });
return ok(toAttendeeDto(attendee as unknown as AttendeeRecord)); return ok({
outcome: 'created',
attendee: toAttendeeDto(attendee as unknown as AttendeeRecord),
});
} }
} }

View File

@@ -0,0 +1,24 @@
import { type RemoveAttendeeQuery, RemoveAttendeeQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { RemoveAttendee } from './use-case';
const route = new Hono();
route.delete('/:groupId/attendees/:attendeeId', validate.query(RemoveAttendeeQuerySchema), 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 query = c.req.valid('query') as RemoveAttendeeQuery;
const useCase = new RemoveAttendee();
const result = await useCase.execute(groupId, attendeeId, user.id, {
promoteFromWaitlist: query.promoteFromWaitlist,
});
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,73 @@
import type { Prisma, PrismaClient } from '@generated/prisma/client';
import type { ProblemDetails, RemoveAttendeeResult, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { attendeeHasPaymentsProblem, notFoundResourceProblem } from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import {
findGroupOwnedByUser,
toGroupWaitlistEntryDto,
} from '@/modules/group-waitlist/lib/helpers';
import { promoteFirstPendingWaitlistEntry } from '@/modules/group-waitlist/lib/promote';
type RemoveAttendeeDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee' | 'payment'>;
unitOfWork?: UnitOfWork;
};
type RemoveAttendeeOptions = {
promoteFromWaitlist?: boolean;
};
export class RemoveAttendee {
constructor(private readonly deps: RemoveAttendeeDeps = {}) {}
async execute(
groupId: string,
attendeeId: string,
userId: string,
options: RemoveAttendeeOptions = {},
): Promise<Result<RemoveAttendeeResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
if (!groupResult.ok) {
return groupResult;
}
const group = groupResult.value;
const attendee = await db.attendee.findFirst({ where: { id: attendeeId, groupId } });
if (!attendee) {
return err(notFoundResourceProblem('Attendee', attendeeId));
}
const paymentsCount = await db.payment.count({ where: { attendeeId } });
if (paymentsCount > 0) {
return err(attendeeHasPaymentsProblem());
}
return unitOfWork.executeResult(
async (tx: Prisma.TransactionClient): Promise<Result<RemoveAttendeeResult, ProblemDetails>> => {
await tx.attendee.delete({ where: { id: attendeeId } });
if (!options.promoteFromWaitlist) {
return ok({ removedAttendeeId: attendeeId, promoted: null });
}
const promoteResult = await promoteFirstPendingWaitlistEntry(tx, group);
if (!promoteResult.ok) {
return err(promoteResult.error);
}
if (!promoteResult.value) {
return ok({ removedAttendeeId: attendeeId, promoted: null });
}
await tx.groupWaitlistEntry.delete({ where: { id: promoteResult.value.entry.id } });
return ok({
removedAttendeeId: attendeeId,
promoted: toGroupWaitlistEntryDto(promoteResult.value.entry),
});
},
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,91 @@
import { AttendanceStatus } from '@generated/prisma/client';
import type { NotifyAbsenceResult, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { notFoundResourceProblem, validationProblem } from '@/http/problem-builders';
import type { UnitOfWork } from '@/lib/prisma';
import { type ClassesDb, sessionNotFound } from './helpers';
type RegisterAbsenceDeps = {
db: Pick<ClassesDb, 'classSession' | 'attendee'>;
unitOfWork: UnitOfWork;
};
type RegisterAbsenceInput = {
attendeeId: string;
classSessionId: string;
now: Date;
releaseHours: number;
};
// Núcleo compartido del aviso de ausencia (profesor autenticado y alumno con token):
// - Marca la asistencia como EXCUSED (upsert idempotente).
// - Si la anticipación alcanza `releaseHours`, crea/conserva un SlotRelease para liberar el cupo.
export async function registerAbsence(
deps: RegisterAbsenceDeps,
input: RegisterAbsenceInput,
): Promise<Result<NotifyAbsenceResult, ProblemDetails>> {
const { db, unitOfWork } = deps;
const { attendeeId, classSessionId, now, releaseHours } = input;
const session = await db.classSession.findUnique({ where: { id: classSessionId } });
if (!session) {
return err(sessionNotFound(classSessionId));
}
const attendee = await db.attendee.findUnique({ where: { id: attendeeId } });
if (!attendee) {
return err(notFoundResourceProblem('Attendee', attendeeId));
}
if (attendee.groupId !== session.groupId) {
return err(
validationProblem({ detail: 'El alumno no pertenece al grupo de esta clase.' }),
);
}
const anticipationMs = session.startsAt.getTime() - now.getTime();
const slotReleased = anticipationMs >= releaseHours * 60 * 60 * 1000;
return unitOfWork.executeResult(async (tx) => {
await tx.attendance.upsert({
where: {
attendeeId_classSessionId: {
attendeeId,
classSessionId,
},
},
create: {
attendeeId,
classSessionId,
status: AttendanceStatus.EXCUSED,
markedAt: now,
},
update: {
status: AttendanceStatus.EXCUSED,
markedAt: now,
},
});
if (slotReleased) {
await tx.slotRelease.upsert({
where: {
attendeeId_classSessionId: {
attendeeId,
classSessionId,
},
},
create: {
attendeeId,
classSessionId,
isClaimed: false,
},
update: {},
});
}
return ok({
classSessionId,
notified: true,
slotReleased,
});
});
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { GroupWaitlistList, GroupWaitlistQuery, ProblemDetails, Result } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma';
import { findGroupForUser, type GroupWaitlistEntryRecord, toGroupWaitlistEntryDto } from '../../lib/helpers';
type ListGroupWaitlistEntriesDeps = {
db?: Pick<PrismaClient, 'group' | 'groupWaitlistEntry'>;
};
export class ListGroupWaitlistEntries {
constructor(private readonly deps: ListGroupWaitlistEntriesDeps = {}) {}
async execute(
groupId: string,
userId: string,
query: GroupWaitlistQuery,
): Promise<Result<GroupWaitlistList, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const groupResult = await findGroupForUser(db, groupId, userId);
if (!groupResult.ok) {
return groupResult;
}
const where = { groupId, status: 'PENDING' as const };
const [records, total] = await Promise.all([
db.groupWaitlistEntry.findMany({
where,
skip: getPaginationOffset(query),
take: query.pageSize,
orderBy: { createdAt: 'asc' },
}),
db.groupWaitlistEntry.count({ where }),
]);
return ok({
data: records.map((record) => toGroupWaitlistEntryDto(record as unknown as GroupWaitlistEntryRecord)),
pagination: getPaginationMetadata(query, total),
});
}
}

View File

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

View File

@@ -0,0 +1,56 @@
import type { Prisma, PrismaClient } from '@generated/prisma/client';
import type { ProblemDetails, PromoteGroupWaitlistEntryResult, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { notFoundResourceProblem } from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import {
findGroupOwnedByUser,
type GroupWaitlistEntryRecord,
} from '../../lib/helpers';
import { promoteWaitlistEntryRecord } from '../../lib/promote';
type PromoteGroupWaitlistEntryDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
unitOfWork?: UnitOfWork;
};
export class PromoteGroupWaitlistEntry {
constructor(private readonly deps: PromoteGroupWaitlistEntryDeps = {}) {}
async execute(
groupId: string,
entryId: string,
userId: string,
): Promise<Result<PromoteGroupWaitlistEntryResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
if (!groupResult.ok) {
return groupResult;
}
const entry = await db.groupWaitlistEntry.findFirst({
where: { id: entryId, groupId },
});
if (!entry) {
return err(notFoundResourceProblem('GroupWaitlistEntry', entryId));
}
return unitOfWork.executeResult(
async (tx: Prisma.TransactionClient) => {
const promoteResult = await promoteWaitlistEntryRecord(
tx,
groupResult.value,
entry as unknown as GroupWaitlistEntryRecord,
);
if (!promoteResult.ok) {
return promoteResult;
}
await tx.groupWaitlistEntry.delete({ where: { id: entry.id } });
return ok({ attendee: promoteResult.value });
},
);
}
}

View File

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

View File

@@ -0,0 +1,37 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { ProblemDetails, RemoveGroupWaitlistEntryResult, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { notFoundResourceProblem } from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { findGroupOwnedByUser } from '../../lib/helpers';
type RemoveGroupWaitlistEntryDeps = {
db?: Pick<PrismaClient, 'group' | 'groupWaitlistEntry'>;
};
export class RemoveGroupWaitlistEntry {
constructor(private readonly deps: RemoveGroupWaitlistEntryDeps = {}) {}
async execute(
groupId: string,
entryId: string,
userId: string,
): Promise<Result<RemoveGroupWaitlistEntryResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
if (!groupResult.ok) {
return groupResult;
}
const existing = await db.groupWaitlistEntry.findFirst({
where: { id: entryId, groupId },
});
if (!existing) {
return err(notFoundResourceProblem('GroupWaitlistEntry', entryId));
}
await db.groupWaitlistEntry.delete({ where: { id: entryId } });
return ok({ deleted: true });
}
}

View File

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

View File

@@ -0,0 +1,65 @@
import type { Group, PrismaClient } from '@generated/prisma/client';
import type { GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
export type GroupWaitlistEntryRecord = {
id: string;
groupId: string;
fullName: string;
phone: string;
email: string | null;
notes: string | null;
status: GroupWaitlistEntryDto['status'];
createdAt: Date;
updatedAt: Date;
};
export function toGroupWaitlistEntryDto(record: GroupWaitlistEntryRecord): GroupWaitlistEntryDto {
return {
id: record.id,
groupId: record.groupId,
fullName: record.fullName,
phone: record.phone,
email: record.email,
notes: record.notes,
status: record.status,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
export async function findGroupForUser(
db: Pick<PrismaClient, 'group'>,
groupId: string,
userId: string,
): Promise<Result<Group, ProblemDetails>> {
const group = await db.group.findUnique({
where: { id: groupId },
include: { members: { where: { userId } } },
});
if (!group) {
return err(notFoundResourceProblem('Group', groupId));
}
const isOwner = group.createdById === userId;
const isMember = group.members.length > 0;
if (!isOwner && !isMember) {
return err(noGroupAccessProblem());
}
return ok(group);
}
export async function findGroupOwnedByUser(
db: Pick<PrismaClient, 'group'>,
groupId: string,
userId: string,
): Promise<Result<Group, ProblemDetails>> {
const group = await db.group.findUnique({ where: { id: groupId } });
if (!group) {
return err(notFoundResourceProblem('Group', groupId));
}
if (group.createdById !== userId) {
return err(noGroupAccessProblem());
}
return ok(group);
}

View File

@@ -0,0 +1,85 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { AttendeeDto, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { capacityReachedProblem, conflictProblem } from '@/http/problem-builders';
import { type AttendeeRecord, toAttendeeDto } from '@/modules/attendees/lib/helpers';
import { type GroupWaitlistEntryRecord } from './helpers';
export type PromoteDb = Pick<PrismaClient, 'attendee' | 'groupWaitlistEntry'>;
type PromoteGroup = {
id: string;
capacity: number | null;
};
export async function findFirstPendingWaitlistEntry(
db: PromoteDb,
groupId: string,
): Promise<GroupWaitlistEntryRecord | null> {
const record = await db.groupWaitlistEntry.findFirst({
where: { groupId, status: 'PENDING' },
orderBy: { createdAt: 'asc' },
});
return record as GroupWaitlistEntryRecord | null;
}
export async function promoteWaitlistEntryRecord(
db: PromoteDb,
group: PromoteGroup,
entry: GroupWaitlistEntryRecord,
): Promise<Result<AttendeeDto, ProblemDetails>> {
if (group.capacity !== null) {
const currentCount = await db.attendee.count({
where: { groupId: group.id, status: 'ACTIVE' },
});
if (currentCount >= group.capacity) {
return err(capacityReachedProblem(group.capacity));
}
}
const existing = await db.attendee.findFirst({
where: { groupId: group.id, phone: entry.phone },
});
if (existing) {
return err(
conflictProblem({
detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.',
code: 'attendee_already_registered',
}),
);
}
const attendee = await db.attendee.create({
data: {
groupId: group.id,
fullName: entry.fullName,
phone: entry.phone,
email: entry.email,
notes: entry.notes,
},
});
return ok(toAttendeeDto(attendee as unknown as AttendeeRecord));
}
export type PromoteFirstResult = {
attendee: AttendeeDto;
entry: GroupWaitlistEntryRecord;
};
export async function promoteFirstPendingWaitlistEntry(
db: PromoteDb,
group: PromoteGroup,
): Promise<Result<PromoteFirstResult | null, ProblemDetails>> {
const entry = await findFirstPendingWaitlistEntry(db, group.id);
if (!entry) {
return ok(null);
}
const result = await promoteWaitlistEntryRecord(db, group, entry);
if (!result.ok) {
return result;
}
return ok({ attendee: result.value, entry });
}

View File

@@ -0,0 +1,12 @@
import { Hono } from 'hono';
import getAllRoute from './features/get-all/route';
import promoteRoute from './features/promote/route';
import removeRoute from './features/remove/route';
const routes = new Hono();
routes.route('/', getAllRoute);
routes.route('/', promoteRoute);
routes.route('/', removeRoute);
export default routes;

View File

@@ -1,27 +0,0 @@
import type { CreateGroupFromOrganization } from '@gruperly/shared';
import { CreateGroupFromOrganizationSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { CreateGroupFromOrganization as UseCase } from './use-case';
const route = new Hono();
route.post('/', validate.json(CreateGroupFromOrganizationSchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const data = c.req.valid('json') as CreateGroupFromOrganization;
const useCase = new UseCase();
const result = await useCase.execute(data, user.id);
if (!result.ok) {
return problemJson(c, result.error);
}
return c.json(result.value, result.value.alreadyExists ? 200 : 201);
});
export default route;

View File

@@ -1,83 +0,0 @@
import type { Prisma } from '@generated/prisma/client';
import { Role } from '@generated/prisma/client';
import type {
CreateGroupFromOrganization as CreateGroupFromOrganizationInput,
CreateGroupFromOrganizationResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import {
groupOwnerRequiredProblem,
organizationNotFoundProblem,
} from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { type GroupDb, type GroupRecord, toGroupDto } from '../../lib';
type CreateGroupFromOrganizationDeps = {
db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>;
unitOfWork?: UnitOfWork;
};
export class CreateGroupFromOrganization {
constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {}
async execute(
data: CreateGroupFromOrganizationInput,
userId: string,
): Promise<Result<CreateGroupFromOrganizationResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const organization = await db.organization.findUnique({
where: { id: data.organizationId },
include: { members: true },
});
if (!organization) {
return err(organizationNotFoundProblem(data.organizationId));
}
const membership = organization.members.find((member: { userId: string; role: string }) => member.userId === userId);
if (membership?.role !== 'owner') {
return err(groupOwnerRequiredProblem());
}
const existing = await db.group.findFirst({
where: { createdById: userId, name: organization.name },
});
if (existing) {
return ok({ group: toGroupDto(existing), alreadyExists: true });
}
const transaction = unitOfWork.executeResult(
async (tx: Prisma.TransactionClient) => {
const created = await tx.group.create({
data: {
name: organization.name,
createdById: userId,
},
});
await tx.groupMember.create({
data: {
groupId: created.id,
userId,
role: Role.OWNER,
},
});
return ok(created);
},
);
const result = await transaction;
if (!result.ok) {
return result;
}
return ok({
group: toGroupDto(result.value as GroupRecord),
alreadyExists: false,
});
}
}

View File

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

View File

@@ -0,0 +1,68 @@
import type { Prisma, PrismaClient } from '@generated/prisma/client';
import { Role } from '@generated/prisma/client';
import type {
CreateFirstGroup as CreateGroupInput,
CreateGroupResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { paymentNotSetupProblem } from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { type GroupRecord, toGroupDto } from '../../lib';
type CreateGroupDeps = {
db?: Pick<PrismaClient, 'group' | 'groupMember' | 'merchantAccount'>;
unitOfWork?: UnitOfWork;
};
export class CreateGroup {
constructor(private readonly deps: CreateGroupDeps = {}) {}
async execute(
data: CreateGroupInput,
userId: string,
): Promise<Result<CreateGroupResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } });
if (!merchantAccount) {
return err(paymentNotSetupProblem());
}
const transaction = unitOfWork.executeResult(
async (tx: Prisma.TransactionClient) => {
const created = await tx.group.create({
data: {
name: data.name,
createdById: userId,
days: data.days,
time: data.time,
capacity: data.capacity,
price: data.price,
billingType: data.billingType,
dueDay: data.dueDay,
},
});
await tx.groupMember.create({
data: {
groupId: created.id,
userId,
role: Role.OWNER,
},
});
return ok(created);
},
);
const result = await transaction;
if (!result.ok) {
return result;
}
return ok({ group: toGroupDto(result.value as GroupRecord) });
}
}

View File

@@ -1,7 +1,7 @@
import type { PrismaClient } from '@generated/prisma/client'; import type { PrismaClient } from '@generated/prisma/client';
import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared'; import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared';
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember' | 'organization'>; export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember'>;
type PriceLike = { toString(): string }; type PriceLike = { toString(): string };

View File

@@ -1,7 +1,11 @@
import { Hono } from 'hono'; 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 bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
import createAttendeeRoute from '../attendees/features/create/route'; import createAttendeeRoute from '../attendees/features/create/route';
import createFromOrganizationRoute from './features/create-from-organization/route'; import removeAttendeeRoute from '../attendees/features/remove/route';
import groupWaitlistRoutes from '../group-waitlist/routes';
import createGroupRoute from './features/create/route';
import getAllRoute from './features/get-all/route'; import getAllRoute from './features/get-all/route';
import getByIdRoute from './features/get-by-id/route'; import getByIdRoute from './features/get-by-id/route';
import inviteTokenRoute from './features/invite-token/route'; import inviteTokenRoute from './features/invite-token/route';
@@ -10,11 +14,15 @@ import listAttendeesRoute from './features/list-attendees/route';
const routes = new Hono(); const routes = new Hono();
routes.route('/', getAllRoute); routes.route('/', getAllRoute);
routes.route('/from-organization', createFromOrganizationRoute); routes.route('/', createGroupRoute);
routes.route('/', inviteTokenRoute); routes.route('/', inviteTokenRoute);
routes.route('/', listAttendeesRoute); routes.route('/', listAttendeesRoute);
routes.route('/', createAttendeeRoute); routes.route('/', createAttendeeRoute);
routes.route('/', bulkCreateAttendeesRoute); routes.route('/', bulkCreateAttendeesRoute);
routes.route('/', getByIdRoute); routes.route('/', getByIdRoute);
routes.route('/', addToGroupWaitlistRoute);
routes.route('/', groupWaitlistRoutes);
routes.route('/', analyticsRoutes);
routes.route('/', removeAttendeeRoute);
export default routes; export default routes;

View File

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

View File

@@ -0,0 +1,134 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
HomeQuery,
HomeSummary,
NextClass,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
import { buildGroupWhereForUser } from '@/modules/groups/lib/helpers';
import { DEFAULT_TIME_ZONE, nextClassOccurrence } from '../../lib/scheduler';
export const UPCOMING_PAYMENTS_LIMIT = 5;
type HomeSummaryDeps = {
db?: Pick<PrismaClient, 'group' | 'payment'>;
now?: Date;
timeZone?: string;
};
type GroupSummaryRecord = {
id: string;
name: string;
days: NextClass['days'];
time: string | null;
_count: { attendees: number };
};
export class GetHomeSummary {
constructor(private readonly deps: HomeSummaryDeps = {}) {}
async execute(userId: string, query: HomeQuery = {}): Promise<Result<HomeSummary, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const timeZone = this.deps.timeZone ?? query.timeZone ?? DEFAULT_TIME_ZONE;
const groups = await db.group.findMany({
where: buildGroupWhereForUser(userId),
select: {
id: true,
name: true,
days: true,
time: true,
_count: { select: { attendees: true } },
},
});
if (groups.length === 0) {
return ok({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
}
const groupIds = groups.map((group) => group.id);
const attendees = groups.reduce((sum, group) => sum + group._count.attendees, 0);
const [pendingAggregate, upcomingPayments] = await Promise.all([
db.payment.aggregate({
where: { groupId: { in: groupIds }, status: { in: ['PENDING', 'OVERDUE'] } },
_count: true,
_sum: { amount: true },
}),
db.payment.findMany({
where: { groupId: { in: groupIds }, status: { in: ['PENDING', 'OVERDUE'] } },
orderBy: [{ dueDate: 'asc' as const }],
take: UPCOMING_PAYMENTS_LIMIT,
include: {
group: { select: { name: true } },
attendee: { select: { fullName: true } },
},
}),
]);
return ok({
stats: {
groups: groups.length,
attendees,
pendingPayments: pendingAggregate._count,
pendingAmount: pendingAggregate._sum.amount
? Number(pendingAggregate._sum.amount.toString())
: 0,
},
nextClass: this.findNextClass(groups, now, timeZone),
upcomingPayments: upcomingPayments.map((payment) => ({
id: payment.id,
amount: Number(payment.amount.toString()),
currency: payment.currency,
dueDate: payment.dueDate.toISOString(),
status: payment.status,
groupName: payment.group.name,
attendeeName: payment.attendee.fullName,
})),
});
}
private findNextClass(
groups: GroupSummaryRecord[],
now: Date,
timeZone: string,
): NextClass | null {
let best: NextClass | null = null;
for (const group of groups) {
if (!group.days || group.days.length === 0 || !group.time) {
continue;
}
const occurrence = nextClassOccurrence({
days: group.days,
time: group.time,
now,
timeZone,
});
if (!occurrence) {
continue;
}
if (best && occurrence.occurrenceAt.getTime() >= new Date(best.occurrenceAt).getTime()) {
continue;
}
best = {
groupId: group.id,
name: group.name,
days: group.days,
time: group.time,
occurrenceAt: occurrence.occurrenceAt.toISOString(),
isNow: occurrence.isNow,
};
}
return best;
}
}

View File

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

View File

@@ -0,0 +1,188 @@
import type { WeekDay } from '@gruperly/shared';
export const DEFAULT_TIME_ZONE = process.env.APP_TIMEZONE ?? 'America/Mexico_City';
// Ventana (en horas) durante la que una clase ya arrancada se muestra como "en curso".
export const CLASS_WINDOW_HOURS = 2;
export const CLASS_WINDOW_MS = CLASS_WINDOW_HOURS * 60 * 60 * 1000;
const WEEKDAY_TO_DOW: Record<WeekDay, number> = {
SUNDAY: 0,
MONDAY: 1,
TUESDAY: 2,
WEDNESDAY: 3,
THURSDAY: 4,
FRIDAY: 5,
SATURDAY: 6,
};
export type NextClassOccurrence = {
occurrenceAt: Date;
isNow: boolean;
};
// Convierte un tiempo de "pared" (wall clock) expresado en `timeZone` al instante absoluto.
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}`);
}
const [, year, month, day, hour, minute] = match;
const asUtc = new Date(
Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute)),
);
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
.formatToParts(asUtc)
.reduce<Record<string, string>>((acc, part) => {
acc[part.type] = part.value;
return acc;
}, {});
const tzRepresentation = new Date(
Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
),
);
const offsetMs = asUtc.getTime() - tzRepresentation.getTime();
return new Date(asUtc.getTime() + offsetMs);
}
export function wallDateInTimeZone(now: Date, timeZone: string): { year: number; month: number; day: number } {
const wall = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.formatToParts(now)
.reduce<Record<string, string>>((acc, part) => {
acc[part.type] = part.value;
return acc;
}, {});
return {
year: Number(wall.year),
month: Number(wall.month),
day: Number(wall.day),
};
}
export function nextClassOccurrence(opt: {
days: WeekDay[];
time: string;
now: Date;
timeZone: string;
}): NextClassOccurrence | 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 dowSet = new Set(days.map((day) => WEEKDAY_TO_DOW[day]));
const { year, month, day } = wallDateInTimeZone(now, timeZone);
let best: NextClassOccurrence | null = null;
for (let offset = 0; offset <= 7; offset++) {
const candidateDay = new Date(Date.UTC(year, month - 1, day + offset, 0, 0, 0));
const dow = candidateDay.getUTCDay();
if (!dowSet.has(dow)) {
continue;
}
const wall = `${candidateDay.toISOString().slice(0, 10)}T${pad(hour)}:${pad(minute)}`;
const candidate = zonedWallClockToUtc(wall, timeZone);
if (offset === 0) {
if (candidate.getTime() > now.getTime()) {
best = { occurrenceAt: candidate, isNow: false };
break;
}
if (now.getTime() >= candidate.getTime() && now.getTime() - candidate.getTime() < CLASS_WINDOW_MS) {
return { occurrenceAt: candidate, isNow: true };
}
continue;
}
if (!best || candidate.getTime() < best.occurrenceAt.getTime()) {
best = { occurrenceAt: candidate, isNow: false };
}
}
return best;
}
function pad(value: number): string {
return String(value).padStart(2, '0');
}
// Instante absoluto de la clase de HOY (aunque la hora ya haya pasado), o null si el grupo
// no tiene clase programada para el día de `now` en `timeZone`.
export function todayOccurrence(opt: {
days: WeekDay[];
time: string;
now: Date;
timeZone: string;
}): Date | null {
const { days, time, now, timeZone } = opt;
if (days.length === 0 || !time) {
return null;
}
const [hour, minute] = time.split(':').map(Number);
if (
Number.isNaN(hour) ||
Number.isNaN(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
const { year, month, day } = wallDateInTimeZone(now, timeZone);
const dow = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
const dayMatches = days.some((d) => WEEKDAY_TO_DOW[d] === dow);
if (!dayMatches) {
return null;
}
const wall = `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}`;
return zonedWallClockToUtc(wall, timeZone);
}
// Primer instante del día de `now` en `timeZone` (medianoche local).
export function startOfDayInTimeZone(now: Date, timeZone: string): Date {
const { year, month, day } = wallDateInTimeZone(now, timeZone);
return zonedWallClockToUtc(`${year}-${pad(month)}-${pad(day)}T00:00`, timeZone);
}

View File

@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import getSummaryRoute from './features/get-summary/route';
const routes = new Hono();
routes.route('/', getSummaryRoute);
export default routes;

View File

@@ -6,12 +6,13 @@ import prisma from '@/lib/prisma';
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers'; import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers';
type JoinViaInviteDeps = { type JoinViaInviteDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee'>; db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
}; };
export type JoinViaInviteResult = { export type JoinViaInviteResult = {
attendee: AttendeeDto; status: 'registered' | 'waitlisted';
message: string; message: string;
attendee: AttendeeDto | null;
}; };
export class JoinViaInvite { export class JoinViaInvite {
@@ -52,6 +53,37 @@ export class JoinViaInvite {
} }
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim(); const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
if (group.capacity !== null) {
const currentCount = await db.attendee.count({
where: { groupId: group.id, status: 'ACTIVE' },
});
if (currentCount >= group.capacity) {
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
where: { groupId: group.id, phone },
});
if (!existingWaitlistEntry) {
await db.groupWaitlistEntry.create({
data: {
groupId: group.id,
fullName,
phone,
email: payload.email?.trim() || null,
},
});
}
return ok({
status: 'waitlisted',
message:
'El grupo está completo. Fuiste agregado a la lista de espera. Te contactaremos cuando haya un lugar disponible.',
attendee: null,
});
}
}
const created = await db.attendee.create({ const created = await db.attendee.create({
data: { data: {
groupId: group.id, groupId: group.id,
@@ -62,8 +94,9 @@ export class JoinViaInvite {
}); });
return ok({ return ok({
status: 'registered',
attendee: toAttendeeDto(created as unknown as AttendeeRecord), attendee: toAttendeeDto(created as unknown as AttendeeRecord),
message: 'Inscripción realizada con éxito', message: 'Inscripción realizada con éxito',
}); });
} }
} }

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findMany: mock(),
},
attendee: {
findMany: mock(),
},
classSession: {
findMany: mock(),
},
attendance: {
findMany: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
},
}));
import { groupsRiskOverviewRoute } 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);
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/groups-risk', groupsRiskOverviewRoute);
return app;
}
describe('GET /groups-risk/overview', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('devuelve items vacíos si el usuario no tiene grupos', async () => {
prismaFetchGroupFindMany([]);
prismaFetchAttendeeFindMany([]);
prismaFetchClassSessionFindMany([]);
prismaFetchAttendanceFindMany([]);
const res = await makeApp({ id: userId }).request('/groups-risk/overview');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ items: [] });
});
it('marca HIGH a un grupo con un alumno con 3 ausencias consecutivas', async () => {
prismaFetchGroupFindMany([
{ id: 'group-1', name: 'Funcional' },
{ id: 'group-2', name: 'Yoga' },
]);
prismaFetchAttendeeFindMany([
{ id: 'a-1', groupId: 'group-1' },
{ id: 'a-2', groupId: 'group-2' },
]);
prismaFetchClassSessionFindMany([
{ id: 's1', groupId: 'group-1', startsAt: at(-1) },
{ id: 's2', groupId: 'group-1', startsAt: at(-2) },
{ id: 's3', groupId: 'group-1', startsAt: at(-3) },
{ id: 's4', groupId: 'group-2', startsAt: at(-1) },
]);
prismaFetchAttendanceFindMany([
attendance('a-1', 'ABSENT', at(-1)),
attendance('a-1', 'ABSENT', at(-2)),
attendance('a-1', 'ABSENT', at(-3)),
attendance('a-2', 'PRESENT', at(-1)),
]);
const res = await makeApp({ id: userId }).request('/groups-risk/overview');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
items: [
{ groupId: 'group-1', groupName: 'Funcional', riskLevel: 'HIGH' },
{ groupId: 'group-2', groupName: 'Yoga', riskLevel: 'NONE' },
],
});
});
});
function prismaFetchGroupFindMany(rows: unknown[]) {
db.group.findMany.mockResolvedValue(rows);
}
function prismaFetchAttendeeFindMany(rows: unknown[]) {
db.attendee.findMany.mockResolvedValue(rows);
}
function prismaFetchClassSessionFindMany(rows: unknown[]) {
db.classSession.findMany.mockResolvedValue(rows);
}
function prismaFetchAttendanceFindMany(rows: unknown[]) {
db.attendance.findMany.mockResolvedValue(rows);
}
function attendance(attendeeId: string, status: string, startsAt: string) {
return { attendeeId, status, classSession: { startsAt } };
}

View File

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

View File

@@ -0,0 +1,325 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findUnique: mock(),
},
classSession: {
findMany: mock(),
},
attendee: {
findMany: mock(),
findFirst: mock(),
findUnique: mock(),
update: mock(),
},
attendance: {
findMany: mock(),
},
slotRelease: {
count: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
},
}));
import prisma from '@/lib/prisma';
import { analyticsRoutes, studentStatusRoutes } from '@/modules/analytics';
const userId = 'user-1';
const DAY_MS = 24 * 60 * 60 * 1000;
const now = Date.now();
const at = (offsetDays: number) => new Date(now - offsetDays * DAY_MS).toISOString();
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/groups', analyticsRoutes);
app.route('/students', studentStatusRoutes);
return app;
}
function makeGroup(overrides: Record<string, unknown> = {}) {
return {
id: 'group-1',
name: 'Funcional',
createdById: userId,
members: [],
...overrides,
};
}
describe('analytics routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /groups/:groupId/analytics', () => {
it('calcula métricas de asistencia de los últimos 30 días', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.classSession.findMany.mockResolvedValue([{ id: 's-1' }, { id: 's-2' }]);
prisma.attendance.findMany.mockResolvedValue([
{ status: 'PRESENT' },
{ status: 'PRESENT' },
{ status: 'ABSENT' },
{ status: 'EXCUSED' },
]);
prisma.slotRelease.count.mockResolvedValue(2);
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
attendanceRate: 50,
totalPresent: 2,
totalClasses: 2,
recoveredSlots: 2,
});
});
it('devuelve 0 en todas las métricas sin clases en el período', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.classSession.findMany.mockResolvedValue([]);
prisma.attendance.findMany.mockResolvedValue([]);
prisma.slotRelease.count.mockResolvedValue(0);
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
attendanceRate: 0,
totalPresent: 0,
totalClasses: 0,
recoveredSlots: 0,
});
});
it('prohíbe el acceso a quien no es owner ni miembro', async () => {
prisma.group.findUnique.mockResolvedValue(
makeGroup({ createdById: 'someone-else' }),
);
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'group_access_denied' });
});
it('devuelve 404 si el grupo no existe', async () => {
prisma.group.findUnique.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
});
it('rechaza requests sin sesión', async () => {
const res = await makeApp(null).request('/groups/group-1/analytics');
expect(res.status).toBe(401);
});
});
describe('GET /groups/:groupId/students-at-risk', () => {
const sessions = [at(0), at(7), at(14), at(21)];
it('clasifica HIGH y MEDIUM según el historial de asistencia', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.classSession.findMany.mockResolvedValue(sessions.map((startsAt) => ({ id: 's', startsAt })));
prisma.attendee.findMany.mockResolvedValue([
{ id: 'a-1', fullName: 'Ana Pérez', phone: '+521111111111' },
{ id: 'a-2', fullName: 'Bruno Díaz', phone: null },
]);
prisma.attendance.findMany.mockResolvedValue([
{ attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(0)) } },
{ attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(7)) } },
{ attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(14)) } },
{ attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(21)) } },
{ attendeeId: 'a-2', status: 'ABSENT', classSession: { startsAt: new Date(at(0)) } },
{ attendeeId: 'a-2', status: 'ABSENT', classSession: { startsAt: new Date(at(7)) } },
{ attendeeId: 'a-2', status: 'PRESENT', classSession: { startsAt: new Date(at(14)) } },
{ attendeeId: 'a-2', status: 'PRESENT', classSession: { startsAt: new Date(at(21)) } },
]);
const res = await makeApp({ id: userId }).request('/groups/group-1/students-at-risk');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
groupName: 'Funcional',
data: [
{
attendeeId: 'a-1',
fullName: 'Ana Pérez',
phone: '+521111111111',
riskLevel: 'HIGH',
consecutiveAbsences: 3,
lastAttendedAt: at(21),
monthlyAttendanceRate: 25,
},
{
attendeeId: 'a-2',
fullName: 'Bruno Díaz',
phone: null,
riskLevel: 'MEDIUM',
consecutiveAbsences: 2,
lastAttendedAt: at(14),
monthlyAttendanceRate: 50,
},
],
});
});
it('devuelve lista vacía cuando nadie está en riesgo', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.classSession.findMany.mockResolvedValue(sessions.map((startsAt) => ({ id: 's', startsAt })));
prisma.attendee.findMany.mockResolvedValue([
{ id: 'a-1', fullName: 'Ana Pérez', phone: null },
]);
prisma.attendance.findMany.mockResolvedValue([
{ attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(0)) } },
{ attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(7)) } },
]);
const res = await makeApp({ id: userId }).request('/groups/group-1/students-at-risk');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ groupName: 'Funcional', data: [] });
});
});
describe('GET /groups/:groupId/attendees/:attendeeId/history', () => {
it('devuelve el historial de presentismo con su porcentaje', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.attendee.findFirst.mockResolvedValue({ id: 'a-1', fullName: 'Ana Pérez' });
prisma.classSession.findMany.mockResolvedValue([
{ id: 's-2', startsAt: new Date(at(7)) },
{ id: 's-1', startsAt: new Date(at(0)) },
]);
prisma.attendance.findMany.mockResolvedValue([
{ classSessionId: 's-1', status: 'PRESENT' },
{ classSessionId: 's-2', status: 'ABSENT' },
]);
const res = await makeApp({ id: userId }).request(
'/groups/group-1/attendees/a-1/history',
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
attendeeId: 'a-1',
fullName: 'Ana Pérez',
groupId: 'group-1',
groupName: 'Funcional',
attendanceRate: 50,
sessions: [
{ classSessionId: 's-1', startsAt: at(0), status: 'PRESENT' },
{ classSessionId: 's-2', startsAt: at(7), status: 'ABSENT' },
],
});
});
it('devuelve 404 si el alumno no pertenece al grupo', async () => {
prisma.group.findUnique.mockResolvedValue(makeGroup());
prisma.attendee.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request(
'/groups/group-1/attendees/nope/history',
);
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
});
});
describe('POST /students/:studentId/status', () => {
function makeAttendee(status: string, overrides: Record<string, unknown> = {}) {
return {
id: 'a-1',
status,
group: { createdById: userId, members: [] },
...overrides,
};
}
it('cambia el estado del alumno', async () => {
prisma.attendee.findUnique.mockResolvedValue(makeAttendee('ACTIVE'));
prisma.attendee.update.mockResolvedValue({ id: 'a-1', status: 'DROPPED' });
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'DROPPED' }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ attendeeId: 'a-1', status: 'DROPPED' });
expect(prisma.attendee.update).toHaveBeenCalledWith({
where: { id: 'a-1' },
data: { status: 'DROPPED' },
select: { id: true, status: true },
});
});
it('es idempotente si el estado no cambia', async () => {
prisma.attendee.findUnique.mockResolvedValue(makeAttendee('PAUSED'));
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'PAUSED' }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ attendeeId: 'a-1', status: 'PAUSED' });
expect(prisma.attendee.update).not.toHaveBeenCalled();
});
it('prohíbe el acceso a quien no es owner ni miembro', async () => {
prisma.attendee.findUnique.mockResolvedValue(
makeAttendee('ACTIVE', { group: { createdById: 'someone-else', members: [] } }),
);
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'PAUSED' }),
});
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'group_access_denied' });
});
it('rechaza estados fuera del contrato', async () => {
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'HOLD' }),
});
expect(res.status).toBe(400);
expect(prisma.attendee.update).not.toHaveBeenCalled();
});
it('rechaza requests sin sesión', async () => {
const res = await makeApp(null).request('/students/a-1/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'ACTIVE' }),
});
expect(res.status).toBe(401);
});
});
});

View File

@@ -14,6 +14,12 @@ const db = {
create: mock(), create: mock(),
count: mock(), count: mock(),
}, },
groupWaitlistEntry: {
findMany: mock(),
findFirst: mock(),
create: mock(),
count: mock(),
},
}; };
mock.module('@/lib/prisma', () => ({ mock.module('@/lib/prisma', () => ({
@@ -130,8 +136,9 @@ describe('attendee incorporation & invite-token in groups', () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
const data = await res.json(); const data = await res.json();
expect(data.fullName).toBe('Martín Gómez'); expect(data.outcome).toBe('created');
expect(data.phone).toBe('+5491133445566'); expect(data.attendee.fullName).toBe('Martín Gómez');
expect(data.attendee.phone).toBe('+5491133445566');
expect(prisma.attendee.create).toHaveBeenCalledWith({ expect(prisma.attendee.create).toHaveBeenCalledWith({
data: { data: {
groupId: 'group-1', groupId: 'group-1',
@@ -161,6 +168,244 @@ describe('attendee incorporation & invite-token in groups', () => {
expect(res.status).toBe(409); expect(res.status).toBe(409);
expect(prisma.attendee.create).not.toHaveBeenCalled(); expect(prisma.attendee.create).not.toHaveBeenCalled();
}); });
it('rejects owner with 409 group_capacity_reached when the group is full', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(20); // capacity full
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/attendees', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('group_capacity_reached');
expect(prisma.attendee.create).not.toHaveBeenCalled();
expect(prisma.groupWaitlistEntry.create).not.toHaveBeenCalled();
});
it('allows the owner to add anyway when allowOverflow is set', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(20);
prisma.attendee.create.mockResolvedValue({
id: 'att-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
email: null,
phone: '+5491133445566',
guardianName: null,
guardianPhone: null,
notes: null,
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
} as never);
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/attendees?allowOverflow=true', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.outcome).toBe('created');
expect(body.attendee.fullName).toBe('Martín Gómez');
expect(prisma.attendee.create).toHaveBeenCalled();
});
it('adds to the waitlist when a non-owner member tries to add to a full group', async () => {
const memberGroup = {
...mockGroup,
members: [{ id: 'gm-1', role: 'MEMBER' }],
};
prisma.group.findUnique.mockResolvedValue(memberGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(20);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.create.mockResolvedValue({
id: 'wl-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: null,
notes: null,
status: 'PENDING',
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
} as never);
const app = makeApp({ id: 'member-1' });
const res = await app.request('/groups/group-1/attendees', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.outcome).toBe('waitlisted');
expect(prisma.attendee.create).not.toHaveBeenCalled();
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
data: {
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '1133445566',
email: null,
notes: null,
},
});
});
it('still waitlists a non-owner member even when allowOverflow is set', async () => {
const memberGroup = {
...mockGroup,
members: [{ id: 'gm-1', role: 'MEMBER' }],
};
prisma.group.findUnique.mockResolvedValue(memberGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(20);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
const app = makeApp({ id: 'member-1' });
const res = await app.request('/groups/group-1/attendees?allowOverflow=true', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.outcome).toBe('waitlisted');
expect(prisma.attendee.create).not.toHaveBeenCalled();
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalled();
});
});
describe('POST /groups/:groupId/waitlist (add to group waitlist)', () => {
it('creates a waitlist entry for the group', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.create.mockResolvedValue({
id: 'wl-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: 'martin@example.com',
notes: 'Viene con su hermano',
status: 'PENDING',
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
} as never);
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '+54 9 11 3344-5566',
email: 'martin@example.com',
notes: 'Viene con su hermano',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.fullName).toBe('Martín Gómez');
expect(body.phone).toBe('+5491133445566');
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
data: {
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: 'martin@example.com',
notes: 'Viene con su hermano',
},
});
});
it('rejects when the phone number already belongs to an attendee', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never);
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('attendee_already_registered');
expect(prisma.groupWaitlistEntry.create).not.toHaveBeenCalled();
});
it('rejects when the phone number is already on the waitlist', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue({ id: 'wl-existing' } as never);
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('already_waitlisted');
});
it('rejects user without access to the group', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const app = makeApp({ id: 'other-user' });
const res = await app.request('/groups/group-1/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(403);
});
}); });
describe('POST /groups/:groupId/attendees/bulk (bulk import)', () => { describe('POST /groups/:groupId/attendees/bulk (bulk import)', () => {

View File

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

View File

@@ -0,0 +1,514 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findMany: mock(),
},
classSession: {
upsert: mock(),
findMany: mock(),
findUnique: mock(),
},
attendee: {
findMany: mock(),
findUnique: mock(),
},
payment: {
findMany: mock(),
},
attendance: {
findMany: mock(),
upsert: mock(),
},
slotRelease: {
findMany: mock(),
upsert: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
},
}));
import prisma from '@/lib/prisma';
import { classesRoutes } from '@/modules/classes';
const userId = 'user-1';
const UTC_WEEKDAYS = [
'SUNDAY',
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
] as const;
const todayUtc = UTC_WEEKDAYS[new Date().getUTCDay()];
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/classes', classesRoutes);
return app;
}
function makeSession(overrides: Record<string, unknown> = {}) {
return {
id: 'session-1',
groupId: 'group-1',
startsAt: todayAtUtc(),
group: {
id: 'group-1',
name: 'Funcional',
createdById: userId,
members: [],
capacity: 10,
},
...overrides,
};
}
// Fecha "hoy" a las 19:00 UTC, del día real del runner.
function todayAtUtc() {
const date = new Date();
date.setUTCHours(19, 0, 0, 0);
return date;
}
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 = todayAtUtc();
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 = todayAtUtc();
prisma.attendee.findUnique.mockResolvedValue({
id: 'attendee-1',
fullName: 'Ana Pérez',
group: {
id: 'group-1',
name: 'Funcional',
days: [mxWeekday],
time: '19:00',
},
});
prisma.classSession.upsert.mockResolvedValue({ id: 'session-1' });
prisma.classSession.findMany.mockResolvedValue([
{ id: 'session-1', groupId: 'group-1', startsAt },
]);
prisma.attendance.findMany.mockResolvedValue([]);
prisma.slotRelease.findMany.mockResolvedValue([]);
const res = await makeApp(null).request('/classes/absence?token=valid-token');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
attendeeId: 'attendee-1',
fullName: 'Ana Pérez',
groupName: 'Funcinal'.replace('Funcinal', 'Funcional'),
sessions: [
{
sessionId: 'session-1',
groupName: 'Funcional',
startsAt: startsAt.toISOString(),
notified: false,
attendanceStatus: null,
},
],
});
});
it('POST /classes/notify-absence público registra el aviso con token', async () => {
const startsAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
prisma.attendee.findUnique.mockResolvedValue({
id: 'attendee-1',
groupId: 'group-1',
});
prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt }));
prisma.attendance.upsert.mockResolvedValue({});
prisma.slotRelease.upsert.mockResolvedValue({});
const res = await makeApp(null).request('/classes/notify-absence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'valid-token', classSessionId: 'session-1' }),
});
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ notified: true, slotReleased: true });
expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1);
});
it('POST /classes/notify-absence público devuelve 404 con token inválido', async () => {
prisma.attendee.findUnique.mockResolvedValue(null);
const res = await makeApp(null).request('/classes/notify-absence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'bad', classSessionId: 'session-1' }),
});
expect(res.status).toBe(404);
expect(prisma.attendance.upsert).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,14 +4,13 @@ import { Hono } from 'hono';
const db = { const db = {
group: { group: {
findMany: mock(), findMany: mock(),
findFirst: mock(),
count: mock(), count: mock(),
create: mock(), create: mock(),
}, },
groupMember: { groupMember: {
create: mock(), create: mock(),
}, },
organization: { merchantAccount: {
findUnique: mock(), findUnique: mock(),
}, },
}; };
@@ -102,19 +101,29 @@ describe('groups routes', () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it('creates a group from an owned organization', async () => { it('creates a group with billing configuration', async () => {
const organization = { prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'merchant-1', userId });
id: 'org-1', prisma.group.create.mockResolvedValue({
name: 'Escuela Alfa', ...group,
members: [{ userId, role: 'owner' }], days: ['MONDAY'],
}; time: '09:00',
prisma.organization.findUnique.mockResolvedValue(organization as never); capacity: 20,
prisma.group.findFirst.mockResolvedValue(null); price: 500,
prisma.group.create.mockResolvedValue(group); billingType: 'MONTHLY',
dueDay: 5,
});
const res = await makeApp({ id: userId }).request('/groups/from-organization', { const res = await makeApp({ id: userId }).request('/groups', {
method: 'POST', method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }), body: JSON.stringify({
name: 'Cuadrilla Alfa',
days: ['MONDAY'],
time: '09:00',
capacity: 20,
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
}),
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
@@ -122,77 +131,63 @@ describe('groups routes', () => {
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
group: { group: {
...group, ...group,
days: ['MONDAY'],
time: '09:00',
capacity: 20,
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
createdAt: group.createdAt.toISOString(), createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(), updatedAt: group.updatedAt.toISOString(),
}, },
alreadyExists: false,
}); });
expect(prisma.group.create).toHaveBeenCalledWith({ expect(prisma.group.create).toHaveBeenCalledWith({
data: { name: 'Escuela Alfa', createdById: userId }, data: {
name: 'Cuadrilla Alfa',
createdById: userId,
days: ['MONDAY'],
time: '09:00',
capacity: 20,
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
},
});
expect(prisma.groupMember.create).toHaveBeenCalledWith({
data: { groupId: 'group-1', userId, role: 'OWNER' },
}); });
}); });
it('reuses an existing group with the same name', async () => { it('rejects creating a group without a linked merchant account', async () => {
const organization = { prisma.merchantAccount.findUnique.mockResolvedValue(null);
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId, role: 'owner' }],
};
prisma.organization.findUnique.mockResolvedValue(organization as never);
prisma.group.findFirst.mockResolvedValue(group);
const res = await makeApp({ id: userId }).request('/groups/from-organization', { const res = await makeApp({ id: userId }).request('/groups', {
method: 'POST', method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }), body: JSON.stringify({
name: 'Cuadrilla Alfa',
days: ['MONDAY'],
time: '09:00',
capacity: 20,
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
}),
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
expect(res.status).toBe(200); expect(res.status).toBe(409);
const body = await res.json(); expect(await res.json()).toMatchObject({ code: 'payment_not_setup' });
expect(body.alreadyExists).toBe(true);
expect(prisma.group.create).not.toHaveBeenCalled(); expect(prisma.group.create).not.toHaveBeenCalled();
}); });
it('rejects creating a group for an unknown organization', async () => { it('rejects creating a group with an invalid payload', async () => {
prisma.organization.findUnique.mockResolvedValue(null); const res = await makeApp({ id: userId }).request('/groups', {
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST', method: 'POST',
body: JSON.stringify({ organizationId: 'missing' }), body: JSON.stringify({ name: 'x' }),
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
expect(res.status).toBe(404); expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'organization_not_found' });
expect(prisma.group.create).not.toHaveBeenCalled(); expect(prisma.group.create).not.toHaveBeenCalled();
}); });
it('rejects creating a group when the user is not the owner', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId: 'other-user', role: 'owner' }],
};
prisma.organization.findUnique.mockResolvedValue(organization as never);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'group_owner_required' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group without a session', async () => {
const res = await makeApp(null).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(401);
});
}); });

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from 'bun:test';
import { CLASS_WINDOW_HOURS, nextClassOccurrence } from '@/modules/home/lib/scheduler';
const TZ = 'America/Mexico_City';
describe('nextClassOccurrence', () => {
it('devuelve la próxima clase de hoy cuando aún no arrancó', () => {
const now = new Date('2026-09-23T18:00:00.000Z'); // miércoles 12:00 hora CDMX
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-09-24T00:00:00.000Z'),
isNow: false,
});
});
it('marca en curso si la clase arrancó hace menos de la ventana', () => {
const now = new Date('2026-09-24T01:00:00.000Z'); // miércoles 19:00 hora CDMX (1 h tras la clase)
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-09-24T00:00:00.000Z'),
isNow: true,
});
});
it('salta a la próxima semana si la clase ya pasó la ventana', () => {
const now = new Date('2026-09-24T03:00:00.000Z'); // miércoles 21:00 hora CDMX (3 h tras la clase)
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-10-01T00:00:00.000Z'),
isNow: false,
});
});
it('elige la ocurrencia más próxima entre varios días', () => {
const now = new Date('2026-09-23T18:00:00.000Z'); // miércoles 12:00 hora CDMX
const result = nextClassOccurrence({
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-09-24T00:00:00.000Z'),
isNow: false,
});
});
it('cruza de mes sin problema', () => {
const now = new Date('2026-09-29T18:00:00.000Z'); // martes 12:00 hora CDMX (último día de septiembre es miércoles 30)
const result = nextClassOccurrence({
days: ['THURSDAY'],
time: '09:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-10-01T15:00:00.000Z'),
isNow: false,
});
});
it('devuelve null si no hay días de clase', () => {
const result = nextClassOccurrence({
days: [],
time: '18:00',
now: new Date('2026-09-23T18:00:00.000Z'),
timeZone: TZ,
});
expect(result).toBeNull();
});
it('devuelve null si la hora es inválida', () => {
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '99:99',
now: new Date('2026-09-23T18:00:00.000Z'),
timeZone: TZ,
});
expect(result).toBeNull();
});
it('expone el tamaño de la ventana de "en curso"', () => {
expect(CLASS_WINDOW_HOURS).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,277 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findMany: mock(),
},
payment: {
aggregate: mock(),
findMany: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
},
}));
import prisma from '@/lib/prisma';
import { homeRoutes } from '@/modules/home';
import { GetHomeSummary } from '@/modules/home/features/get-summary/use-case';
const TZ = 'America/Mexico_City';
const userId = 'user-1';
const groupA = {
id: 'g1',
name: 'Cuadrilla A',
days: ['WEDNESDAY'],
time: '18:00',
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
capacity: 8,
_count: { attendees: 3 },
};
const groupB = {
id: 'g2',
name: 'Cuadrilla B',
days: [],
time: null,
price: null,
billingType: null,
dueDay: null,
capacity: null,
_count: { attendees: 2 },
};
const madeUpAmount = (value: string) => ({ toString: () => value });
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/home', homeRoutes);
return app;
}
describe('GetHomeSummary', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('agrega stats, próxima clase y próximos cobros del usuario', async () => {
prisma.group.findMany.mockResolvedValue([groupB, groupA]);
prisma.payment.aggregate.mockResolvedValue({
_count: 4,
_sum: { amount: madeUpAmount('2100') },
});
prisma.payment.findMany.mockResolvedValue([
{
id: 'p1',
amount: madeUpAmount('500'),
currency: 'MXN',
dueDate: new Date('2026-09-25T12:00:00.000Z'),
status: 'PENDING',
group: { name: 'Cuadrilla A' },
attendee: { fullName: 'Ana García' },
},
]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({
stats: { groups: 2, attendees: 5, pendingPayments: 4, pendingAmount: 2100 },
nextClass: {
groupId: 'g1',
name: 'Cuadrilla A',
days: ['WEDNESDAY'],
time: '18:00',
occurrenceAt: '2026-09-24T00:00:00.000Z',
isNow: false,
},
upcomingPayments: [
{
id: 'p1',
amount: 500,
currency: 'MXN',
dueDate: '2026-09-25T12:00:00.000Z',
status: 'PENDING',
groupName: 'Cuadrilla A',
attendeeName: 'Ana García',
},
],
});
const where = {
OR: [{ createdById: userId }, { members: { some: { userId } } }],
};
expect(prisma.group.findMany).toHaveBeenCalledWith({ where, select: expect.any(Object) });
expect(prisma.payment.aggregate).toHaveBeenCalledWith({
where: { groupId: { in: ['g2', 'g1'] }, status: { in: ['PENDING', 'OVERDUE'] } },
_count: true,
_sum: { amount: true },
});
expect(prisma.payment.findMany).toHaveBeenCalledWith({
where: { groupId: { in: ['g2', 'g1'] }, status: { in: ['PENDING', 'OVERDUE'] } },
orderBy: [{ dueDate: 'asc' }],
take: 5,
include: { group: { select: { name: true } }, attendee: { select: { fullName: true } } },
});
});
it('marca como en curso la clase que ya arrancó', async () => {
prisma.group.findMany.mockResolvedValue([groupA]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-24T01:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
groupId: 'g1',
occurrenceAt: '2026-09-24T00:00:00.000Z',
isNow: true,
});
});
it('elige el grupo con la próxima clase más cercana', async () => {
prisma.group.findMany.mockResolvedValue([
groupA,
{
id: 'g3',
name: 'Cuadrilla C',
days: ['THURSDAY'],
time: '09:00',
price: null,
billingType: null,
dueDay: null,
capacity: null,
_count: { attendees: 0 },
},
]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass?.groupId).toBe('g1');
expect(result.value.nextClass?.occurrenceAt).toBe('2026-09-24T00:00:00.000Z');
});
it('devuelve nextClass null si ningún grupo tiene horario', async () => {
prisma.group.findMany.mockResolvedValue([groupB]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toBeNull();
expect(prisma.payment.aggregate).toHaveBeenCalled();
expect(prisma.payment.findMany).toHaveBeenCalled();
});
it('computa la próxima clase en la zona horaria del cliente', async () => {
prisma.group.findMany.mockResolvedValue([
{ ...groupA, days: ['WEDNESDAY'], time: '10:00' },
]);
// 10:23 en Buenos Aires (UTC-3). La clase de las 10:00 ya arrancó.
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T13:23:00.000Z') });
const result = await useCase.execute(userId, { timeZone: 'America/Argentina/Buenos_Aires' });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
groupId: 'g1',
occurrenceAt: '2026-09-23T13:00:00.000Z',
isNow: true,
});
});
it('usa la zona por defecto si no se envía una', async () => {
prisma.group.findMany.mockResolvedValue([
{ ...groupA, days: ['WEDNESDAY'], time: '10:00' },
]);
// 10:23 en Buenos Aires, pero la zona por defecto es CDMX → las 07:23 CDMX (aún no empieza).
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T13:23:00.000Z') });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
occurrenceAt: '2026-09-23T16:00:00.000Z',
isNow: false,
});
});
it('devuelve ceros y listas vacías cuando el usuario no tiene grupos', async () => {
prisma.group.findMany.mockResolvedValue([]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
expect(prisma.payment.aggregate).not.toHaveBeenCalled();
expect(prisma.payment.findMany).not.toHaveBeenCalled();
});
});
describe('home routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('devuelve el resumen del home autenticado', async () => {
prisma.group.findMany.mockResolvedValue([]);
const res = await makeApp({ id: userId }).request('/home');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
});
it('rechaza el home sin sesión', async () => {
const res = await makeApp(null).request('/home');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
it('rechaza una zona horaria inválida', async () => {
const res = await makeApp({ id: userId }).request('/home?timeZone=No/Es_Una_Zona');
expect(res.status).toBe(400);
});
});

View File

@@ -8,6 +8,11 @@ const db = {
attendee: { attendee: {
findFirst: mock(), findFirst: mock(),
create: mock(), create: mock(),
count: mock(),
},
groupWaitlistEntry: {
findFirst: mock(),
create: mock(),
}, },
}; };
@@ -151,5 +156,48 @@ describe('public invitations routes', () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it('adds to the waitlist when the group is full', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(15); // capacity full
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.create.mockResolvedValue({
id: 'wl-1',
groupId: 'group-1',
fullName: 'Lucía Méndez',
phone: '+5491122334455',
email: 'lucia@test.com',
notes: null,
status: 'PENDING',
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
} as never);
const res = await app.request('/invitations/valid-token-123/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Lucía',
lastName: 'Méndez',
phone: '+54 9 11 2233-4455',
email: 'lucia@test.com',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.status).toBe('waitlisted');
expect(body.attendee).toBeNull();
expect(prisma.attendee.create).not.toHaveBeenCalled();
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
data: {
groupId: 'group-1',
fullName: 'Lucía Méndez',
phone: '+5491122334455',
email: 'lucia@test.com',
},
});
});
}); });
}); });

View File

@@ -38,6 +38,18 @@ describe('session auth', () => {
expect(isPublicApiRequest('GET', '/api/v1/attendees')).toBe(false); 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 () => { it('allows public requests without a session', async () => {
const app = new Hono(); const app = new Hono();
app.use('*', sessionAuthMiddleware); app.use('*', sessionAuthMiddleware);

View File

@@ -0,0 +1,352 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findUnique: mock(),
},
attendee: {
findFirst: mock(),
findMany: mock(),
count: mock(),
create: mock(),
delete: mock(),
},
groupWaitlistEntry: {
findMany: mock(),
findFirst: mock(),
count: mock(),
create: mock(),
delete: mock(),
},
payment: {
count: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
},
}));
import prisma from '@/lib/prisma';
import { groupsRoutes } from '@/modules/groups';
const teacherId = 'teacher-1';
const mockGroup = {
id: 'group-1',
name: 'Taller de Pintura',
description: null,
createdById: teacherId,
inviteToken: null,
createdAt: new Date('2026-09-01T10:00:00.000Z'),
updatedAt: new Date('2026-09-01T10:00:00.000Z'),
days: ['MONDAY'],
time: '18:00',
capacity: 20,
price: 1500,
billingType: 'MONTHLY',
dueDay: 10,
members: [],
};
const waitlistEntry = (overrides: Record<string, unknown> = {}) => ({
id: 'wl-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: null,
notes: null,
status: 'PENDING',
createdAt: new Date('2026-09-10T10:00:00.000Z'),
updatedAt: new Date('2026-09-10T10:00:00.000Z'),
...overrides,
});
const attendee = (overrides: Record<string, unknown> = {}) => ({
id: 'att-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: null,
guardianName: null,
guardianPhone: null,
notes: null,
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
...overrides,
});
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/groups', groupsRoutes);
return app;
}
describe('group waitlist management', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /groups/:groupId/waitlist', () => {
it('lists pending entries in FIFO order with pagination', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findMany.mockResolvedValue([
waitlistEntry({ id: 'wl-1', fullName: 'Ana García', createdAt: new Date('2026-09-08T10:00:00.000Z') }),
waitlistEntry({ id: 'wl-2', fullName: 'Pedro López', createdAt: new Date('2026-09-09T10:00:00.000Z') }),
] as never);
prisma.groupWaitlistEntry.count.mockResolvedValue(2);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.data).toHaveLength(2);
expect(data.data[0].fullName).toBe('Ana García');
expect(data.pagination).toEqual({ page: 1, pageSize: 10, total: 2, totalPages: 1 });
expect(prisma.groupWaitlistEntry.findMany).toHaveBeenCalledWith({
where: { groupId: 'group-1', status: 'PENDING' },
skip: 0,
take: 10,
orderBy: { createdAt: 'asc' },
});
});
it('rejects user without access to the group', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist');
expect(res.status).toBe(403);
expect(prisma.groupWaitlistEntry.findMany).not.toHaveBeenCalled();
});
});
describe('POST /groups/:groupId/waitlist/:entryId/promote', () => {
it('creates an attendee from the entry and removes it from the waitlist', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
prisma.attendee.count.mockResolvedValue(5);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.create.mockResolvedValue(attendee() as never);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
method: 'POST',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.attendee.fullName).toBe('Martín Gómez');
expect(prisma.attendee.create).toHaveBeenCalledWith({
data: expect.objectContaining({ groupId: 'group-1', fullName: 'Martín Gómez', phone: '+5491133445566' }),
});
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
});
it('rejects with 409 when the group reached its capacity', async () => {
prisma.group.findUnique.mockResolvedValue({ ...mockGroup, capacity: 5 } as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
prisma.attendee.count.mockResolvedValue(5);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
method: 'POST',
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('group_capacity_reached');
expect(prisma.attendee.create).not.toHaveBeenCalled();
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
});
it('rejects with 409 when the phone already belongs to an attendee', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
prisma.attendee.count.mockResolvedValue(5);
prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
method: 'POST',
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('attendee_already_registered');
expect(prisma.attendee.create).not.toHaveBeenCalled();
});
it('returns 404 when the entry does not exist', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing/promote', {
method: 'POST',
});
expect(res.status).toBe(404);
});
it('rejects a non-owner user', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1/promote', {
method: 'POST',
});
expect(res.status).toBe(403);
expect(prisma.attendee.create).not.toHaveBeenCalled();
});
});
describe('DELETE /groups/:groupId/waitlist/:entryId', () => {
it('removes a waitlist entry', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1', {
method: 'DELETE',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.deleted).toBe(true);
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
});
it('returns 404 when the entry does not exist', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing', {
method: 'DELETE',
});
expect(res.status).toBe(404);
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
});
it('rejects a non-owner user', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1', {
method: 'DELETE',
});
expect(res.status).toBe(403);
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
});
});
describe('DELETE /groups/:groupId/attendees/:attendeeId', () => {
it('removes an attendee without promotion', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
prisma.payment.count.mockResolvedValue(0);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', {
method: 'DELETE',
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.removedAttendeeId).toBe('att-1');
expect(body.promoted).toBeNull();
expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } });
});
it('removes an attendee and promotes the first pending waitlist entry atomically', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockImplementation(async ({ where }: { where?: { id?: string } }) => {
if (where?.id) return attendee() as never;
return null;
});
prisma.payment.count.mockResolvedValue(0);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(
waitlistEntry({ id: 'wl-1', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never,
);
prisma.attendee.count.mockResolvedValue(5);
prisma.attendee.create.mockResolvedValue(
attendee({ id: 'att-2', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never,
);
const res = await makeApp({ id: teacherId }).request(
'/groups/group-1/attendees/att-1?promoteFromWaitlist=true',
{ method: 'DELETE' },
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.removedAttendeeId).toBe('att-1');
expect(body.promoted).toMatchObject({ id: 'wl-1', fullName: 'Sofía Ruiz' });
expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } });
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
expect(prisma.attendee.create).toHaveBeenCalledWith({
data: expect.objectContaining({ fullName: 'Sofía Ruiz', phone: '+5491133778899' }),
});
});
it('removes the attendee when promote requested but waitlist is empty', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
prisma.payment.count.mockResolvedValue(0);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: teacherId }).request(
'/groups/group-1/attendees/att-1?promoteFromWaitlist=true',
{ method: 'DELETE' },
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.removedAttendeeId).toBe('att-1');
expect(body.promoted).toBeNull();
expect(prisma.attendee.create).not.toHaveBeenCalled();
});
it('blocks removal when the attendee has payments', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
prisma.payment.count.mockResolvedValue(2);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', {
method: 'DELETE',
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('attendee_has_payments');
expect(prisma.attendee.delete).not.toHaveBeenCalled();
});
it('returns 404 when the attendee does not belong to the group', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-missing', {
method: 'DELETE',
});
expect(res.status).toBe(404);
expect(prisma.attendee.delete).not.toHaveBeenCalled();
});
it('rejects a non-owner user', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/attendees/att-1', {
method: 'DELETE',
});
expect(res.status).toBe(403);
expect(prisma.attendee.delete).not.toHaveBeenCalled();
});
});
});

View File

@@ -14,7 +14,7 @@
"@gruperly/shared": "workspace:*", "@gruperly/shared": "workspace:*",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.90.0", "@tanstack/react-router": "^1.170.38",
"better-auth": "1.7.2", "better-auth": "1.7.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.34.0", "lucide-react": "^1.34.0",
@@ -27,6 +27,7 @@
"devDependencies": { "devDependencies": {
"@gruperly/config": "workspace:*", "@gruperly/config": "workspace:*",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@tanstack/router-plugin": "^1.168.40",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",

View File

@@ -1 +0,0 @@
export { AuthShell } from './AuthShell'

View File

@@ -1,4 +1,5 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
@@ -6,7 +7,7 @@ import { useAuth } from '../../context/AuthProvider'
import { getOnboardingStatus } from '../../lib/api' import { getOnboardingStatus } from '../../lib/api'
import { RootLayout } from './RootLayout' import { RootLayout } from './RootLayout'
export function AppLayoutGuard() { export function AppLayoutGuard({ children }: { children: ReactNode }) {
const { user, isPending } = useAuth() const { user, isPending } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
@@ -30,5 +31,5 @@ export function AppLayoutGuard() {
) )
} }
return <RootLayout /> return <RootLayout>{children}</RootLayout>
} }

View File

@@ -17,16 +17,11 @@ const BREADCRUMBS: Record<string, Crumb[]> = {
{ label: 'Inicio', to: '/' }, { label: 'Inicio', to: '/' },
{ label: 'Ajustes' }, { label: 'Ajustes' },
], ],
'/seguridad': [ '/security': [
{ label: 'Inicio', to: '/' }, { label: 'Inicio', to: '/' },
{ label: 'Ajustes', to: '/settings' }, { label: 'Ajustes', to: '/settings' },
{ label: 'Seguridad' }, { label: 'Seguridad' },
], ],
'/settings/organizations': [
{ label: 'Inicio', to: '/' },
{ label: 'Ajustes', to: '/settings' },
{ label: 'Organizaciones' },
],
'/profile': [ '/profile': [
{ label: 'Inicio', to: '/' }, { label: 'Inicio', to: '/' },
{ label: 'Mi perfil' }, { label: 'Mi perfil' },

View File

@@ -1,24 +1,8 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '../../context/ThemeProvider'
import { Logo } from '../brand' import { Logo } from '../brand'
import { Breadcrumb } from './Breadcrumb' import { Breadcrumb } from './Breadcrumb'
import { ThemeToggle } from './ThemeToggle'
import { UserMenu } from './UserMenu' import { UserMenu } from './UserMenu'
function ThemeToggle() {
const { isDark, setTheme } = useTheme()
return (
<button
type="button"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
aria-label={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
className="flex size-9 items-center justify-center rounded-full border border-border text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
)
}
export function Header() { export function Header() {
return ( return (
<header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur"> <header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur">

View File

@@ -0,0 +1,7 @@
export function PublicFooter() {
return (
<footer className="border-t border-border px-4 py-4 text-center text-xs text-foreground/40">
Gruperly — Gestión sencilla de cobros y grupos
</footer>
)
}

View File

@@ -0,0 +1,14 @@
import { Link } from '@tanstack/react-router'
import { Logo } from '../brand'
import { ThemeToggle } from './ThemeToggle'
export function PublicHeader() {
return (
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/80 px-4 py-3 backdrop-blur-md sm:px-8">
<Link to="/" className="flex items-center gap-2">
<Logo className="h-7 w-auto" />
</Link>
<ThemeToggle />
</header>
)
}

View File

@@ -1,9 +1,9 @@
import { Outlet } from '@tanstack/react-router' import type { ReactNode } from 'react'
import { Header } from './Header' import { Header } from './Header'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { BottomNav } from './BottomNav' import { BottomNav } from './BottomNav'
export function RootLayout() { export function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="min-h-dvh w-full"> <div className="min-h-dvh w-full">
<Header /> <Header />
@@ -11,12 +11,10 @@ export function RootLayout() {
<div className="mx-auto flex w-full max-w-7xl gap-6 lg:px-6"> <div className="mx-auto flex w-full max-w-7xl gap-6 lg:px-6">
<Sidebar /> <Sidebar />
<main className="min-w-0 flex-1 px-4 pb-24 pt-4 lg:px-0 lg:pb-8 lg:pt-8"> <main className="min-w-0 flex-1 px-4 pb-24 pt-4 lg:px-0 lg:pb-8 lg:pt-8">{children}</main>
<Outlet />
</main>
</div> </div>
<BottomNav /> <BottomNav />
</div> </div>
) )
} }

View File

@@ -0,0 +1,17 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '../../context/ThemeProvider'
export function ThemeToggle() {
const { isDark, setTheme } = useTheme()
return (
<button
type="button"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
aria-label={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
className="flex size-9 items-center justify-center rounded-full border border-border text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
)
}

View File

@@ -1,5 +1,8 @@
export { RootLayout } from './RootLayout' export { RootLayout } from './RootLayout'
export { Header } from './Header' export { Header } from './Header'
export { ThemeToggle } from './ThemeToggle'
export { PublicHeader } from './PublicHeader'
export { PublicFooter } from './PublicFooter'
export { Sidebar } from './Sidebar' export { Sidebar } from './Sidebar'
export { BottomNav } from './BottomNav' export { BottomNav } from './BottomNav'
export { NAV_ITEMS, type NavItem } from './nav-items' export { NAV_ITEMS, type NavItem } from './nav-items'

View File

@@ -1,5 +0,0 @@
export { Stepper, type StepperProps } from './Stepper'
export { WelcomeStep } from './WelcomeStep'
export { PaymentStep } from './PaymentStep'
export { FirstGroupStep } from './FirstGroupStep'
export { ConfirmationStep } from './ConfirmationStep'

View File

@@ -0,0 +1,65 @@
import { createContext, useContext, useState } from 'react'
import type { ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import type { AbsenceDetails } from '@gruperly/shared'
import { useToast } from '../../components/ui'
import { ApiError, getAbsenceDetails, publicNotifyAbsence } from '../../lib/api'
type AbsenceNotifyContextValue = {
token: string
details: AbsenceDetails | undefined
detailsPending: boolean
detailsFailed: boolean
isNotified: (session: { notified: boolean; sessionId: string }) => boolean
isNotifying: (sessionId: string) => boolean
notify: (sessionId: string) => void
}
const AbsenceNotifyContext = createContext<AbsenceNotifyContextValue | null>(null)
export function AbsenceNotifyProvider({ token, children }: { token: string; children: ReactNode }) {
const toast = useToast()
const [locallyNotified, setLocallyNotified] = useState<string[]>([])
const detailsQuery = useQuery({
queryKey: ['absence-details', token],
queryFn: () => getAbsenceDetails(token),
enabled: Boolean(token),
retry: 1,
})
const notifyMutation = useMutation({
mutationFn: (sessionId: string) => publicNotifyAbsence({ token, classSessionId: sessionId }),
onSuccess: (_data, sessionId) => {
setLocallyNotified((prev) => [...prev, sessionId])
toast.success('Tu profesor ya tiene registrado el aviso. Gracias por avisar.', 'Ausencia avisada')
},
onError: (error: unknown) => {
if (error instanceof ApiError && error.problem?.status === 404) {
toast.error('Este enlace ya no es válido. Consultá a tu profesor.')
return
}
toast.error(error instanceof Error ? error.message : 'No pudimos registrar el aviso.')
},
})
const value: AbsenceNotifyContextValue = {
token,
details: detailsQuery.data,
detailsPending: detailsQuery.isPending,
detailsFailed: detailsQuery.isError,
isNotified: (session) => session.notified || locallyNotified.includes(session.sessionId),
isNotifying: (sessionId) => notifyMutation.isPending && notifyMutation.variables === sessionId,
notify: (sessionId) => notifyMutation.mutate(sessionId),
}
return <AbsenceNotifyContext.Provider value={value}>{children}</AbsenceNotifyContext.Provider>
}
export function useAbsenceNotify() {
const context = useContext(AbsenceNotifyContext)
if (!context) {
throw new Error('useAbsenceNotify debe usarse dentro de <AbsenceNotifyProvider>')
}
return context
}

View File

@@ -0,0 +1,71 @@
import { CalendarX2, Loader2 } from 'lucide-react'
import { PublicFooter, PublicHeader } from '../../components/layout'
import { Badge } from '../../components/ui'
import { AbsenceSessionRow } from './AbsenceSessionRow'
import { useAbsenceNotify } from './AbsenceNotifyProvider'
export function AbsenceNotifyView() {
const { details, detailsPending, detailsFailed } = useAbsenceNotify()
return (
<div className="flex min-h-screen flex-col justify-between bg-background text-primary selection:bg-accent selection:text-white">
<PublicHeader />
<main className="flex flex-1 items-center justify-center p-4 sm:p-6 md:p-10">
<div className="w-full max-w-lg space-y-6">
{detailsPending ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<Loader2 className="size-8 animate-spin text-accent" />
<p className="text-sm text-foreground/60">Cargando tus clases...</p>
</div>
) : null}
{detailsFailed || (!details && !detailsPending) ? (
<div className="space-y-3 rounded-2xl border border-danger/20 bg-danger-soft p-6 text-center sm:p-8">
<h2 className="text-xl font-bold text-danger">Enlace no válido</h2>
<p className="mx-auto max-w-sm text-sm leading-relaxed text-foreground/70">
No pudimos identificarte con este enlace. Consultá con tu profesor para solicitar uno nuevo.
</p>
</div>
) : null}
{details ? (
<div className="animate-step-enter space-y-5 rounded-2xl border border-border bg-surface p-6 shadow-xl sm:p-8">
<div className="border-b border-border pb-5 text-center">
<div className="mx-auto mb-3 flex size-14 items-center justify-center rounded-full bg-accent-soft text-accent">
<CalendarX2 className="size-7" />
</div>
<Badge variant="neutral" className="mb-2">
Aviso de ausencia
</Badge>
<h1 className="text-2xl font-bold text-primary">Hola, {details.fullName}</h1>
<p className="mt-1 text-sm font-medium text-foreground/80">
Grupo: <span className="text-primary">{details.groupName}</span>
</p>
</div>
{details.sessions.length === 0 ? (
<p className="rounded-xl border border-dashed border-border bg-primary-soft/40 px-4 py-8 text-center text-sm text-foreground/60">
No tenés clases programadas para hoy. Si necesitás avisar de todas formas, contactá a tu
profesor.
</p>
) : (
<ul className="space-y-3">
{details.sessions.map((session) => (
<AbsenceSessionRow key={session.sessionId} session={session} />
))}
</ul>
)}
<p className="text-center text-xs leading-relaxed text-foreground/50">
Si avisás con anticipación, tu cupo se libera para una clase de recuperación.
</p>
</div>
) : null}
</div>
</main>
<PublicFooter />
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { CheckCircle2, Loader2 } from 'lucide-react'
import { Button } from '../../components/ui'
import { useAbsenceNotify } from './AbsenceNotifyProvider'
type AbsenceSession = {
sessionId: string
startsAt: string
groupName: string
notified: boolean
}
export function AbsenceSessionRow({ session }: { session: AbsenceSession }) {
const { isNotified, isNotifying, notify } = useAbsenceNotify()
const time = new Date(session.startsAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return (
<li className="flex items-center justify-between gap-3 rounded-xl border border-border bg-primary-soft/30 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-primary">Hoy · {time} hs</p>
<p className="truncate text-xs text-foreground/60">{session.groupName}</p>
</div>
{isNotified(session) ? (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success">
<CheckCircle2 className="size-3.5" />
Ya avisaste
</span>
) : (
<Button
variant="primary"
size="sm"
disabled={isNotifying(session.sessionId)}
onClick={() => notify(session.sessionId)}
>
{isNotifying(session.sessionId) ? <Loader2 className="size-4 animate-spin" /> : null}
Avisar ausencia
</Button>
)}
</li>
)
}

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Logo } from '../brand' import { Logo } from '../../components/brand'
export type AuthShellProps = { export type AuthShellProps = {
title: string title: string

View File

@@ -5,7 +5,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
import { Fingerprint, Loader2 } from 'lucide-react' import { Fingerprint, Loader2 } from 'lucide-react'
import { authClient } from '../../lib/auth-client' import { authClient } from '../../lib/auth-client'
import { Button, Input, Label } from '../../components/ui' import { Button, Input, Label } from '../../components/ui'
import { AuthShell } from '../../components/auth' import { AuthShell } from './AuthShell'
const schema = z.object({ const schema = z.object({
email: z.string().email('Ingresá un email válido'), email: z.string().email('Ingresá un email válido'),

View File

@@ -5,7 +5,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { authClient } from '../../lib/auth-client' import { authClient } from '../../lib/auth-client'
import { Button, Input, Label } from '../../components/ui' import { Button, Input, Label } from '../../components/ui'
import { AuthShell } from '../../components/auth' import { AuthShell } from './AuthShell'
const schema = z const schema = z
.object({ .object({

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { Link, useNavigate, useSearch } from '@tanstack/react-router'
import { MailCheck, MailWarning, Loader2 } from 'lucide-react' import { MailCheck, MailWarning, Loader2 } from 'lucide-react'
import { authClient } from '../../lib/auth-client' import { authClient } from '../../lib/auth-client'
import { AuthShell } from '../../components/auth' import { AuthShell } from './AuthShell'
type SearchParams = { type SearchParams = {
token?: string token?: string

Some files were not shown because too many files have changed in this diff Show More