diff --git a/AGENTS.md b/AGENTS.md index 353cc9e..2205d2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push - `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`. - `src/logger.ts` — pino + pino-pretty. - Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `attendees`, `payments`, `waitlist` (listados paginados `{ data, pagination }`). -- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`); el backend llama a `/api/v1/groups/from-organization` (`src/routes/organizations.tsx`). +- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`). - `packages/shared` — Esquemas Zod (v3.24) + tipos + `Result` + Problem Details. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo); se resuelve vía el symlink de bun en `node_modules` (`@gruperly/shared` no está en `paths` de los tsconfig). El `paths` de los tsconfig solo mapea `@/*` → `src/*` y `@generated/*` → `generated/*`. - `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete). @@ -52,6 +52,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push - Nav (Inicio/Grupos/Cobros/Ajustes) vive en `apps/web/src/components/layout/nav-items.ts`; es la fuente única para `BottomNav` (móvil) y `Sidebar` (desktop) — no dupliques la lista. - **UI**: primitivos propios en `apps/web/src/components/ui/` (avatar, button, badge) más helper `cn()` en `src/lib/utils.ts` (clsx + tailwind-merge). Aunque `stack.md` mencione Shadcn, **todavía no está instalado** (sin Radix); úsalos directos. - Layout mobile-first: `RootLayout` usa columna `max-w-md` en móvil y dos columnas (Sidebar + contenido `max-w-6xl`) en `lg+`. +- **Sin "volver atrás" en mobile**: en ninguna vista se muestra un link/botón de navegación a la página anterior cuando se ve en mobile (se apoya en el gesto de back del dispositivo). Los links de "volver" solo en desktop: usar `hidden ... lg:inline-flex` (p. ej. `create-group`, `group-detail`) o `hidden lg:flex` (`Breadcrumb`). No confundir con botones "Volver" de pasos dentro de un wizard/formulario: esos sí se mantienen. ## Fuentes de contexto 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..b94f2c0 100644 --- a/apps/backend/src/http/problem-builders.ts +++ b/apps/backend/src/http/problem-builders.ts @@ -47,23 +47,6 @@ export function forbiddenProblem(params: { }; } -export function organizationNotFoundProblem(id: string): ProblemDetails { - return { - type: `${PROBLEM_DOMAIN}/problems/organization-not-found`, - title: 'Not Found', - status: 404, - detail: `Organization ${id} was not found.`, - code: 'organization_not_found', - }; -} - -export function groupOwnerRequiredProblem(): ProblemDetails { - return forbiddenProblem({ - detail: 'Only the organization owner can create the group.', - code: 'group_owner_required', - }); -} - export function databaseUnavailableProblem(params?: { instance?: string; }): ProblemDetails { @@ -100,4 +83,27 @@ 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', + }); +} + +export function attendeeHasPaymentsProblem(): ProblemDetails { + return conflictProblem({ + detail: 'Este miembro tiene cobros asociados. Gestiona o cancela sus cobros antes de quitarlo del grupo.', + code: 'attendee_has_payments', + }); } \ 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/attendees/features/remove/route.ts b/apps/backend/src/modules/attendees/features/remove/route.ts new file mode 100644 index 0000000..6596ffc --- /dev/null +++ b/apps/backend/src/modules/attendees/features/remove/route.ts @@ -0,0 +1,24 @@ +import { type RemoveAttendeeQuery, RemoveAttendeeQuerySchema } from '@gruperly/shared'; +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { validate } from '@/http/validate'; +import { RemoveAttendee } from './use-case'; + +const route = new Hono(); + +route.delete('/:groupId/attendees/:attendeeId', validate.query(RemoveAttendeeQuerySchema), async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + const groupId = c.req.param('groupId'); + const attendeeId = c.req.param('attendeeId'); + const query = c.req.valid('query') as RemoveAttendeeQuery; + const useCase = new RemoveAttendee(); + const result = await useCase.execute(groupId, attendeeId, user.id, { + promoteFromWaitlist: query.promoteFromWaitlist, + }); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/attendees/features/remove/use-case.ts b/apps/backend/src/modules/attendees/features/remove/use-case.ts new file mode 100644 index 0000000..584956b --- /dev/null +++ b/apps/backend/src/modules/attendees/features/remove/use-case.ts @@ -0,0 +1,73 @@ +import type { Prisma, PrismaClient } from '@generated/prisma/client'; +import type { ProblemDetails, RemoveAttendeeResult, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { attendeeHasPaymentsProblem, notFoundResourceProblem } from '@/http/problem-builders'; +import { default as prisma, UnitOfWork } from '@/lib/prisma'; +import { + findGroupOwnedByUser, + toGroupWaitlistEntryDto, +} from '@/modules/group-waitlist/lib/helpers'; +import { promoteFirstPendingWaitlistEntry } from '@/modules/group-waitlist/lib/promote'; + +type RemoveAttendeeDeps = { + db?: Pick; + unitOfWork?: UnitOfWork; +}; + +type RemoveAttendeeOptions = { + promoteFromWaitlist?: boolean; +}; + +export class RemoveAttendee { + constructor(private readonly deps: RemoveAttendeeDeps = {}) {} + + async execute( + groupId: string, + attendeeId: string, + userId: string, + options: RemoveAttendeeOptions = {}, + ): Promise> { + const db = this.deps.db ?? prisma; + const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma); + + const groupResult = await findGroupOwnedByUser(db, groupId, userId); + if (!groupResult.ok) { + return groupResult; + } + const group = groupResult.value; + + const attendee = await db.attendee.findFirst({ where: { id: attendeeId, groupId } }); + if (!attendee) { + return err(notFoundResourceProblem('Attendee', attendeeId)); + } + + const paymentsCount = await db.payment.count({ where: { attendeeId } }); + if (paymentsCount > 0) { + return err(attendeeHasPaymentsProblem()); + } + + return unitOfWork.executeResult( + async (tx: Prisma.TransactionClient): Promise> => { + await tx.attendee.delete({ where: { id: attendeeId } }); + + if (!options.promoteFromWaitlist) { + return ok({ removedAttendeeId: attendeeId, promoted: null }); + } + + const promoteResult = await promoteFirstPendingWaitlistEntry(tx, group); + if (!promoteResult.ok) { + return err(promoteResult.error); + } + if (!promoteResult.value) { + return ok({ removedAttendeeId: attendeeId, promoted: null }); + } + + await tx.groupWaitlistEntry.delete({ where: { id: promoteResult.value.entry.id } }); + return ok({ + removedAttendeeId: attendeeId, + promoted: toGroupWaitlistEntryDto(promoteResult.value.entry), + }); + }, + ); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/get-all/route.ts b/apps/backend/src/modules/group-waitlist/features/get-all/route.ts new file mode 100644 index 0000000..50e73df --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/get-all/route.ts @@ -0,0 +1,21 @@ +import { type GroupWaitlistQuery, GroupWaitlistQuerySchema } from '@gruperly/shared'; +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { validate } from '@/http/validate'; +import { ListGroupWaitlistEntries } from './use-case'; + +const route = new Hono(); + +route.get('/:groupId/waitlist', validate.query(GroupWaitlistQuerySchema), async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + const groupId = c.req.param('groupId'); + const query = c.req.valid('query') as GroupWaitlistQuery; + const useCase = new ListGroupWaitlistEntries(); + const result = await useCase.execute(groupId, user.id, query); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/get-all/use-case.ts b/apps/backend/src/modules/group-waitlist/features/get-all/use-case.ts new file mode 100644 index 0000000..c9522e6 --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/get-all/use-case.ts @@ -0,0 +1,43 @@ +import type { PrismaClient } from '@generated/prisma/client'; +import type { GroupWaitlistList, GroupWaitlistQuery, ProblemDetails, Result } from '@gruperly/shared'; +import { ok } from '@gruperly/shared'; +import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination'; +import prisma from '@/lib/prisma'; +import { findGroupForUser, type GroupWaitlistEntryRecord, toGroupWaitlistEntryDto } from '../../lib/helpers'; + +type ListGroupWaitlistEntriesDeps = { + db?: Pick; +}; + +export class ListGroupWaitlistEntries { + constructor(private readonly deps: ListGroupWaitlistEntriesDeps = {}) {} + + async execute( + groupId: string, + userId: string, + query: GroupWaitlistQuery, + ): Promise> { + const db = this.deps.db ?? prisma; + + const groupResult = await findGroupForUser(db, groupId, userId); + if (!groupResult.ok) { + return groupResult; + } + + const where = { groupId, status: 'PENDING' as const }; + const [records, total] = await Promise.all([ + db.groupWaitlistEntry.findMany({ + where, + skip: getPaginationOffset(query), + take: query.pageSize, + orderBy: { createdAt: 'asc' }, + }), + db.groupWaitlistEntry.count({ where }), + ]); + + return ok({ + data: records.map((record) => toGroupWaitlistEntryDto(record as unknown as GroupWaitlistEntryRecord)), + pagination: getPaginationMetadata(query, total), + }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/promote/route.ts b/apps/backend/src/modules/group-waitlist/features/promote/route.ts new file mode 100644 index 0000000..3b88eea --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/promote/route.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { PromoteGroupWaitlistEntry } from './use-case'; + +const route = new Hono(); + +route.post('/:groupId/waitlist/:entryId/promote', async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + const groupId = c.req.param('groupId'); + const entryId = c.req.param('entryId'); + const useCase = new PromoteGroupWaitlistEntry(); + const result = await useCase.execute(groupId, entryId, user.id); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/promote/use-case.ts b/apps/backend/src/modules/group-waitlist/features/promote/use-case.ts new file mode 100644 index 0000000..3e5fc09 --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/promote/use-case.ts @@ -0,0 +1,56 @@ +import type { Prisma, PrismaClient } from '@generated/prisma/client'; +import type { ProblemDetails, PromoteGroupWaitlistEntryResult, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { notFoundResourceProblem } from '@/http/problem-builders'; +import { default as prisma, UnitOfWork } from '@/lib/prisma'; +import { + findGroupOwnedByUser, + type GroupWaitlistEntryRecord, +} from '../../lib/helpers'; +import { promoteWaitlistEntryRecord } from '../../lib/promote'; + +type PromoteGroupWaitlistEntryDeps = { + db?: Pick; + unitOfWork?: UnitOfWork; +}; + +export class PromoteGroupWaitlistEntry { + constructor(private readonly deps: PromoteGroupWaitlistEntryDeps = {}) {} + + async execute( + groupId: string, + entryId: string, + userId: string, + ): Promise> { + const db = this.deps.db ?? prisma; + const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma); + + const groupResult = await findGroupOwnedByUser(db, groupId, userId); + if (!groupResult.ok) { + return groupResult; + } + + const entry = await db.groupWaitlistEntry.findFirst({ + where: { id: entryId, groupId }, + }); + if (!entry) { + return err(notFoundResourceProblem('GroupWaitlistEntry', entryId)); + } + + return unitOfWork.executeResult( + async (tx: Prisma.TransactionClient) => { + const promoteResult = await promoteWaitlistEntryRecord( + tx, + groupResult.value, + entry as unknown as GroupWaitlistEntryRecord, + ); + if (!promoteResult.ok) { + return promoteResult; + } + + await tx.groupWaitlistEntry.delete({ where: { id: entry.id } }); + return ok({ attendee: promoteResult.value }); + }, + ); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/remove/route.ts b/apps/backend/src/modules/group-waitlist/features/remove/route.ts new file mode 100644 index 0000000..a55c71b --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/remove/route.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { RemoveGroupWaitlistEntry } from './use-case'; + +const route = new Hono(); + +route.delete('/:groupId/waitlist/:entryId', async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + const groupId = c.req.param('groupId'); + const entryId = c.req.param('entryId'); + const useCase = new RemoveGroupWaitlistEntry(); + const result = await useCase.execute(groupId, entryId, user.id); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/remove/use-case.ts b/apps/backend/src/modules/group-waitlist/features/remove/use-case.ts new file mode 100644 index 0000000..11f25cd --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/remove/use-case.ts @@ -0,0 +1,37 @@ +import type { PrismaClient } from '@generated/prisma/client'; +import type { ProblemDetails, RemoveGroupWaitlistEntryResult, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { notFoundResourceProblem } from '@/http/problem-builders'; +import prisma from '@/lib/prisma'; +import { findGroupOwnedByUser } from '../../lib/helpers'; + +type RemoveGroupWaitlistEntryDeps = { + db?: Pick; +}; + +export class RemoveGroupWaitlistEntry { + constructor(private readonly deps: RemoveGroupWaitlistEntryDeps = {}) {} + + async execute( + groupId: string, + entryId: string, + userId: string, + ): Promise> { + const db = this.deps.db ?? prisma; + + const groupResult = await findGroupOwnedByUser(db, groupId, userId); + if (!groupResult.ok) { + return groupResult; + } + + const existing = await db.groupWaitlistEntry.findFirst({ + where: { id: entryId, groupId }, + }); + if (!existing) { + return err(notFoundResourceProblem('GroupWaitlistEntry', entryId)); + } + + await db.groupWaitlistEntry.delete({ where: { id: entryId } }); + return ok({ deleted: true }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/index.ts b/apps/backend/src/modules/group-waitlist/index.ts new file mode 100644 index 0000000..545f43d --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/index.ts @@ -0,0 +1 @@ +export { default as groupWaitlistRoutes } from './routes'; \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/lib/helpers.ts b/apps/backend/src/modules/group-waitlist/lib/helpers.ts new file mode 100644 index 0000000..907311d --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/lib/helpers.ts @@ -0,0 +1,65 @@ +import type { Group, PrismaClient } from '@generated/prisma/client'; +import type { GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders'; + +export type GroupWaitlistEntryRecord = { + id: string; + groupId: string; + fullName: string; + phone: string; + email: string | null; + notes: string | null; + status: GroupWaitlistEntryDto['status']; + createdAt: Date; + updatedAt: Date; +}; + +export function toGroupWaitlistEntryDto(record: GroupWaitlistEntryRecord): 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 async function findGroupForUser( + db: Pick, + groupId: string, + userId: string, +): Promise> { + 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()); + } + return ok(group); +} + +export async function findGroupOwnedByUser( + db: Pick, + groupId: string, + userId: string, +): Promise> { + const group = await db.group.findUnique({ where: { id: groupId } }); + if (!group) { + return err(notFoundResourceProblem('Group', groupId)); + } + if (group.createdById !== userId) { + return err(noGroupAccessProblem()); + } + return ok(group); +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/lib/promote.ts b/apps/backend/src/modules/group-waitlist/lib/promote.ts new file mode 100644 index 0000000..ee5a158 --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/lib/promote.ts @@ -0,0 +1,83 @@ +import type { PrismaClient } from '@generated/prisma/client'; +import type { AttendeeDto, ProblemDetails, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { capacityReachedProblem, conflictProblem } from '@/http/problem-builders'; +import { type AttendeeRecord, toAttendeeDto } from '@/modules/attendees/lib/helpers'; +import { type GroupWaitlistEntryRecord } from './helpers'; + +export type PromoteDb = Pick; + +type PromoteGroup = { + id: string; + capacity: number | null; +}; + +export async function findFirstPendingWaitlistEntry( + db: PromoteDb, + groupId: string, +): Promise { + const record = await db.groupWaitlistEntry.findFirst({ + where: { groupId, status: 'PENDING' }, + orderBy: { createdAt: 'asc' }, + }); + return record as GroupWaitlistEntryRecord | null; +} + +export async function promoteWaitlistEntryRecord( + db: PromoteDb, + group: PromoteGroup, + entry: GroupWaitlistEntryRecord, +): Promise> { + if (group.capacity !== null) { + const currentCount = await db.attendee.count({ where: { groupId: group.id } }); + if (currentCount >= group.capacity) { + return err(capacityReachedProblem(group.capacity)); + } + } + + const existing = await db.attendee.findFirst({ + where: { groupId: group.id, phone: entry.phone }, + }); + if (existing) { + return err( + conflictProblem({ + detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.', + code: 'attendee_already_registered', + }), + ); + } + + const attendee = await db.attendee.create({ + data: { + groupId: group.id, + fullName: entry.fullName, + phone: entry.phone, + email: entry.email, + notes: entry.notes, + }, + }); + + return ok(toAttendeeDto(attendee as unknown as AttendeeRecord)); +} + +export type PromoteFirstResult = { + attendee: AttendeeDto; + entry: GroupWaitlistEntryRecord; +}; + +export async function promoteFirstPendingWaitlistEntry( + db: PromoteDb, + group: PromoteGroup, +): Promise> { + const entry = await findFirstPendingWaitlistEntry(db, group.id); + if (!entry) { + return ok(null); + } + + const result = await promoteWaitlistEntryRecord(db, group, entry); + if (!result.ok) { + return result; + } + + return ok({ attendee: result.value, entry }); +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/routes.ts b/apps/backend/src/modules/group-waitlist/routes.ts new file mode 100644 index 0000000..41da6c1 --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/routes.ts @@ -0,0 +1,12 @@ +import { Hono } from 'hono'; +import getAllRoute from './features/get-all/route'; +import promoteRoute from './features/promote/route'; +import removeRoute from './features/remove/route'; + +const routes = new Hono(); + +routes.route('/', getAllRoute); +routes.route('/', promoteRoute); +routes.route('/', removeRoute); + +export default routes; \ No newline at end of file diff --git a/apps/backend/src/modules/groups/features/create-from-organization/route.ts b/apps/backend/src/modules/groups/features/create-from-organization/route.ts deleted file mode 100644 index 5b7fc90..0000000 --- a/apps/backend/src/modules/groups/features/create-from-organization/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { CreateGroupFromOrganization } from '@gruperly/shared'; -import { CreateGroupFromOrganizationSchema } from '@gruperly/shared'; -import { Hono } from 'hono'; -import { problemJson, unauthorizedProblem } from '@/http/problem-details'; -import { validate } from '@/http/validate'; -import { CreateGroupFromOrganization as UseCase } from './use-case'; - -const route = new Hono(); - -route.post('/', validate.json(CreateGroupFromOrganizationSchema), async (c) => { - const user = c.get('user'); - if (!user) { - return problemJson(c, unauthorizedProblem(c.req.path)); - } - - const data = c.req.valid('json') as CreateGroupFromOrganization; - const useCase = new UseCase(); - const result = await useCase.execute(data, user.id); - - if (!result.ok) { - return problemJson(c, result.error); - } - - return c.json(result.value, result.value.alreadyExists ? 200 : 201); -}); - -export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts b/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts deleted file mode 100644 index c3dceba..0000000 --- a/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { Prisma } from '@generated/prisma/client'; -import { Role } from '@generated/prisma/client'; -import type { - CreateGroupFromOrganization as CreateGroupFromOrganizationInput, - CreateGroupFromOrganizationResult, - ProblemDetails, - Result, -} from '@gruperly/shared'; -import { err, ok } from '@gruperly/shared'; -import { - groupOwnerRequiredProblem, - organizationNotFoundProblem, -} from '@/http/problem-builders'; -import { default as prisma, UnitOfWork } from '@/lib/prisma'; -import { type GroupDb, type GroupRecord, toGroupDto } from '../../lib'; - -type CreateGroupFromOrganizationDeps = { - db?: Pick; - unitOfWork?: UnitOfWork; -}; - -export class CreateGroupFromOrganization { - constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {} - - async execute( - data: CreateGroupFromOrganizationInput, - userId: string, - ): Promise> { - const db = this.deps.db ?? prisma; - const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma); - - const organization = await db.organization.findUnique({ - where: { id: data.organizationId }, - include: { members: true }, - }); - if (!organization) { - return err(organizationNotFoundProblem(data.organizationId)); - } - - const membership = organization.members.find((member: { userId: string; role: string }) => member.userId === userId); - if (membership?.role !== 'owner') { - return err(groupOwnerRequiredProblem()); - } - - const existing = await db.group.findFirst({ - where: { createdById: userId, name: organization.name }, - }); - if (existing) { - return ok({ group: toGroupDto(existing), alreadyExists: true }); - } - - const transaction = unitOfWork.executeResult( - async (tx: Prisma.TransactionClient) => { - const created = await tx.group.create({ - data: { - name: organization.name, - createdById: userId, - }, - }); - - await tx.groupMember.create({ - data: { - groupId: created.id, - userId, - role: Role.OWNER, - }, - }); - - return ok(created); - }, - ); - - const result = await transaction; - if (!result.ok) { - return result; - } - - return ok({ - group: toGroupDto(result.value as GroupRecord), - alreadyExists: false, - }); - } -} \ No newline at end of file diff --git a/apps/backend/src/modules/groups/features/create/route.ts b/apps/backend/src/modules/groups/features/create/route.ts new file mode 100644 index 0000000..39c8631 --- /dev/null +++ b/apps/backend/src/modules/groups/features/create/route.ts @@ -0,0 +1,22 @@ +import type { CreateFirstGroup } from '@gruperly/shared'; +import { CreateFirstGroupSchema } from '@gruperly/shared'; +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { validate } from '@/http/validate'; +import { CreateGroup as CreateGroupUseCase } from './use-case'; + +const route = new Hono(); + +route.post('/', validate.json(CreateFirstGroupSchema), async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + + const data = c.req.valid('json') as CreateFirstGroup; + const useCase = new CreateGroupUseCase(); + const result = await useCase.execute(data, user.id); + return resultJson(c, result, { status: 201 }); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/groups/features/create/use-case.ts b/apps/backend/src/modules/groups/features/create/use-case.ts new file mode 100644 index 0000000..d731a85 --- /dev/null +++ b/apps/backend/src/modules/groups/features/create/use-case.ts @@ -0,0 +1,68 @@ +import type { Prisma, PrismaClient } from '@generated/prisma/client'; +import { Role } from '@generated/prisma/client'; +import type { + CreateFirstGroup as CreateGroupInput, + CreateGroupResult, + ProblemDetails, + Result, +} from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { paymentNotSetupProblem } from '@/http/problem-builders'; +import { default as prisma, UnitOfWork } from '@/lib/prisma'; +import { type GroupRecord, toGroupDto } from '../../lib'; + +type CreateGroupDeps = { + db?: Pick; + unitOfWork?: UnitOfWork; +}; + +export class CreateGroup { + constructor(private readonly deps: CreateGroupDeps = {}) {} + + async execute( + data: CreateGroupInput, + userId: string, + ): Promise> { + const db = this.deps.db ?? prisma; + const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma); + + const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } }); + if (!merchantAccount) { + return err(paymentNotSetupProblem()); + } + + const transaction = unitOfWork.executeResult( + async (tx: Prisma.TransactionClient) => { + const created = await tx.group.create({ + data: { + name: data.name, + createdById: userId, + days: data.days, + time: data.time, + capacity: data.capacity, + price: data.price, + billingType: data.billingType, + dueDay: data.dueDay, + }, + }); + + await tx.groupMember.create({ + data: { + groupId: created.id, + userId, + role: Role.OWNER, + }, + }); + + return ok(created); + }, + ); + + const result = await transaction; + if (!result.ok) { + return result; + } + + return ok({ group: toGroupDto(result.value as GroupRecord) }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/groups/lib/helpers.ts b/apps/backend/src/modules/groups/lib/helpers.ts index 3db8c8c..958e7ad 100644 --- a/apps/backend/src/modules/groups/lib/helpers.ts +++ b/apps/backend/src/modules/groups/lib/helpers.ts @@ -1,7 +1,7 @@ import type { PrismaClient } from '@generated/prisma/client'; import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared'; -export type GroupDb = Pick; +export type GroupDb = Pick; type PriceLike = { toString(): string }; diff --git a/apps/backend/src/modules/groups/routes.ts b/apps/backend/src/modules/groups/routes.ts index f9816a9..bfbc4d6 100644 --- a/apps/backend/src/modules/groups/routes.ts +++ b/apps/backend/src/modules/groups/routes.ts @@ -1,7 +1,10 @@ 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'; +import removeAttendeeRoute from '../attendees/features/remove/route'; +import groupWaitlistRoutes from '../group-waitlist/routes'; +import createGroupRoute from './features/create/route'; import getAllRoute from './features/get-all/route'; import getByIdRoute from './features/get-by-id/route'; import inviteTokenRoute from './features/invite-token/route'; @@ -10,11 +13,14 @@ import listAttendeesRoute from './features/list-attendees/route'; const routes = new Hono(); routes.route('/', getAllRoute); -routes.route('/from-organization', createFromOrganizationRoute); +routes.route('/', createGroupRoute); routes.route('/', inviteTokenRoute); routes.route('/', listAttendeesRoute); routes.route('/', createAttendeeRoute); routes.route('/', bulkCreateAttendeesRoute); routes.route('/', getByIdRoute); +routes.route('/', addToGroupWaitlistRoute); +routes.route('/', groupWaitlistRoutes); +routes.route('/', removeAttendeeRoute); 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/groups.test.ts b/apps/backend/test/groups.test.ts index 3a55ced..eaa1a53 100644 --- a/apps/backend/test/groups.test.ts +++ b/apps/backend/test/groups.test.ts @@ -4,14 +4,13 @@ import { Hono } from 'hono'; const db = { group: { findMany: mock(), - findFirst: mock(), count: mock(), create: mock(), }, groupMember: { create: mock(), }, - organization: { + merchantAccount: { findUnique: mock(), }, }; @@ -102,19 +101,29 @@ describe('groups routes', () => { expect(res.status).toBe(400); }); - it('creates a group from an owned organization', async () => { - const organization = { - id: 'org-1', - name: 'Escuela Alfa', - members: [{ userId, role: 'owner' }], - }; - prisma.organization.findUnique.mockResolvedValue(organization as never); - prisma.group.findFirst.mockResolvedValue(null); - prisma.group.create.mockResolvedValue(group); + it('creates a group with billing configuration', async () => { + prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'merchant-1', userId }); + prisma.group.create.mockResolvedValue({ + ...group, + days: ['MONDAY'], + time: '09:00', + capacity: 20, + price: 500, + billingType: 'MONTHLY', + dueDay: 5, + }); - const res = await makeApp({ id: userId }).request('/groups/from-organization', { + const res = await makeApp({ id: userId }).request('/groups', { method: 'POST', - body: JSON.stringify({ organizationId: 'org-1' }), + body: JSON.stringify({ + name: 'Cuadrilla Alfa', + days: ['MONDAY'], + time: '09:00', + capacity: 20, + price: 500, + billingType: 'MONTHLY', + dueDay: 5, + }), headers: { 'Content-Type': 'application/json' }, }); @@ -122,77 +131,63 @@ describe('groups routes', () => { expect(await res.json()).toEqual({ group: { ...group, + days: ['MONDAY'], + time: '09:00', + capacity: 20, + price: 500, + billingType: 'MONTHLY', + dueDay: 5, createdAt: group.createdAt.toISOString(), updatedAt: group.updatedAt.toISOString(), }, - alreadyExists: false, }); expect(prisma.group.create).toHaveBeenCalledWith({ - data: { name: 'Escuela Alfa', createdById: userId }, + data: { + name: 'Cuadrilla Alfa', + createdById: userId, + days: ['MONDAY'], + time: '09:00', + capacity: 20, + price: 500, + billingType: 'MONTHLY', + dueDay: 5, + }, + }); + expect(prisma.groupMember.create).toHaveBeenCalledWith({ + data: { groupId: 'group-1', userId, role: 'OWNER' }, }); }); - it('reuses an existing group with the same name', async () => { - const organization = { - id: 'org-1', - name: 'Escuela Alfa', - members: [{ userId, role: 'owner' }], - }; - prisma.organization.findUnique.mockResolvedValue(organization as never); - prisma.group.findFirst.mockResolvedValue(group); + it('rejects creating a group without a linked merchant account', async () => { + prisma.merchantAccount.findUnique.mockResolvedValue(null); - const res = await makeApp({ id: userId }).request('/groups/from-organization', { + const res = await makeApp({ id: userId }).request('/groups', { method: 'POST', - body: JSON.stringify({ organizationId: 'org-1' }), + body: JSON.stringify({ + name: 'Cuadrilla Alfa', + days: ['MONDAY'], + time: '09:00', + capacity: 20, + price: 500, + billingType: 'MONTHLY', + dueDay: 5, + }), headers: { 'Content-Type': 'application/json' }, }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.alreadyExists).toBe(true); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ code: 'payment_not_setup' }); expect(prisma.group.create).not.toHaveBeenCalled(); }); - it('rejects creating a group for an unknown organization', async () => { - prisma.organization.findUnique.mockResolvedValue(null); - - const res = await makeApp({ id: userId }).request('/groups/from-organization', { + it('rejects creating a group with an invalid payload', async () => { + const res = await makeApp({ id: userId }).request('/groups', { method: 'POST', - body: JSON.stringify({ organizationId: 'missing' }), + body: JSON.stringify({ name: 'x' }), headers: { 'Content-Type': 'application/json' }, }); - expect(res.status).toBe(404); - expect(await res.json()).toMatchObject({ code: 'organization_not_found' }); + expect(res.status).toBe(400); expect(prisma.group.create).not.toHaveBeenCalled(); }); - - it('rejects creating a group when the user is not the owner', async () => { - const organization = { - id: 'org-1', - name: 'Escuela Alfa', - members: [{ userId: 'other-user', role: 'owner' }], - }; - prisma.organization.findUnique.mockResolvedValue(organization as never); - - const res = await makeApp({ id: userId }).request('/groups/from-organization', { - method: 'POST', - body: JSON.stringify({ organizationId: 'org-1' }), - headers: { 'Content-Type': 'application/json' }, - }); - - expect(res.status).toBe(403); - expect(await res.json()).toMatchObject({ code: 'group_owner_required' }); - expect(prisma.group.create).not.toHaveBeenCalled(); - }); - - it('rejects creating a group without a session', async () => { - const res = await makeApp(null).request('/groups/from-organization', { - method: 'POST', - body: JSON.stringify({ organizationId: 'org-1' }), - headers: { 'Content-Type': 'application/json' }, - }); - - expect(res.status).toBe(401); - }); }); \ No newline at end of file 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/backend/test/waitlist-management.test.ts b/apps/backend/test/waitlist-management.test.ts new file mode 100644 index 0000000..76d1a06 --- /dev/null +++ b/apps/backend/test/waitlist-management.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, mock, vi } from 'bun:test'; +import { Hono } from 'hono'; + +const db = { + group: { + findUnique: mock(), + }, + attendee: { + findFirst: mock(), + findMany: mock(), + count: mock(), + create: mock(), + delete: mock(), + }, + groupWaitlistEntry: { + findMany: mock(), + findFirst: mock(), + count: mock(), + create: mock(), + delete: mock(), + }, + payment: { + count: mock(), + }, +}; + +mock.module('@/lib/prisma', () => ({ + default: db, + getPrismaClient: mock(), + UnitOfWork: class { + executeResult = mock(async (cb: (tx: unknown) => Promise) => cb(db)); + execute = mock(async (cb: (tx: unknown) => Promise) => cb(db)); + }, +})); + +import prisma from '@/lib/prisma'; +import { groupsRoutes } from '@/modules/groups'; + +const teacherId = 'teacher-1'; +const mockGroup = { + id: 'group-1', + name: 'Taller de Pintura', + description: null, + createdById: teacherId, + inviteToken: null, + createdAt: new Date('2026-09-01T10:00:00.000Z'), + updatedAt: new Date('2026-09-01T10:00:00.000Z'), + days: ['MONDAY'], + time: '18:00', + capacity: 20, + price: 1500, + billingType: 'MONTHLY', + dueDay: 10, + members: [], +}; + +const waitlistEntry = (overrides: Record = {}) => ({ + id: 'wl-1', + groupId: 'group-1', + fullName: 'Martín Gómez', + phone: '+5491133445566', + email: null, + notes: null, + status: 'PENDING', + createdAt: new Date('2026-09-10T10:00:00.000Z'), + updatedAt: new Date('2026-09-10T10:00:00.000Z'), + ...overrides, +}); + +const attendee = (overrides: Record = {}) => ({ + id: 'att-1', + groupId: 'group-1', + fullName: 'Martín Gómez', + phone: '+5491133445566', + email: null, + guardianName: null, + guardianPhone: null, + notes: null, + createdAt: new Date('2026-09-18T10:00:00.000Z'), + updatedAt: new Date('2026-09-18T10:00:00.000Z'), + ...overrides, +}); + +function makeApp(userValue: unknown) { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('user', userValue as never); + await next(); + }); + app.route('/groups', groupsRoutes); + return app; +} + +describe('group waitlist management', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET /groups/:groupId/waitlist', () => { + it('lists pending entries in FIFO order with pagination', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findMany.mockResolvedValue([ + waitlistEntry({ id: 'wl-1', fullName: 'Ana García', createdAt: new Date('2026-09-08T10:00:00.000Z') }), + waitlistEntry({ id: 'wl-2', fullName: 'Pedro López', createdAt: new Date('2026-09-09T10:00:00.000Z') }), + ] as never); + prisma.groupWaitlistEntry.count.mockResolvedValue(2); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist'); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data).toHaveLength(2); + expect(data.data[0].fullName).toBe('Ana García'); + expect(data.pagination).toEqual({ page: 1, pageSize: 10, total: 2, totalPages: 1 }); + expect(prisma.groupWaitlistEntry.findMany).toHaveBeenCalledWith({ + where: { groupId: 'group-1', status: 'PENDING' }, + skip: 0, + take: 10, + orderBy: { createdAt: 'asc' }, + }); + }); + + it('rejects user without access to the group', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + + const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist'); + + expect(res.status).toBe(403); + expect(prisma.groupWaitlistEntry.findMany).not.toHaveBeenCalled(); + }); + }); + + describe('POST /groups/:groupId/waitlist/:entryId/promote', () => { + it('creates an attendee from the entry and removes it from the waitlist', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); + prisma.attendee.count.mockResolvedValue(5); + prisma.attendee.findFirst.mockResolvedValue(null); + prisma.attendee.create.mockResolvedValue(attendee() as never); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', { + method: 'POST', + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.attendee.fullName).toBe('Martín Gómez'); + expect(prisma.attendee.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ groupId: 'group-1', fullName: 'Martín Gómez', phone: '+5491133445566' }), + }); + expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } }); + }); + + it('rejects with 409 when the group reached its capacity', async () => { + prisma.group.findUnique.mockResolvedValue({ ...mockGroup, capacity: 5 } as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); + prisma.attendee.count.mockResolvedValue(5); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', { + method: 'POST', + }); + + 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.delete).not.toHaveBeenCalled(); + }); + + it('rejects with 409 when the phone already belongs to an attendee', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); + prisma.attendee.count.mockResolvedValue(5); + prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', { + method: 'POST', + }); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.code).toBe('attendee_already_registered'); + expect(prisma.attendee.create).not.toHaveBeenCalled(); + }); + + it('returns 404 when the entry does not exist', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing/promote', { + method: 'POST', + }); + + expect(res.status).toBe(404); + }); + + it('rejects a non-owner user', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + + const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1/promote', { + method: 'POST', + }); + + expect(res.status).toBe(403); + expect(prisma.attendee.create).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /groups/:groupId/waitlist/:entryId', () => { + it('removes a waitlist entry', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.deleted).toBe(true); + expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } }); + }); + + it('returns 404 when the entry does not exist', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing', { + method: 'DELETE', + }); + + expect(res.status).toBe(404); + expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled(); + }); + + it('rejects a non-owner user', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + + const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(403); + expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /groups/:groupId/attendees/:attendeeId', () => { + it('removes an attendee without promotion', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockResolvedValue(attendee() as never); + prisma.payment.count.mockResolvedValue(0); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.removedAttendeeId).toBe('att-1'); + expect(body.promoted).toBeNull(); + expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } }); + }); + + it('removes an attendee and promotes the first pending waitlist entry atomically', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockImplementation(async ({ where }: { where?: { id?: string } }) => { + if (where?.id) return attendee() as never; + return null; + }); + prisma.payment.count.mockResolvedValue(0); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue( + waitlistEntry({ id: 'wl-1', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never, + ); + prisma.attendee.count.mockResolvedValue(5); + prisma.attendee.create.mockResolvedValue( + attendee({ id: 'att-2', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never, + ); + + const res = await makeApp({ id: teacherId }).request( + '/groups/group-1/attendees/att-1?promoteFromWaitlist=true', + { method: 'DELETE' }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.removedAttendeeId).toBe('att-1'); + expect(body.promoted).toMatchObject({ id: 'wl-1', fullName: 'Sofía Ruiz' }); + expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } }); + expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } }); + expect(prisma.attendee.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ fullName: 'Sofía Ruiz', phone: '+5491133778899' }), + }); + }); + + it('removes the attendee when promote requested but waitlist is empty', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockResolvedValue(attendee() as never); + prisma.payment.count.mockResolvedValue(0); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null); + + const res = await makeApp({ id: teacherId }).request( + '/groups/group-1/attendees/att-1?promoteFromWaitlist=true', + { method: 'DELETE' }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.removedAttendeeId).toBe('att-1'); + expect(body.promoted).toBeNull(); + expect(prisma.attendee.create).not.toHaveBeenCalled(); + }); + + it('blocks removal when the attendee has payments', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockResolvedValue(attendee() as never); + prisma.payment.count.mockResolvedValue(2); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.code).toBe('attendee_has_payments'); + expect(prisma.attendee.delete).not.toHaveBeenCalled(); + }); + + it('returns 404 when the attendee does not belong to the group', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockResolvedValue(null); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-missing', { + method: 'DELETE', + }); + + expect(res.status).toBe(404); + expect(prisma.attendee.delete).not.toHaveBeenCalled(); + }); + + it('rejects a non-owner user', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + + const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/attendees/att-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(403); + expect(prisma.attendee.delete).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/apps/web/src/components/groups/GroupForm.tsx b/apps/web/src/components/groups/GroupForm.tsx new file mode 100644 index 0000000..a9548c4 --- /dev/null +++ b/apps/web/src/components/groups/GroupForm.tsx @@ -0,0 +1,231 @@ +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import type { BillingType, CreateFirstGroup, WeekDay } from '@gruperly/shared' +import { CreateFirstGroupSchema } from '@gruperly/shared' +import { ArrowLeft, Check, Loader2 } from 'lucide-react' +import { cn } from '../../lib/utils' +import { Button, Input, Label } from '../ui' +import { BILLING_TYPES, WEEK_DAY_CHIPS } from '../onboarding/constants' + +const defaultValues: CreateFirstGroup = { + name: '', + days: [], + time: '09:00', + capacity: 1, + price: 0, + billingType: 'MONTHLY', + dueDay: 1, +} + +type GroupFormProps = { + heading: string + description: string + submitLabel: string + isSubmitting: boolean + errorMessage?: string | null + onSubmit: (values: CreateFirstGroup) => void + onBack?: () => void +} + +export function GroupForm({ + heading, + description, + submitLabel, + isSubmitting, + errorMessage, + onSubmit, + onBack, +}: GroupFormProps) { + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(CreateFirstGroupSchema), + defaultValues, + mode: 'onTouched', + }) + + const days = watch('days') + const billingType = watch('billingType') + const dueDay = watch('dueDay') + + const toggleDay = (day: WeekDay) => { + const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day] + setValue('days', next, { shouldValidate: true }) + } + + const selectBillingType = (type: BillingType) => { + setValue('billingType', type, { shouldValidate: true }) + } + + return ( +
+
+

{heading}

+

{description}

+
+ +
+ + + {errors.name ?

{errors.name.message}

: null} +
+ +
+ +
+ {WEEK_DAY_CHIPS.map(({ value, label }) => { + const isActive = days.includes(value) + return ( + + ) + })} +
+ {errors.days ?

{errors.days.message}

: null} +
+ +
+ + + {errors.time ?

{errors.time.message}

: null} +
+ +
+
+ + + {errors.capacity ? ( +

{errors.capacity.message}

+ ) : null} +
+ +
+ +
+ + $ + + +
+ {errors.price ? ( +

{errors.price.message}

+ ) : null} +
+
+ +
+ +
+ {BILLING_TYPES.map(({ value, label, hint }) => { + const isActive = billingType === value + return ( + + ) + })} +
+
+ +
+ + +

+ Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes. +

+ {errors.dueDay ? ( +

{errors.dueDay.message}

+ ) : null} +
+ + {errorMessage ? ( +

{errorMessage}

+ ) : null} + +
+ {onBack ? ( + + ) : null} + +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/layout/Breadcrumb.tsx b/apps/web/src/components/layout/Breadcrumb.tsx index 73d3a9b..0b86caa 100644 --- a/apps/web/src/components/layout/Breadcrumb.tsx +++ b/apps/web/src/components/layout/Breadcrumb.tsx @@ -22,11 +22,6 @@ const BREADCRUMBS: Record = { { label: 'Ajustes', to: '/settings' }, { label: 'Seguridad' }, ], - '/settings/organizations': [ - { label: 'Inicio', to: '/' }, - { label: 'Ajustes', to: '/settings' }, - { label: 'Organizaciones' }, - ], '/profile': [ { label: 'Inicio', to: '/' }, { label: 'Mi perfil' }, diff --git a/apps/web/src/components/onboarding/FirstGroupStep.tsx b/apps/web/src/components/onboarding/FirstGroupStep.tsx index 5cf7d6b..32da7bd 100644 --- a/apps/web/src/components/onboarding/FirstGroupStep.tsx +++ b/apps/web/src/components/onboarding/FirstGroupStep.tsx @@ -1,23 +1,7 @@ import { useMutation } from '@tanstack/react-query' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import type { BillingType, CreateFirstGroup, OnboardingGroupDto, WeekDay } from '@gruperly/shared' -import { CreateFirstGroupSchema } from '@gruperly/shared' -import { ArrowLeft, Check, Loader2 } from 'lucide-react' +import type { CreateFirstGroup, OnboardingGroupDto } from '@gruperly/shared' import { createFirstGroup } from '../../lib/api' -import { cn } from '../../lib/utils' -import { Button, Input, Label } from '../ui' -import { BILLING_TYPES, WEEK_DAY_CHIPS } from './constants' - -const defaultValues: CreateFirstGroup = { - name: '', - days: [], - time: '09:00', - capacity: 1, - price: 0, - billingType: 'MONTHLY', - dueDay: 1, -} +import { GroupForm } from '../groups/GroupForm' type FirstGroupStepProps = { onBack: () => void @@ -25,212 +9,22 @@ type FirstGroupStepProps = { } export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) { - const { - register, - handleSubmit, - watch, - setValue, - formState: { errors }, - } = useForm({ - resolver: zodResolver(CreateFirstGroupSchema), - defaultValues, - mode: 'onTouched', - }) - - const days = watch('days') - const billingType = watch('billingType') - const dueDay = watch('dueDay') - const create = useMutation({ mutationFn: (values: CreateFirstGroup) => createFirstGroup(values), onSuccess: (result) => onCompleted(result.group), }) - const toggleDay = (day: WeekDay) => { - const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day] - setValue('days', next, { shouldValidate: true }) - } - - const selectBillingType = (type: BillingType) => { - setValue('billingType', type, { shouldValidate: true }) - } - return ( -
create.mutate(values))} noValidate className="space-y-5"> -
-

Tu primer grupo

-

- Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos. -

-
- -
- - - {errors.name ?

{errors.name.message}

: null} -
- -
- -
- {WEEK_DAY_CHIPS.map(({ value, label }) => { - const isActive = days.includes(value) - return ( - - ) - })} -
- {errors.days ?

{errors.days.message}

: null} -
- -
- - - {errors.time ?

{errors.time.message}

: null} -
- -
-
- - - {errors.capacity ? ( -

{errors.capacity.message}

- ) : null} -
- -
- -
- - $ - - -
- {errors.price ? ( -

{errors.price.message}

- ) : null} -
-
- -
- -
- {BILLING_TYPES.map(({ value, label, hint }) => { - const isActive = billingType === value - return ( - - ) - })} -
-
- -
- - -

- Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes. -

- {errors.dueDay ? ( -

{errors.dueDay.message}

- ) : null} -
- - {create.isError ? ( -

- No pudimos crear el grupo. Revisá los datos e intentá de nuevo. -

- ) : null} - -
- - -
-
+ create.mutate(values)} + onBack={onBack} + /> ) } \ No newline at end of file diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6b925fb..14601f5 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -6,15 +6,24 @@ import type { ConnectPayment, ConnectPaymentResult, CreateAttendee, + CreateAttendeeResult, CreateFirstGroup, CreateFirstGroupResult, + CreateGroupResult, + CreateGroupWaitlistEntry, GroupDto, GroupInviteInfoDto, GroupList, + GroupWaitlistEntryDto, + GroupWaitlistList, InviteTokenResult, JoinGroupViaInvite, + JoinGroupViaInviteResult, OnboardingStatusDto, ProblemDetails, + PromoteGroupWaitlistEntryResult, + RemoveAttendeeResult, + RemoveGroupWaitlistEntryResult, } from '@gruperly/shared' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000' @@ -71,6 +80,12 @@ export const createFirstGroup = (payload: CreateFirstGroup) => body: JSON.stringify(payload), }) +export const createGroup = (payload: CreateFirstGroup) => + apiFetch('/api/v1/groups', { + method: 'POST', + body: JSON.stringify(payload), + }) + export const getGroups = () => apiFetch('/api/v1/groups') export const getGroup = (groupId: string) => @@ -87,13 +102,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), }) @@ -105,4 +133,29 @@ export const bulkCreateAttendees = (groupId: string, payload: BulkCreateAttendee }) export const getGroupAttendees = (groupId: string, page = 1, pageSize = 20) => - apiFetch(`/api/v1/groups/${groupId}/attendees?page=${page}&pageSize=${pageSize}`) \ No newline at end of file + apiFetch(`/api/v1/groups/${groupId}/attendees?page=${page}&pageSize=${pageSize}`) + +export const getGroupWaitlist = (groupId: string, page = 1, pageSize = 100) => + apiFetch(`/api/v1/groups/${groupId}/waitlist?page=${page}&pageSize=${pageSize}`) + +export const promoteGroupWaitlistEntry = (groupId: string, entryId: string) => + apiFetch(`/api/v1/groups/${groupId}/waitlist/${entryId}/promote`, { + method: 'POST', + }) + +export const removeGroupWaitlistEntry = (groupId: string, entryId: string) => + apiFetch(`/api/v1/groups/${groupId}/waitlist/${entryId}`, { + method: 'DELETE', + }) + +export const removeGroupAttendee = ( + groupId: string, + attendeeId: string, + options?: { promoteFromWaitlist?: boolean }, +) => + apiFetch( + `/api/v1/groups/${groupId}/attendees/${attendeeId}${options?.promoteFromWaitlist ? '?promoteFromWaitlist=true' : ''}`, + { + method: 'DELETE', + }, + ) \ No newline at end of file diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index ff61e75..7865cb8 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -6,12 +6,12 @@ import { PaymentsView } from './routes/payments' import { SettingsView } from './routes/settings' import { ProfileView } from './routes/profile' import { SecurityPage } from './routes/security' -import { OrganizationsPage } from './routes/organizations' import { LoginPage } from './routes/auth/login' import { SignupPage } from './routes/auth/signup' import { VerifyEmailPage } from './routes/auth/verify-email' import { OnboardingView } from './routes/onboarding' import { GroupDetailView } from './routes/group-detail' +import { CreateGroupView } from './routes/create-group' import { JoinGroupView } from './routes/join-group' const rootRoute = createRootRoute({ @@ -68,6 +68,12 @@ const groupsRoute = createRoute({ component: GroupsView, }) +const createGroupRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: '/groups/new', + component: CreateGroupView, +}) + const groupDetailRoute = createRoute({ getParentRoute: () => appLayoutRoute, path: '/groups/$groupId', @@ -98,12 +104,6 @@ const profileRoute = createRoute({ component: ProfileView, }) -const organizationsRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/settings/organizations', - component: OrganizationsPage, -}) - const routeTree = rootRoute.addChildren([ loginRoute, signupRoute, @@ -113,11 +113,11 @@ const routeTree = rootRoute.addChildren([ appLayoutRoute.addChildren([ indexRoute, groupsRoute, + createGroupRoute, groupDetailRoute, paymentsRoute, settingsRoute, securityRoute, - organizationsRoute, profileRoute, ]), ]) diff --git a/apps/web/src/routes/create-group.tsx b/apps/web/src/routes/create-group.tsx new file mode 100644 index 0000000..4af6940 --- /dev/null +++ b/apps/web/src/routes/create-group.tsx @@ -0,0 +1,39 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate, Link } from '@tanstack/react-router' +import type { CreateFirstGroup } from '@gruperly/shared' +import { ChevronLeft } from 'lucide-react' +import { GroupForm } from '../components/groups/GroupForm' +import { createGroup } from '../lib/api' + +export function CreateGroupView() { + const navigate = useNavigate() + const queryClient = useQueryClient() + + const create = useMutation({ + mutationFn: (values: CreateFirstGroup) => createGroup(values), + onSuccess: (result) => { + void queryClient.invalidateQueries({ queryKey: ['groups'] }) + void navigate({ to: '/groups/$groupId', params: { groupId: result.group.id } }) + }, + }) + + return ( +
+ + + Grupos + + + create.mutate(values)} + /> +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/routes/group-detail.tsx b/apps/web/src/routes/group-detail.tsx index 1c062ae..28de332 100644 --- a/apps/web/src/routes/group-detail.tsx +++ b/apps/web/src/routes/group-detail.tsx @@ -1,7 +1,14 @@ 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, + GroupWaitlistEntryDto, + GroupWaitlistList, +} from '@gruperly/shared'; import { ArrowLeft, CalendarClock, @@ -23,17 +30,24 @@ import { StickyNote, Trash2, UploadCloud, + UserCheck, + UserMinus, 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, getGroupAttendees, + getGroupWaitlist, getInviteToken, + promoteGroupWaitlistEntry, + removeGroupAttendee, + removeGroupWaitlistEntry, } from '../lib/api'; import { downloadAttendeeTemplateCsv, @@ -55,6 +69,9 @@ export function GroupDetailView() { const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false); const [selectedAttendee, setSelectedAttendee] = useState(null); const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick'); + const [listSection, setListSection] = useState<'members' | 'waitlist'>('members'); + const [attendeeToRemove, setAttendeeToRemove] = useState(null); + const [selectedWaitlistEntry, setSelectedWaitlistEntry] = useState(null); // Quick form state const [firstName, setFirstName] = useState(''); @@ -70,6 +87,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(''); @@ -92,6 +114,12 @@ export function GroupDetailView() { enabled: Boolean(groupId), }); + const waitlistQuery = useQuery({ + queryKey: ['group-waitlist', groupId], + queryFn: () => getGroupWaitlist(groupId, 1, 100), + enabled: Boolean(groupId), + }); + // Regenerate invite token mutation const regenerateTokenMutation = useMutation({ mutationFn: () => getInviteToken(groupId, true), @@ -105,23 +133,103 @@ 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), + onMutate: async (payload: CreateGroupWaitlistEntry) => { + await queryClient.cancelQueries({ queryKey: ['group-waitlist', groupId] }); + const previousWaitlist = queryClient.getQueryData(['group-waitlist', groupId]); + + if (previousWaitlist) { + const optimisticEntry: GroupWaitlistEntryDto = { + id: `temp-${Date.now()}`, + groupId, + fullName: `${payload.firstName} ${payload.lastName ?? ''}`.trim(), + phone: payload.phone, + email: payload.email || null, + notes: payload.notes ?? null, + status: 'PENDING', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + queryClient.setQueryData(['group-waitlist', groupId], { + data: [optimisticEntry, ...previousWaitlist.data], + pagination: { + ...previousWaitlist.pagination, + total: previousWaitlist.pagination.total + 1, + }, + }); + } + + return { previousWaitlist }; + }, + onSuccess: (entry) => { + toast.info(`${entry.fullName} fue agregado a la lista de espera del grupo.`); + queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] }); + queryClient.invalidateQueries({ queryKey: ['group', groupId] }); + closeAddAttendeeFlow(); + }, + onError: (err: Error, _payload, context) => { + if (context?.previousWaitlist) { + queryClient.setQueryData(['group-waitlist', groupId], context.previousWaitlist); + } + 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 }>) => @@ -138,6 +246,63 @@ export function GroupDetailView() { }, }); + // Waitlist & removal mutations + const invalidateGroupQueries = () => { + queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }); + queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] }); + queryClient.invalidateQueries({ queryKey: ['group', groupId] }); + }; + + const removeAttendeeMutation = useMutation({ + mutationFn: (payload: { attendeeId: string; promoteFromWaitlist: boolean }) => + removeGroupAttendee(groupId, payload.attendeeId, { promoteFromWaitlist: payload.promoteFromWaitlist }), + onSuccess: (result, payload) => { + const removedName = attendees.find((a) => a.id === payload.attendeeId)?.fullName ?? 'El miembro'; + if (result.promoted) { + toast.success(`${removedName} fue quitado del grupo y ${result.promoted.fullName} pasó de la lista de espera al grupo.`); + } else { + toast.success(`${removedName} fue quitado del grupo.`); + } + invalidateGroupQueries(); + setSelectedAttendee(null); + setAttendeeToRemove(null); + }, + onError: (err: Error) => { + if (err instanceof ApiError && err.problem?.code === 'attendee_has_payments') { + toast.error(err.problem.detail ?? 'Este miembro tiene cobros asociados.'); + } else { + toast.error(err.message || 'Error al quitar al miembro del grupo.'); + } + setAttendeeToRemove(null); + }, + }); + + const promoteWaitlistMutation = useMutation({ + mutationFn: (entryId: string) => promoteGroupWaitlistEntry(groupId, entryId), + onSuccess: (result) => { + toast.success(`${result.attendee.fullName} pasó de la lista de espera al grupo.`); + invalidateGroupQueries(); + }, + onError: (err: Error) => { + if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') { + toast.error('El grupo alcanzó su cupo. Quita un miembro o aumenta el cupo primero.'); + } else { + toast.error(err.message || 'No se pudo pasar al miembro al grupo.'); + } + }, + }); + + const removeWaitlistEntryMutation = useMutation({ + mutationFn: (entry: GroupWaitlistEntryDto) => removeGroupWaitlistEntry(groupId, entry.id), + onSuccess: (_result, entry) => { + toast.success(`${entry.fullName} fue quitado de la lista de espera.`); + invalidateGroupQueries(); + }, + onError: (err: Error) => { + toast.error(err.message || 'No se pudo quitar de la lista de espera.'); + }, + }); + const handleCopyLink = async () => { const url = inviteTokenQuery.data?.inviteUrl; if (!url) return; @@ -240,7 +405,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 +423,25 @@ 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 waitlistEntries = waitlistQuery.data?.data ?? []; + const waitlistTotal = waitlistQuery.data?.pagination?.total ?? waitlistEntries.length; + const firstWaitlistEntry = waitlistEntries[0] ?? null; + const hasFreeCapacity = group?.capacity == null || attendeesTotal < group.capacity; const filteredAttendees = attendees.filter((a) => { const q = searchFilter.toLowerCase(); return ( @@ -307,7 +489,7 @@ export function GroupDetailView() {
Volver a grupos @@ -360,9 +542,10 @@ export function GroupDetailView() {
-

Miembros & Cupo

+

Miembros & Cupo

- {attendees.length} inscritos {group.capacity ? `· Cupo de ${group.capacity}` : ''} + {attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''} + {group.capacity ? ` · Cupo de ${group.capacity}` : ''}

@@ -382,108 +565,201 @@ export function GroupDetailView() {
- {/* Attendees Management Section */} + {/* Miembros & Lista de espera */}
-
-
-

- Miembros del Grupo ({attendees.length}) -

-

- Listado de todos los miembros incorporados al grupo. -

+
+
+
+ + +
+ + {listSection === 'members' ? ( +
+ + setSearchFilter(e.target.value)} + className="pl-9 h-9 text-xs" + /> +
+ ) : null}
-
- - setSearchFilter(e.target.value)} - className="pl-9 h-9 text-xs" - /> -
+

+ {listSection === 'members' + ? 'Listado de todos los miembros incorporados al grupo.' + : 'Personas que esperan un cupo libre en el grupo. Al pasar a alguien al grupo, se crea automáticamente su registro como miembro.'} +

- {attendeesQuery.isPending ? ( -
- -
- ) : null} + {listSection === 'members' ? ( + <> + {attendeesQuery.isPending ? ( +
+ +
+ ) : null} - {attendeesQuery.isSuccess && attendees.length === 0 ? ( -
- -

Todavía no hay miembros en este grupo

-

- Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva. -

-
- - -
-
- ) : null} - - {attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? ( -
- No se encontraron miembros que coincidan con la búsqueda. -
- ) : null} - - {filteredAttendees.length > 0 ? ( -
- - - - - - - - - {filteredAttendees.map((attendee) => ( - setSelectedAttendee(attendee)} + {attendeesQuery.isSuccess && attendees.length === 0 ? ( +
+ +

Todavía no hay miembros en este grupo

+

+ Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva. +

+
+
- - - - ))} - -
NombreTeléfono -
-
-
- {attendee.fullName.charAt(0).toUpperCase()} -
- {attendee.fullName} -
-
- {attendee.phone || —} - - -
+ + Compartir Enlace + + +
+
+ ) : null} + + {attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? ( +
+ No se encontraron miembros que coincidan con la búsqueda. +
+ ) : null} + + {filteredAttendees.length > 0 ? ( +
+ + + + + + + + + {filteredAttendees.map((attendee) => ( + setSelectedAttendee(attendee)} + > + + + + + ))} + +
NombreTeléfono +
+
+
+ {attendee.fullName.charAt(0).toUpperCase()} +
+ {attendee.fullName} +
+
+ {attendee.phone || —} + + +
+
+ ) : null} + + ) : ( +
+ {waitlistQuery.isPending ? ( +
+ +
+ ) : null} + + {waitlistQuery.isSuccess && waitlistEntries.length === 0 ? ( +
+ +

No hay nadie en la lista de espera

+

+ Cuando el grupo alcance su cupo, las personas podrán sumarse a la espera y podrás pasarlas al grupo desde aquí. +

+
+ ) : null} + + {waitlistEntries.length > 0 ? ( +
+ + + + + + + + + {waitlistEntries.map((entry) => ( + setSelectedWaitlistEntry(entry)} + > + + + + + ))} + +
NombreTeléfono +
+
+
+ {entry.fullName.charAt(0).toUpperCase()} +
+
+

{entry.fullName}

+ {entry.notes ? ( +

{entry.notes}

+ ) : null} +
+
+
+ {entry.phone} + + +
+
+ ) : null}
- ) : null} + )}
{/* MODAL: COMPARTIR LINK DE INVITACION */} @@ -880,6 +1156,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} +
+ {/* MODAL: CONFIRMAR QUITAR MIEMBRO */} + setAttendeeToRemove(null)} + title="Quitar del grupo" + maxWidth="md" + > + {attendeeToRemove ? ( +
+

+ {attendeeToRemove.fullName} dejará de ser miembro del grupo{' '} + {group.name} + {firstWaitlistEntry ? ' y el cupo quedará libre.' : '.'} + {attendeeToRemove.phone ? ( + + Teléfono: {attendeeToRemove.phone} + + ) : null} +

+ + {firstWaitlistEntry ? ( +
+
+ + + Hay {waitlistTotal} persona{waitlistTotal === 1 ? '' : 's'} esperando un cupo + +
+

+ ¿Quieres pasar a{' '} + {firstWaitlistEntry.fullName}, el primero de + la lista de espera, al grupo? +

+
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : null} +
+ {/* MODAL: DETALLE LISTA DE ESPERA */} + setSelectedWaitlistEntry(null)} + title="Detalle de la lista de espera" + maxWidth="sm" + > + {selectedWaitlistEntry ? ( +
+ {/* Header: avatar + name */} +
+
+ {selectedWaitlistEntry.fullName.charAt(0).toUpperCase()} +
+
+

+ {selectedWaitlistEntry.fullName} +

+

+ En espera desde el{' '} + {new Date(selectedWaitlistEntry.createdAt).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +

+
+
+ + {/* Info rows */} +
+ } label="Teléfono"> + {selectedWaitlistEntry.phone ? ( +
+ {selectedWaitlistEntry.phone} + e.stopPropagation()} + > + + WhatsApp + +
+ ) : ( + Sin teléfono + )} +
+ + {selectedWaitlistEntry.email ? ( + } label="Email"> + e.stopPropagation()} + > + {selectedWaitlistEntry.email} + + + ) : null} + + } label="Notas"> + {selectedWaitlistEntry.notes ? ( + + {selectedWaitlistEntry.notes} + + ) : ( + Sin notas + )} + +
+ + {/* Footer: status + actions */} + {!hasFreeCapacity ? ( +

+ El grupo alcanzó su cupo de miembros. Quita un miembro o aumenta el cupo para poder pasar + a esta persona al grupo. +

+ ) : null} +
+ + +
) : null}
diff --git a/apps/web/src/routes/groups.tsx b/apps/web/src/routes/groups.tsx index 4b97be2..ded2901 100644 --- a/apps/web/src/routes/groups.tsx +++ b/apps/web/src/routes/groups.tsx @@ -70,7 +70,7 @@ export function GroupsView() {

Grupos

Tus grupos de cobranza.

- @@ -100,7 +100,7 @@ export function GroupsView() { - - Owner - - ))} - - ) -} - -export function OrganizationsPage() { - const [refreshKey, setRefreshKey] = useState(0) - const [feedback, setFeedback] = useState(null) - - const { - register, - handleSubmit, - reset, - setError, - formState: { errors, isSubmitting }, - } = useForm({ resolver: zodResolver(createOrgSchema) }) - - const refreshOrganizations = () => setRefreshKey((k) => k + 1) - - const onCreateOrg = handleSubmit(async ({ name, slug }) => { - setFeedback(null) - const { error, data } = await authClient.organization.create({ name, slug }) - if (error) { - setError('root', { message: error.message ?? 'No se pudo crear el grupo' }) - return - } - reset({ name: '', slug: '' }) - setFeedback('Grupo creado. Sincronizalo con el cobro grupal.') - refreshOrganizations() - void data - }) - - return ( -
-
-

Grupos

-

- Creá un grupo para organizar tus cobros. Cada organización se vincula a un grupo. -

-
- - {feedback ? ( -

{feedback}

- ) : null} - -
-
- -

Crear grupo

-
-
-
- - - {errors.name ?

{errors.name.message}

: null} -
-
- - - {errors.slug ?

{errors.slug.message}

: null} -
- {errors.root ?

{errors.root.message}

: null} - -
-
- -
-

Tus grupos

- -
-
- ) -} \ No newline at end of file diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 665b901..8777eca 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,5 +1,5 @@ import { Link } from '@tanstack/react-router' -import { Building2, Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react' +import { Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react' import { signOut } from '../lib/auth-client' import { Button } from '../components/ui' import { useTheme, type Theme } from '../context/ThemeProvider' @@ -76,19 +76,6 @@ export function SettingsView() {
- - - - -
-

Grupos

-

Crear y gestionar tus organizaciones

-
- - diff --git a/packages/shared/src/schemas/attendees.ts b/packages/shared/src/schemas/attendees.ts index 35f5ad0..9886c9d 100644 --- a/packages/shared/src/schemas/attendees.ts +++ b/packages/shared/src/schemas/attendees.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js'; +import { GroupWaitlistEntryDtoSchema } from './waitlist.js'; const isoDateTimeSchema = z.string().datetime(); const pageSchema = createPageSchema(); @@ -42,6 +43,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 +63,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(''), @@ -73,4 +93,20 @@ export const BulkCreateAttendeesResultSchema = z.object({ message: z.string(), created: z.array(AttendeeDtoSchema), }); -export type BulkCreateAttendeesResult = z.output; \ No newline at end of file +export type BulkCreateAttendeesResult = z.output; + +export const PromoteGroupWaitlistEntryResultSchema = z.object({ + attendee: AttendeeDtoSchema, +}); +export type PromoteGroupWaitlistEntryResult = z.output; + +export const RemoveAttendeeQuerySchema = z.object({ + promoteFromWaitlist: z.coerce.boolean().optional(), +}); +export type RemoveAttendeeQuery = z.output; + +export const RemoveAttendeeResultSchema = z.object({ + removedAttendeeId: z.string(), + promoted: GroupWaitlistEntryDtoSchema.nullable(), +}); +export type RemoveAttendeeResult = z.output; \ No newline at end of file diff --git a/packages/shared/src/schemas/groups.ts b/packages/shared/src/schemas/groups.ts index 10f04b9..6d933f4 100644 --- a/packages/shared/src/schemas/groups.ts +++ b/packages/shared/src/schemas/groups.ts @@ -56,15 +56,7 @@ export const GroupListSchema = z.object({ }); export type GroupList = z.output; -export const CreateGroupFromOrganizationSchema = z.strictObject({ - organizationId: z.string().min(1), -}); -export type CreateGroupFromOrganization = z.output; - -export const CreateGroupFromOrganizationResultSchema = z.object({ +export const CreateGroupResultSchema = z.object({ group: GroupDtoSchema, - alreadyExists: z.boolean(), }); -export type CreateGroupFromOrganizationResult = z.output< - typeof CreateGroupFromOrganizationResultSchema ->; \ No newline at end of file +export type CreateGroupResult = z.output; \ No newline at end of file diff --git a/packages/shared/src/schemas/waitlist.ts b/packages/shared/src/schemas/waitlist.ts index fdf3fab..5d32184 100644 --- a/packages/shared/src/schemas/waitlist.ts +++ b/packages/shared/src/schemas/waitlist.ts @@ -28,4 +28,43 @@ 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; + +export const GroupWaitlistQuerySchema = z.object({ + page: pageSchema, + pageSize: pageSizeSchema, +}); +export type GroupWaitlistQuery = z.output; + +export const GroupWaitlistListSchema = z.object({ + data: z.array(GroupWaitlistEntryDtoSchema), + pagination: paginationSchema, +}); +export type GroupWaitlistList = z.output; + +export const RemoveGroupWaitlistEntryResultSchema = z.object({ + deleted: z.boolean(), +}); +export type RemoveGroupWaitlistEntryResult = z.output; \ No newline at end of file