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,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

@@ -19,6 +19,7 @@ model Group {
members GroupMember[]
attendees Attendee[]
payments Payment[]
waitlist GroupWaitlistEntry[]
@@map("groups")
}
@@ -120,6 +121,24 @@ model WaitlistEntry {
@@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 {
PENDING
INVITED

View File

@@ -100,4 +100,20 @@ export function onboardingAlreadyCompletedProblem(): ProblemDetails {
detail: 'Ya completaste el onboarding de Gruperly.',
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',
});
}

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',
});
}
}
}

View File

@@ -14,6 +14,12 @@ const db = {
create: mock(),
count: mock(),
},
groupWaitlistEntry: {
findMany: mock(),
findFirst: mock(),
create: mock(),
count: mock(),
},
};
mock.module('@/lib/prisma', () => ({
@@ -130,8 +136,9 @@ describe('attendee incorporation & invite-token in groups', () => {
expect(res.status).toBe(201);
const data = await res.json();
expect(data.fullName).toBe('Martín Gómez');
expect(data.phone).toBe('+5491133445566');
expect(data.outcome).toBe('created');
expect(data.attendee.fullName).toBe('Martín Gómez');
expect(data.attendee.phone).toBe('+5491133445566');
expect(prisma.attendee.create).toHaveBeenCalledWith({
data: {
groupId: 'group-1',
@@ -161,6 +168,244 @@ describe('attendee incorporation & invite-token in groups', () => {
expect(res.status).toBe(409);
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)', () => {

View File

@@ -8,6 +8,11 @@ const db = {
attendee: {
findFirst: mock(),
create: mock(),
count: mock(),
},
groupWaitlistEntry: {
findFirst: mock(),
create: mock(),
},
};
@@ -151,5 +156,48 @@ describe('public invitations routes', () => {
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

@@ -6,13 +6,17 @@ import type {
ConnectPayment,
ConnectPaymentResult,
CreateAttendee,
CreateAttendeeResult,
CreateFirstGroup,
CreateFirstGroupResult,
CreateGroupWaitlistEntry,
GroupDto,
GroupInviteInfoDto,
GroupList,
GroupWaitlistEntryDto,
InviteTokenResult,
JoinGroupViaInvite,
JoinGroupViaInviteResult,
OnboardingStatusDto,
ProblemDetails,
} from '@gruperly/shared'
@@ -87,13 +91,26 @@ export const getInviteInfo = (token: string) =>
apiFetch<GroupInviteInfoDto>(`/api/v1/invitations/${token}`)
export const joinViaInvite = (token: string, payload: JoinGroupViaInvite) =>
apiFetch<{ attendee: AttendeeDto; message: string }>(`/api/v1/invitations/${token}/join`, {
apiFetch<JoinGroupViaInviteResult>(`/api/v1/invitations/${token}/join`, {
method: 'POST',
body: JSON.stringify(payload),
})
export const createAttendee = (groupId: string, payload: CreateAttendee) =>
apiFetch<AttendeeDto>(`/api/v1/groups/${groupId}/attendees`, {
export const createAttendee = (
groupId: string,
payload: CreateAttendee,
options?: { allowOverflow?: boolean },
) =>
apiFetch<CreateAttendeeResult>(
`/api/v1/groups/${groupId}/attendees${options?.allowOverflow ? '?allowOverflow=true' : ''}`,
{
method: 'POST',
body: JSON.stringify(payload),
},
)
export const addToGroupWaitlist = (groupId: string, payload: CreateGroupWaitlistEntry) =>
apiFetch<GroupWaitlistEntryDto>(`/api/v1/groups/${groupId}/waitlist`, {
method: 'POST',
body: JSON.stringify(payload),
})

View File

@@ -1,7 +1,7 @@
import { useState, useRef, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate, Link } from '@tanstack/react-router';
import type { AttendeeDto } from '@gruperly/shared';
import type { AttendeeDto, CreateAttendee, CreateAttendeeResult, CreateGroupWaitlistEntry } from '@gruperly/shared';
import {
ArrowLeft,
CalendarClock,
@@ -26,9 +26,10 @@ import {
UserPlus,
Users,
} from 'lucide-react';
import type { CreateAttendee } from '@gruperly/shared';
import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui';
import {
addToGroupWaitlist,
ApiError,
bulkCreateAttendees,
createAttendee,
getGroup,
@@ -70,6 +71,11 @@ export function GroupDetailView() {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Capacity & waitlist state
const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false);
const [pendingCapacityPayload, setPendingCapacityPayload] = useState<CreateAttendee | null>(null);
const [isBulkCapacityModalOpen, setIsBulkCapacityModalOpen] = useState(false);
// Attendees search filter
const [searchFilter, setSearchFilter] = useState('');
@@ -105,23 +111,72 @@ export function GroupDetailView() {
});
// Quick add attendee mutation
const resetQuickForm = () => {
setFirstName('');
setLastName('');
setPhone('');
setEmail('');
setNotes('');
};
const closeAddAttendeeFlow = () => {
resetQuickForm();
setPendingCapacityPayload(null);
setIsCapacityModalOpen(false);
setIsAddAttendeeModalOpen(false);
};
const handleCreateSuccess = (result: CreateAttendeeResult) => {
if (result.outcome === 'created') {
toast.success(`Miembro ${result.attendee.fullName} agregado con éxito.`);
} else {
toast.info(result.message);
}
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
closeAddAttendeeFlow();
};
const createAttendeeMutation = useMutation({
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
onSuccess: (created) => {
toast.success(`Miembro ${created.fullName} agregado con éxito.`);
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
setFirstName('');
setLastName('');
setPhone('');
setEmail('');
setNotes('');
setIsAddAttendeeModalOpen(false);
onMutate: (payload) => setPendingCapacityPayload(payload),
onSuccess: handleCreateSuccess,
onError: (err: Error) => {
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
setIsCapacityModalOpen(true);
return;
}
toast.error(err.message || 'Error al agregar miembro.');
},
});
// Force add (owner overrides the full capacity)
const createAttendeeForceMutation = useMutation({
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }),
onSuccess: handleCreateSuccess,
onError: (err: Error) => {
toast.error(err.message || 'Error al agregar miembro.');
},
});
// Add to group waitlist
const addToWaitlistMutation = useMutation({
mutationFn: (payload: CreateGroupWaitlistEntry) => addToGroupWaitlist(groupId, payload),
onSuccess: (entry) => {
toast.info(`${entry.fullName} fue agregado a la lista de espera del grupo.`);
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
closeAddAttendeeFlow();
},
onError: (err: Error) => {
if (err instanceof ApiError && err.problem?.code === 'already_waitlisted') {
toast.info('Este número ya está en la lista de espera del grupo.');
} else {
toast.error(err.message || 'Error al agregar a la lista de espera.');
}
setPendingCapacityPayload(null);
setIsCapacityModalOpen(false);
},
});
// Bulk import mutation
const bulkImportMutation = useMutation({
mutationFn: (attendees: Array<{ firstName: string; lastName: string; fullName: string; phone: string; email?: string; notes?: string }>) =>
@@ -240,7 +295,7 @@ export function GroupDetailView() {
setParsedContacts((prev) => prev.filter((c) => c.id !== id));
};
const handleConfirmBulkImport = () => {
const doBulkImport = () => {
const validRows = parsedContacts.filter((c) => c.isValid);
if (validRows.length === 0) {
toast.error('No hay miembros válidos para importar.');
@@ -258,8 +313,21 @@ export function GroupDetailView() {
);
};
const handleConfirmBulkImport = () => {
if (validRowsCount === 0) {
toast.error('No hay miembros válidos para importar.');
return;
}
if (group?.capacity != null && attendeesTotal + validRowsCount > group.capacity) {
setIsBulkCapacityModalOpen(true);
return;
}
doBulkImport();
};
const group = groupQuery.data;
const attendees = attendeesQuery.data?.data ?? [];
const attendeesTotal = attendeesQuery.data?.pagination?.total ?? attendees.length;
const filteredAttendees = attendees.filter((a) => {
const q = searchFilter.toLowerCase();
return (
@@ -880,6 +948,95 @@ export function GroupDetailView() {
</div>
</Modal>
{/* MODAL: CUPO ALCANZADO (ALTA INDIVIDUAL) */}
<Modal
isOpen={isCapacityModalOpen}
onClose={() => {
setPendingCapacityPayload(null);
setIsCapacityModalOpen(false);
}}
title="Cupo alcanzado"
description="El grupo llegó a su cupo máximo de miembros."
maxWidth="md"
>
{pendingCapacityPayload ? (
<div className="space-y-4">
<p className="text-sm text-foreground/70">
El grupo <strong className="text-primary">{group.name}</strong> ya alcanzó su cupo de{' '}
<strong className="text-primary">{group.capacity}</strong> miembros. ¿Qué deseas hacer con{' '}
<strong className="text-primary">
{`${pendingCapacityPayload.firstName.trim()} ${pendingCapacityPayload.lastName.trim()}`.trim()}
</strong>
?
</p>
<div className="flex flex-col gap-2.5">
<Button
variant="primary"
onClick={() => createAttendeeForceMutation.mutate({ ...pendingCapacityPayload })}
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
className="gap-2"
>
{createAttendeeForceMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserPlus className="size-4" />
)}
<span>Agregar de todos modos</span>
</Button>
<Button
variant="outline"
onClick={() => addToWaitlistMutation.mutate({ ...pendingCapacityPayload })}
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
className="gap-2"
>
{addToWaitlistMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Clock className="size-4" />
)}
<span>Sumar a la lista de espera</span>
</Button>
</div>
<p className="text-[11px] text-foreground/50 text-center">
Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo.
</p>
</div>
) : null}
</Modal>
{/* MODAL: AVISO DE CUPO EN CARGA MASIVA */}
<Modal
isOpen={isBulkCapacityModalOpen}
onClose={() => setIsBulkCapacityModalOpen(false)}
title="Aviso de cupo"
description="La carga supera el cupo del grupo."
maxWidth="md"
>
<div className="space-y-4">
<p className="text-sm text-foreground/70">
Estás por importar <strong className="text-primary">{validRowsCount}</strong> miembro(s), pero el grupo ya
tiene <strong className="text-primary">{attendeesTotal}</strong> inscrito(s) y su cupo es de{' '}
<strong className="text-primary">{group.capacity}</strong>. ¿Deseas importarlos de todos modos?
</p>
<div className="flex items-center justify-end gap-2.5 pt-2">
<Button variant="ghost" onClick={() => setIsBulkCapacityModalOpen(false)}>
Cancelar
</Button>
<Button
variant="primary"
onClick={() => {
setIsBulkCapacityModalOpen(false);
doBulkImport();
}}
className="gap-2"
>
<Check className="size-4" />
Importar de todos modos
</Button>
</div>
</div>
</Modal>
{/* MODAL: DETALLE DEL PARTICIPANTE */}
<Modal
isOpen={selectedAttendee !== null}

View File

@@ -4,6 +4,7 @@ import { useParams, Link } from '@tanstack/react-router';
import {
CalendarClock,
CheckCircle2,
Clock,
GraduationCap,
Loader2,
Moon,
@@ -32,6 +33,9 @@ export function JoinGroupView() {
phone: string | null;
} | null>(null);
// Waitlist state (group is full)
const [waitlistInfo, setWaitlistInfo] = useState<{ message: string } | null>(null);
const inviteQuery = useQuery({
queryKey: ['public-invite', token],
queryFn: () => getInviteInfo(token),
@@ -49,9 +53,15 @@ export function JoinGroupView() {
}),
onSuccess: (data) => {
setErrorMessage(null);
if (data.status === 'waitlisted') {
setRegisteredAttendee(null);
setWaitlistInfo({ message: data.message });
return;
}
setWaitlistInfo(null);
setRegisteredAttendee({
fullName: data.attendee.fullName,
phone: data.attendee.phone,
fullName: data.attendee!.fullName,
phone: data.attendee!.phone,
});
},
onError: (error: unknown) => {
@@ -163,8 +173,32 @@ export function JoinGroupView() {
</div>
) : null}
{/* Waitlist Confirmation (group full) */}
{waitlistInfo && group ? (
<div className="rounded-2xl border border-accent/30 bg-surface p-6 sm:p-8 text-center shadow-xl space-y-5 animate-step-enter">
<div className="size-16 rounded-full bg-accent-soft text-accent flex items-center justify-center mx-auto">
<Clock className="size-10" />
</div>
<div>
<Badge variant="neutral" className="mb-2">
Lista de Espera
</Badge>
<h1 className="text-2xl font-bold text-primary">Cupo completo</h1>
<p className="mt-2 text-sm text-foreground/70 leading-relaxed">
{waitlistInfo.message}
</p>
</div>
<p className="text-xs text-foreground/60">
El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '}
<strong className="text-primary font-semibold">{group.name}</strong>.
</p>
</div>
) : null}
{/* Inscription Form */}
{!registeredAttendee && group ? (
{!registeredAttendee && !waitlistInfo && group ? (
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8 shadow-xl space-y-6">
{/* Group Info Header */}
<div className="border-b border-border pb-5">

View File

@@ -42,6 +42,18 @@ export const CreateAttendeeSchema = z.object({
});
export type CreateAttendee = z.output<typeof CreateAttendeeSchema>;
export const CreateAttendeeResultSchema = z.discriminatedUnion('outcome', [
z.object({
outcome: z.literal('created'),
attendee: AttendeeDtoSchema,
}),
z.object({
outcome: z.literal('waitlisted'),
message: z.string(),
}),
]);
export type CreateAttendeeResult = z.output<typeof CreateAttendeeResultSchema>;
export const JoinGroupViaInviteSchema = z.object({
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
lastName: z.string().trim().min(1, 'El apellido es obligatorio'),
@@ -50,6 +62,13 @@ export const JoinGroupViaInviteSchema = z.object({
});
export type JoinGroupViaInvite = z.output<typeof JoinGroupViaInviteSchema>;
export const JoinGroupViaInviteResultSchema = z.object({
status: z.enum(['registered', 'waitlisted']),
message: z.string(),
attendee: AttendeeDtoSchema.nullable(),
});
export type JoinGroupViaInviteResult = z.output<typeof JoinGroupViaInviteResultSchema>;
export const BulkAttendeeItemSchema = z.object({
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
lastName: z.string().trim().optional().default(''),

View File

@@ -28,4 +28,26 @@ export const WaitlistListSchema = z.object({
data: z.array(WaitlistEntryDtoSchema),
pagination: paginationSchema,
});
export type WaitlistList = z.output<typeof WaitlistListSchema>;
export type WaitlistList = z.output<typeof WaitlistListSchema>;
export const GroupWaitlistEntryDtoSchema = z.object({
id: z.string(),
groupId: z.string(),
fullName: z.string(),
phone: z.string(),
email: z.string().nullable(),
notes: z.string().nullable(),
status: waitlistStatusSchema,
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type GroupWaitlistEntryDto = z.output<typeof GroupWaitlistEntryDtoSchema>;
export const CreateGroupWaitlistEntrySchema = z.object({
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
lastName: z.string().trim().optional().default(''),
phone: z.string().trim().min(6, 'Ingresa un número de teléfono válido'),
email: z.string().trim().email('Email inválido').optional().or(z.literal('')),
notes: z.string().trim().optional(),
});
export type CreateGroupWaitlistEntry = z.output<typeof CreateGroupWaitlistEntrySchema>;