diff --git a/apps/backend/prisma/migrations/20260922191756_add_group_waitlist/migration.sql b/apps/backend/prisma/migrations/20260922191756_add_group_waitlist/migration.sql new file mode 100644 index 0000000..a1dc578 --- /dev/null +++ b/apps/backend/prisma/migrations/20260922191756_add_group_waitlist/migration.sql @@ -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; diff --git a/apps/backend/prisma/models/domain.prisma b/apps/backend/prisma/models/domain.prisma index a9b001e..09a8fdb 100644 --- a/apps/backend/prisma/models/domain.prisma +++ b/apps/backend/prisma/models/domain.prisma @@ -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 diff --git a/apps/backend/src/http/problem-builders.ts b/apps/backend/src/http/problem-builders.ts index 282283b..afba39f 100644 --- a/apps/backend/src/http/problem-builders.ts +++ b/apps/backend/src/http/problem-builders.ts @@ -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', + }); } \ No newline at end of file diff --git a/apps/backend/src/modules/attendees/features/add-to-waitlist/route.ts b/apps/backend/src/modules/attendees/features/add-to-waitlist/route.ts new file mode 100644 index 0000000..e03c04e --- /dev/null +++ b/apps/backend/src/modules/attendees/features/add-to-waitlist/route.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/attendees/features/add-to-waitlist/use-case.ts b/apps/backend/src/modules/attendees/features/add-to-waitlist/use-case.ts new file mode 100644 index 0000000..764b629 --- /dev/null +++ b/apps/backend/src/modules/attendees/features/add-to-waitlist/use-case.ts @@ -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; +}; + +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> { + 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)); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/attendees/features/create/route.ts b/apps/backend/src/modules/attendees/features/create/route.ts index aa66997..8f6f33a 100644 --- a/apps/backend/src/modules/attendees/features/create/route.ts +++ b/apps/backend/src/modules/attendees/features/create/route.ts @@ -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 }); }); diff --git a/apps/backend/src/modules/attendees/features/create/use-case.ts b/apps/backend/src/modules/attendees/features/create/use-case.ts index e38bc34..da27a42 100644 --- a/apps/backend/src/modules/attendees/features/create/use-case.ts +++ b/apps/backend/src/modules/attendees/features/create/use-case.ts @@ -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; + db?: Pick; +}; + +type CreateAttendeeOptions = { + allowOverflow?: boolean; }; export class CreateAttendeeUseCase { @@ -16,7 +26,8 @@ export class CreateAttendeeUseCase { groupId: string, userId: string, payload: CreateAttendee, - ): Promise> { + options: CreateAttendeeOptions = {}, + ): Promise> { 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), + }); } -} +} \ No newline at end of file diff --git a/apps/backend/src/modules/groups/routes.ts b/apps/backend/src/modules/groups/routes.ts index f9816a9..ac1d671 100644 --- a/apps/backend/src/modules/groups/routes.ts +++ b/apps/backend/src/modules/groups/routes.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/invitations/features/join-via-invite/use-case.ts b/apps/backend/src/modules/invitations/features/join-via-invite/use-case.ts index e3c094d..69a0497 100644 --- a/apps/backend/src/modules/invitations/features/join-via-invite/use-case.ts +++ b/apps/backend/src/modules/invitations/features/join-via-invite/use-case.ts @@ -6,12 +6,13 @@ import prisma from '@/lib/prisma'; import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers'; type JoinViaInviteDeps = { - db?: Pick; + db?: Pick; }; 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', }); } -} +} \ No newline at end of file diff --git a/apps/backend/test/attendees-incorporation.test.ts b/apps/backend/test/attendees-incorporation.test.ts index 7935104..582124b 100644 --- a/apps/backend/test/attendees-incorporation.test.ts +++ b/apps/backend/test/attendees-incorporation.test.ts @@ -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)', () => { diff --git a/apps/backend/test/invitations.test.ts b/apps/backend/test/invitations.test.ts index 01310fb..407963b 100644 --- a/apps/backend/test/invitations.test.ts +++ b/apps/backend/test/invitations.test.ts @@ -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', + }, + }); + }); }); }); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6b925fb..6da61ab 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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(`/api/v1/invitations/${token}`) export const joinViaInvite = (token: string, payload: JoinGroupViaInvite) => - apiFetch<{ attendee: AttendeeDto; message: string }>(`/api/v1/invitations/${token}/join`, { + apiFetch(`/api/v1/invitations/${token}/join`, { method: 'POST', body: JSON.stringify(payload), }) -export const createAttendee = (groupId: string, payload: CreateAttendee) => - apiFetch(`/api/v1/groups/${groupId}/attendees`, { +export const createAttendee = ( + groupId: string, + payload: CreateAttendee, + options?: { allowOverflow?: boolean }, +) => + apiFetch( + `/api/v1/groups/${groupId}/attendees${options?.allowOverflow ? '?allowOverflow=true' : ''}`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ) + +export const addToGroupWaitlist = (groupId: string, payload: CreateGroupWaitlistEntry) => + apiFetch(`/api/v1/groups/${groupId}/waitlist`, { method: 'POST', body: JSON.stringify(payload), }) diff --git a/apps/web/src/routes/group-detail.tsx b/apps/web/src/routes/group-detail.tsx index 1c062ae..7e09c27 100644 --- a/apps/web/src/routes/group-detail.tsx +++ b/apps/web/src/routes/group-detail.tsx @@ -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(null); + // Capacity & waitlist state + const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false); + const [pendingCapacityPayload, setPendingCapacityPayload] = useState(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() { + {/* MODAL: CUPO ALCANZADO (ALTA INDIVIDUAL) */} + { + setPendingCapacityPayload(null); + setIsCapacityModalOpen(false); + }} + title="Cupo alcanzado" + description="El grupo llegó a su cupo máximo de miembros." + maxWidth="md" + > + {pendingCapacityPayload ? ( +
+

+ El grupo {group.name} ya alcanzó su cupo de{' '} + {group.capacity} miembros. ¿Qué deseas hacer con{' '} + + {`${pendingCapacityPayload.firstName.trim()} ${pendingCapacityPayload.lastName.trim()}`.trim()} + + ? +

+
+ + +
+

+ Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo. +

+
+ ) : null} +
+ + {/* MODAL: AVISO DE CUPO EN CARGA MASIVA */} + setIsBulkCapacityModalOpen(false)} + title="Aviso de cupo" + description="La carga supera el cupo del grupo." + maxWidth="md" + > +
+

