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.
This commit is contained in:
Jose Selesan
2026-09-22 16:28:54 -03:00
parent a937c827bb
commit 785d54df7e
16 changed files with 848 additions and 31 deletions

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 body = c.req.valid('json') as CreateAttendee;
const allowOverflow = c.req.query('allowOverflow') === 'true';
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 });
});

View File

@@ -1,12 +1,22 @@
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 { conflictProblem, noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
import {
alreadyWaitlistedProblem,
capacityReachedProblem,
conflictProblem,
noGroupAccessProblem,
notFoundResourceProblem,
} from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
type CreateAttendeeDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee'>;
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
};
type CreateAttendeeOptions = {
allowOverflow?: boolean;
};
export class CreateAttendeeUseCase {
@@ -16,7 +26,8 @@ export class CreateAttendeeUseCase {
groupId: string,
userId: string,
payload: CreateAttendee,
): Promise<Result<AttendeeDto, ProblemDetails>> {
options: CreateAttendeeOptions = {},
): Promise<Result<CreateAttendeeResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const group = await db.group.findUnique({
@@ -57,6 +68,42 @@ export class CreateAttendeeUseCase {
}
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
if (group.capacity !== null) {
const currentCount = await db.attendee.count({ where: { groupId } });
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({
data: {
groupId,
@@ -67,6 +114,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

@@ -1,4 +1,5 @@
import { Hono } from 'hono';
import addToGroupWaitlistRoute from '../attendees/features/add-to-waitlist/route';
import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
import createAttendeeRoute from '../attendees/features/create/route';
import createFromOrganizationRoute from './features/create-from-organization/route';
@@ -16,5 +17,6 @@ routes.route('/', listAttendeesRoute);
routes.route('/', createAttendeeRoute);
routes.route('/', bulkCreateAttendeesRoute);
routes.route('/', getByIdRoute);
routes.route('/', addToGroupWaitlistRoute);
export default routes;

View File

@@ -6,12 +6,13 @@ import prisma from '@/lib/prisma';
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers';
type JoinViaInviteDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee'>;
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
};
export type JoinViaInviteResult = {
attendee: AttendeeDto;
status: 'registered' | 'waitlisted';
message: string;
attendee: AttendeeDto | null;
};
export class JoinViaInvite {
@@ -52,6 +53,35 @@ 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 } });
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({
data: {
groupId: group.id,
@@ -62,8 +92,9 @@ export class JoinViaInvite {
});
return ok({
status: 'registered',
attendee: toAttendeeDto(created as unknown as AttendeeRecord),
message: 'Inscripción realizada con éxito',
});
}
}
}