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

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