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:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user