feat: add absence notification and analytics features

- Implemented AbsenceNotifyView for notifying absence with session details.
- Created AnalyticsView for displaying group analytics and managing students at risk.
- Added ClassAttendanceView for marking attendance in classes.
- Defined new schemas for analytics and attendance in shared package.
This commit is contained in:
Jose Selesan
2026-09-23 16:24:45 -03:00
parent a363e9e8fc
commit 2e184845e1
59 changed files with 3854 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { default as studentStatusRoutes } from './features/update-attendee-status/route';
export { default as analyticsRoutes } from './routes';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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