+ Estás por importar {validRowsCount} miembro(s), pero el grupo ya + tiene {attendeesTotal} inscrito(s) y su cupo es de{' '} + {group.capacity}. ¿Deseas importarlos de todos modos? +

+
+ + +
+
+
+ {/* MODAL: DETALLE DEL PARTICIPANTE */} (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() { ) : null} + {/* Waitlist Confirmation (group full) */} + {waitlistInfo && group ? ( +
+
+ +
+ +
+ + Lista de Espera + +

Cupo completo

+

+ {waitlistInfo.message} +

+
+ +

+ El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '} + {group.name}. +

+
+ ) : null} + {/* Inscription Form */} - {!registeredAttendee && group ? ( + {!registeredAttendee && !waitlistInfo && group ? (
{/* Group Info Header */}
diff --git a/packages/shared/src/schemas/attendees.ts b/packages/shared/src/schemas/attendees.ts index 35f5ad0..cbca23a 100644 --- a/packages/shared/src/schemas/attendees.ts +++ b/packages/shared/src/schemas/attendees.ts @@ -42,6 +42,18 @@ export const CreateAttendeeSchema = z.object({ }); export type CreateAttendee = z.output; +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; + 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; +export const JoinGroupViaInviteResultSchema = z.object({ + status: z.enum(['registered', 'waitlisted']), + message: z.string(), + attendee: AttendeeDtoSchema.nullable(), +}); +export type JoinGroupViaInviteResult = z.output; + export const BulkAttendeeItemSchema = z.object({ firstName: z.string().trim().min(1, 'El nombre es obligatorio'), lastName: z.string().trim().optional().default(''), diff --git a/packages/shared/src/schemas/waitlist.ts b/packages/shared/src/schemas/waitlist.ts index fdf3fab..42e8395 100644 --- a/packages/shared/src/schemas/waitlist.ts +++ b/packages/shared/src/schemas/waitlist.ts @@ -28,4 +28,26 @@ export const WaitlistListSchema = z.object({ data: z.array(WaitlistEntryDtoSchema), pagination: paginationSchema, }); -export type WaitlistList = z.output; \ No newline at end of file +export type WaitlistList = z.output; + +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; + +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; \ No newline at end of file