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