feat/waiting-list #3
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "group_waitlist_entries" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"fullName" TEXT NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"status" "WaitlistStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "group_waitlist_entries_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "group_waitlist_entries_groupId_idx" ON "group_waitlist_entries"("groupId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "group_waitlist_entries_groupId_phone_key" ON "group_waitlist_entries"("groupId", "phone");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "group_waitlist_entries" ADD CONSTRAINT "group_waitlist_entries_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -19,6 +19,7 @@ model Group {
|
|||||||
members GroupMember[]
|
members GroupMember[]
|
||||||
attendees Attendee[]
|
attendees Attendee[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
waitlist GroupWaitlistEntry[]
|
||||||
|
|
||||||
@@map("groups")
|
@@map("groups")
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,24 @@ model WaitlistEntry {
|
|||||||
@@map("waitlist_entries")
|
@@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 {
|
enum WaitlistStatus {
|
||||||
PENDING
|
PENDING
|
||||||
INVITED
|
INVITED
|
||||||
|
|||||||
@@ -101,3 +101,19 @@ export function onboardingAlreadyCompletedProblem(): ProblemDetails {
|
|||||||
code: 'onboarding_already_completed',
|
code: 'onboarding_already_completed',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function capacityReachedProblem(capacity: number | null): ProblemDetails {
|
||||||
|
return conflictProblem({
|
||||||
|
detail: capacity
|
||||||
|
? `El grupo alcanzó su cupo máximo de ${capacity} miembros.`
|
||||||
|
: 'El grupo alcanzó su cupo máximo de miembros.',
|
||||||
|
code: 'group_capacity_reached',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alreadyWaitlistedProblem(): ProblemDetails {
|
||||||
|
return conflictProblem({
|
||||||
|
detail: 'Este número de teléfono ya está en la lista de espera del grupo.',
|
||||||
|
code: 'already_waitlisted',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { type CreateGroupWaitlistEntry, CreateGroupWaitlistEntrySchema } from '@gruperly/shared';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||||
|
import { validate } from '@/http/validate';
|
||||||
|
import { AddToGroupWaitlist } from './use-case';
|
||||||
|
|
||||||
|
const route = new Hono();
|
||||||
|
|
||||||
|
route.post('/:groupId/waitlist', validate.json(CreateGroupWaitlistEntrySchema), async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) {
|
||||||
|
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||||
|
}
|
||||||
|
const groupId = c.req.param('groupId');
|
||||||
|
const body = c.req.valid('json') as CreateGroupWaitlistEntry;
|
||||||
|
const useCase = new AddToGroupWaitlist();
|
||||||
|
const result = await useCase.execute(groupId, user.id, body);
|
||||||
|
return resultJson(c, result, { status: 201 });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { PrismaClient } from '@generated/prisma/client';
|
||||||
|
import type { CreateGroupWaitlistEntry, GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared';
|
||||||
|
import { err, ok } from '@gruperly/shared';
|
||||||
|
import {
|
||||||
|
alreadyWaitlistedProblem,
|
||||||
|
conflictProblem,
|
||||||
|
noGroupAccessProblem,
|
||||||
|
notFoundResourceProblem,
|
||||||
|
} from '@/http/problem-builders';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { normalizePhone } from '../../lib/helpers';
|
||||||
|
|
||||||
|
type AddToGroupWaitlistDeps = {
|
||||||
|
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupWaitlistRecord = {
|
||||||
|
id: string;
|
||||||
|
groupId: string;
|
||||||
|
fullName: string;
|
||||||
|
phone: string;
|
||||||
|
email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
status: GroupWaitlistEntryDto['status'];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toGroupWaitlistEntryDto(record: GroupWaitlistRecord): GroupWaitlistEntryDto {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
groupId: record.groupId,
|
||||||
|
fullName: record.fullName,
|
||||||
|
phone: record.phone,
|
||||||
|
email: record.email,
|
||||||
|
notes: record.notes,
|
||||||
|
status: record.status,
|
||||||
|
createdAt: record.createdAt.toISOString(),
|
||||||
|
updatedAt: record.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddToGroupWaitlist {
|
||||||
|
constructor(private readonly deps: AddToGroupWaitlistDeps = {}) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
groupId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: CreateGroupWaitlistEntry,
|
||||||
|
): Promise<Result<GroupWaitlistEntryDto, ProblemDetails>> {
|
||||||
|
const db = this.deps.db ?? prisma;
|
||||||
|
|
||||||
|
const group = await db.group.findUnique({
|
||||||
|
where: { id: groupId },
|
||||||
|
include: {
|
||||||
|
members: { where: { userId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return err(notFoundResourceProblem('Group', groupId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = group.createdById === userId;
|
||||||
|
const isMember = group.members.length > 0;
|
||||||
|
if (!isOwner && !isMember) {
|
||||||
|
return err(noGroupAccessProblem());
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = normalizePhone(payload.phone);
|
||||||
|
|
||||||
|
const existingAttendee = await db.attendee.findFirst({
|
||||||
|
where: {
|
||||||
|
groupId,
|
||||||
|
OR: [
|
||||||
|
{ phone },
|
||||||
|
{ phone: payload.phone.trim() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingAttendee) {
|
||||||
|
return err(
|
||||||
|
conflictProblem({
|
||||||
|
detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.',
|
||||||
|
code: 'attendee_already_registered',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
|
||||||
|
where: { groupId, phone },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingWaitlistEntry) {
|
||||||
|
return err(alreadyWaitlistedProblem());
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
||||||
|
const entry = await db.groupWaitlistEntry.create({
|
||||||
|
data: {
|
||||||
|
groupId,
|
||||||
|
fullName,
|
||||||
|
phone,
|
||||||
|
email: payload.email?.trim() || null,
|
||||||
|
notes: payload.notes?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok(toGroupWaitlistEntryDto(entry as unknown as GroupWaitlistRecord));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,9 @@ route.post('/:groupId/attendees', validate.json(CreateAttendeeSchema), async (c)
|
|||||||
}
|
}
|
||||||
const groupId = c.req.param('groupId');
|
const groupId = c.req.param('groupId');
|
||||||
const body = c.req.valid('json') as CreateAttendee;
|
const body = c.req.valid('json') as CreateAttendee;
|
||||||
|
const allowOverflow = c.req.query('allowOverflow') === 'true';
|
||||||
const useCase = new CreateAttendeeUseCase();
|
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 });
|
return resultJson(c, result, { status: 201 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import type { PrismaClient } from '@generated/prisma/client';
|
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 { 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 prisma from '@/lib/prisma';
|
||||||
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
|
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
|
||||||
|
|
||||||
type CreateAttendeeDeps = {
|
type CreateAttendeeDeps = {
|
||||||
db?: Pick<PrismaClient, 'group' | 'attendee'>;
|
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateAttendeeOptions = {
|
||||||
|
allowOverflow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CreateAttendeeUseCase {
|
export class CreateAttendeeUseCase {
|
||||||
@@ -16,7 +26,8 @@ export class CreateAttendeeUseCase {
|
|||||||
groupId: string,
|
groupId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: CreateAttendee,
|
payload: CreateAttendee,
|
||||||
): Promise<Result<AttendeeDto, ProblemDetails>> {
|
options: CreateAttendeeOptions = {},
|
||||||
|
): Promise<Result<CreateAttendeeResult, ProblemDetails>> {
|
||||||
const db = this.deps.db ?? prisma;
|
const db = this.deps.db ?? prisma;
|
||||||
|
|
||||||
const group = await db.group.findUnique({
|
const group = await db.group.findUnique({
|
||||||
@@ -57,6 +68,42 @@ export class CreateAttendeeUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
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({
|
const attendee = await db.attendee.create({
|
||||||
data: {
|
data: {
|
||||||
groupId,
|
groupId,
|
||||||
@@ -67,6 +114,9 @@ export class CreateAttendeeUseCase {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return ok(toAttendeeDto(attendee as unknown as AttendeeRecord));
|
return ok({
|
||||||
|
outcome: 'created',
|
||||||
|
attendee: toAttendeeDto(attendee as unknown as AttendeeRecord),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import addToGroupWaitlistRoute from '../attendees/features/add-to-waitlist/route';
|
||||||
import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
|
import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
|
||||||
import createAttendeeRoute from '../attendees/features/create/route';
|
import createAttendeeRoute from '../attendees/features/create/route';
|
||||||
import createFromOrganizationRoute from './features/create-from-organization/route';
|
import createFromOrganizationRoute from './features/create-from-organization/route';
|
||||||
@@ -16,5 +17,6 @@ routes.route('/', listAttendeesRoute);
|
|||||||
routes.route('/', createAttendeeRoute);
|
routes.route('/', createAttendeeRoute);
|
||||||
routes.route('/', bulkCreateAttendeesRoute);
|
routes.route('/', bulkCreateAttendeesRoute);
|
||||||
routes.route('/', getByIdRoute);
|
routes.route('/', getByIdRoute);
|
||||||
|
routes.route('/', addToGroupWaitlistRoute);
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
@@ -6,12 +6,13 @@ import prisma from '@/lib/prisma';
|
|||||||
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers';
|
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers';
|
||||||
|
|
||||||
type JoinViaInviteDeps = {
|
type JoinViaInviteDeps = {
|
||||||
db?: Pick<PrismaClient, 'group' | 'attendee'>;
|
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JoinViaInviteResult = {
|
export type JoinViaInviteResult = {
|
||||||
attendee: AttendeeDto;
|
status: 'registered' | 'waitlisted';
|
||||||
message: string;
|
message: string;
|
||||||
|
attendee: AttendeeDto | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class JoinViaInvite {
|
export class JoinViaInvite {
|
||||||
@@ -52,6 +53,35 @@ export class JoinViaInvite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
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({
|
const created = await db.attendee.create({
|
||||||
data: {
|
data: {
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
@@ -62,6 +92,7 @@ export class JoinViaInvite {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
|
status: 'registered',
|
||||||
attendee: toAttendeeDto(created as unknown as AttendeeRecord),
|
attendee: toAttendeeDto(created as unknown as AttendeeRecord),
|
||||||
message: 'Inscripción realizada con éxito',
|
message: 'Inscripción realizada con éxito',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ const db = {
|
|||||||
create: mock(),
|
create: mock(),
|
||||||
count: mock(),
|
count: mock(),
|
||||||
},
|
},
|
||||||
|
groupWaitlistEntry: {
|
||||||
|
findMany: mock(),
|
||||||
|
findFirst: mock(),
|
||||||
|
create: mock(),
|
||||||
|
count: mock(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module('@/lib/prisma', () => ({
|
mock.module('@/lib/prisma', () => ({
|
||||||
@@ -130,8 +136,9 @@ describe('attendee incorporation & invite-token in groups', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
expect(data.fullName).toBe('Martín Gómez');
|
expect(data.outcome).toBe('created');
|
||||||
expect(data.phone).toBe('+5491133445566');
|
expect(data.attendee.fullName).toBe('Martín Gómez');
|
||||||
|
expect(data.attendee.phone).toBe('+5491133445566');
|
||||||
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
||||||
data: {
|
data: {
|
||||||
groupId: 'group-1',
|
groupId: 'group-1',
|
||||||
@@ -161,6 +168,244 @@ describe('attendee incorporation & invite-token in groups', () => {
|
|||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
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)', () => {
|
describe('POST /groups/:groupId/attendees/bulk (bulk import)', () => {
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ const db = {
|
|||||||
attendee: {
|
attendee: {
|
||||||
findFirst: mock(),
|
findFirst: mock(),
|
||||||
create: mock(),
|
create: mock(),
|
||||||
|
count: mock(),
|
||||||
|
},
|
||||||
|
groupWaitlistEntry: {
|
||||||
|
findFirst: mock(),
|
||||||
|
create: mock(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,5 +156,48 @@ describe('public invitations routes', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(400);
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ import type {
|
|||||||
ConnectPayment,
|
ConnectPayment,
|
||||||
ConnectPaymentResult,
|
ConnectPaymentResult,
|
||||||
CreateAttendee,
|
CreateAttendee,
|
||||||
|
CreateAttendeeResult,
|
||||||
CreateFirstGroup,
|
CreateFirstGroup,
|
||||||
CreateFirstGroupResult,
|
CreateFirstGroupResult,
|
||||||
|
CreateGroupWaitlistEntry,
|
||||||
GroupDto,
|
GroupDto,
|
||||||
GroupInviteInfoDto,
|
GroupInviteInfoDto,
|
||||||
GroupList,
|
GroupList,
|
||||||
|
GroupWaitlistEntryDto,
|
||||||
InviteTokenResult,
|
InviteTokenResult,
|
||||||
JoinGroupViaInvite,
|
JoinGroupViaInvite,
|
||||||
|
JoinGroupViaInviteResult,
|
||||||
OnboardingStatusDto,
|
OnboardingStatusDto,
|
||||||
ProblemDetails,
|
ProblemDetails,
|
||||||
} from '@gruperly/shared'
|
} from '@gruperly/shared'
|
||||||
@@ -87,13 +91,26 @@ export const getInviteInfo = (token: string) =>
|
|||||||
apiFetch<GroupInviteInfoDto>(`/api/v1/invitations/${token}`)
|
apiFetch<GroupInviteInfoDto>(`/api/v1/invitations/${token}`)
|
||||||
|
|
||||||
export const joinViaInvite = (token: string, payload: JoinGroupViaInvite) =>
|
export const joinViaInvite = (token: string, payload: JoinGroupViaInvite) =>
|
||||||
apiFetch<{ attendee: AttendeeDto; message: string }>(`/api/v1/invitations/${token}/join`, {
|
apiFetch<JoinGroupViaInviteResult>(`/api/v1/invitations/${token}/join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createAttendee = (groupId: string, payload: CreateAttendee) =>
|
export const createAttendee = (
|
||||||
apiFetch<AttendeeDto>(`/api/v1/groups/${groupId}/attendees`, {
|
groupId: string,
|
||||||
|
payload: CreateAttendee,
|
||||||
|
options?: { allowOverflow?: boolean },
|
||||||
|
) =>
|
||||||
|
apiFetch<CreateAttendeeResult>(
|
||||||
|
`/api/v1/groups/${groupId}/attendees${options?.allowOverflow ? '?allowOverflow=true' : ''}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const addToGroupWaitlist = (groupId: string, payload: CreateGroupWaitlistEntry) =>
|
||||||
|
apiFetch<GroupWaitlistEntryDto>(`/api/v1/groups/${groupId}/waitlist`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, type ReactNode } from 'react';
|
import { useState, useRef, type ReactNode } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate, Link } from '@tanstack/react-router';
|
import { useParams, useNavigate, Link } from '@tanstack/react-router';
|
||||||
import type { AttendeeDto } from '@gruperly/shared';
|
import type { AttendeeDto, CreateAttendee, CreateAttendeeResult, CreateGroupWaitlistEntry } from '@gruperly/shared';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
@@ -26,9 +26,10 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { CreateAttendee } from '@gruperly/shared';
|
|
||||||
import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui';
|
import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui';
|
||||||
import {
|
import {
|
||||||
|
addToGroupWaitlist,
|
||||||
|
ApiError,
|
||||||
bulkCreateAttendees,
|
bulkCreateAttendees,
|
||||||
createAttendee,
|
createAttendee,
|
||||||
getGroup,
|
getGroup,
|
||||||
@@ -70,6 +71,11 @@ export function GroupDetailView() {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Capacity & waitlist state
|
||||||
|
const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false);
|
||||||
|
const [pendingCapacityPayload, setPendingCapacityPayload] = useState<CreateAttendee | null>(null);
|
||||||
|
const [isBulkCapacityModalOpen, setIsBulkCapacityModalOpen] = useState(false);
|
||||||
|
|
||||||
// Attendees search filter
|
// Attendees search filter
|
||||||
const [searchFilter, setSearchFilter] = useState('');
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
|
|
||||||
@@ -105,23 +111,72 @@ export function GroupDetailView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Quick add attendee mutation
|
// Quick add attendee mutation
|
||||||
const createAttendeeMutation = useMutation({
|
const resetQuickForm = () => {
|
||||||
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
|
|
||||||
onSuccess: (created) => {
|
|
||||||
toast.success(`Miembro ${created.fullName} agregado con éxito.`);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
|
|
||||||
setFirstName('');
|
setFirstName('');
|
||||||
setLastName('');
|
setLastName('');
|
||||||
setPhone('');
|
setPhone('');
|
||||||
setEmail('');
|
setEmail('');
|
||||||
setNotes('');
|
setNotes('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAddAttendeeFlow = () => {
|
||||||
|
resetQuickForm();
|
||||||
|
setPendingCapacityPayload(null);
|
||||||
|
setIsCapacityModalOpen(false);
|
||||||
setIsAddAttendeeModalOpen(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),
|
||||||
|
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) => {
|
onError: (err: Error) => {
|
||||||
toast.error(err.message || 'Error al agregar miembro.');
|
toast.error(err.message || 'Error al agregar miembro.');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add to group waitlist
|
||||||
|
const addToWaitlistMutation = useMutation({
|
||||||
|
mutationFn: (payload: CreateGroupWaitlistEntry) => addToGroupWaitlist(groupId, payload),
|
||||||
|
onSuccess: (entry) => {
|
||||||
|
toast.info(`${entry.fullName} fue agregado a la lista de espera del grupo.`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
|
||||||
|
closeAddAttendeeFlow();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
if (err instanceof ApiError && err.problem?.code === 'already_waitlisted') {
|
||||||
|
toast.info('Este número ya está en la lista de espera del grupo.');
|
||||||
|
} else {
|
||||||
|
toast.error(err.message || 'Error al agregar a la lista de espera.');
|
||||||
|
}
|
||||||
|
setPendingCapacityPayload(null);
|
||||||
|
setIsCapacityModalOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Bulk import mutation
|
// Bulk import mutation
|
||||||
const bulkImportMutation = useMutation({
|
const bulkImportMutation = useMutation({
|
||||||
mutationFn: (attendees: Array<{ firstName: string; lastName: string; fullName: string; phone: string; email?: string; notes?: string }>) =>
|
mutationFn: (attendees: Array<{ firstName: string; lastName: string; fullName: string; phone: string; email?: string; notes?: string }>) =>
|
||||||
@@ -240,7 +295,7 @@ export function GroupDetailView() {
|
|||||||
setParsedContacts((prev) => prev.filter((c) => c.id !== id));
|
setParsedContacts((prev) => prev.filter((c) => c.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmBulkImport = () => {
|
const doBulkImport = () => {
|
||||||
const validRows = parsedContacts.filter((c) => c.isValid);
|
const validRows = parsedContacts.filter((c) => c.isValid);
|
||||||
if (validRows.length === 0) {
|
if (validRows.length === 0) {
|
||||||
toast.error('No hay miembros válidos para importar.');
|
toast.error('No hay miembros válidos para importar.');
|
||||||
@@ -258,8 +313,21 @@ export function GroupDetailView() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmBulkImport = () => {
|
||||||
|
if (validRowsCount === 0) {
|
||||||
|
toast.error('No hay miembros válidos para importar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (group?.capacity != null && attendeesTotal + validRowsCount > group.capacity) {
|
||||||
|
setIsBulkCapacityModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doBulkImport();
|
||||||
|
};
|
||||||
|
|
||||||
const group = groupQuery.data;
|
const group = groupQuery.data;
|
||||||
const attendees = attendeesQuery.data?.data ?? [];
|
const attendees = attendeesQuery.data?.data ?? [];
|
||||||
|
const attendeesTotal = attendeesQuery.data?.pagination?.total ?? attendees.length;
|
||||||
const filteredAttendees = attendees.filter((a) => {
|
const filteredAttendees = attendees.filter((a) => {
|
||||||
const q = searchFilter.toLowerCase();
|
const q = searchFilter.toLowerCase();
|
||||||
return (
|
return (
|
||||||
@@ -880,6 +948,95 @@ export function GroupDetailView() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* MODAL: CUPO ALCANZADO (ALTA INDIVIDUAL) */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isCapacityModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setPendingCapacityPayload(null);
|
||||||
|
setIsCapacityModalOpen(false);
|
||||||
|
}}
|
||||||
|
title="Cupo alcanzado"
|
||||||
|
description="El grupo llegó a su cupo máximo de miembros."
|
||||||
|
maxWidth="md"
|
||||||
|
>
|
||||||
|
{pendingCapacityPayload ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-foreground/70">
|
||||||
|
El grupo <strong className="text-primary">{group.name}</strong> ya alcanzó su cupo de{' '}
|
||||||
|
<strong className="text-primary">{group.capacity}</strong> miembros. ¿Qué deseas hacer con{' '}
|
||||||
|
<strong className="text-primary">
|
||||||
|
{`${pendingCapacityPayload.firstName.trim()} ${pendingCapacityPayload.lastName.trim()}`.trim()}
|
||||||
|
</strong>
|
||||||
|
?
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => createAttendeeForceMutation.mutate({ ...pendingCapacityPayload })}
|
||||||
|
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{createAttendeeForceMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
)}
|
||||||
|
<span>Agregar de todos modos</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => addToWaitlistMutation.mutate({ ...pendingCapacityPayload })}
|
||||||
|
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{addToWaitlistMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Clock className="size-4" />
|
||||||
|
)}
|
||||||
|
<span>Sumar a la lista de espera</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-foreground/50 text-center">
|
||||||
|
Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* MODAL: AVISO DE CUPO EN CARGA MASIVA */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isBulkCapacityModalOpen}
|
||||||
|
onClose={() => setIsBulkCapacityModalOpen(false)}
|
||||||
|
title="Aviso de cupo"
|
||||||
|
description="La carga supera el cupo del grupo."
|
||||||
|
maxWidth="md"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-foreground/70">
|
||||||
|
Estás por importar <strong className="text-primary">{validRowsCount}</strong> miembro(s), pero el grupo ya
|
||||||
|
tiene <strong className="text-primary">{attendeesTotal}</strong> inscrito(s) y su cupo es de{' '}
|
||||||
|
<strong className="text-primary">{group.capacity}</strong>. ¿Deseas importarlos de todos modos?
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2.5 pt-2">
|
||||||
|
<Button variant="ghost" onClick={() => setIsBulkCapacityModalOpen(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsBulkCapacityModalOpen(false);
|
||||||
|
doBulkImport();
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Check className="size-4" />
|
||||||
|
Importar de todos modos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* MODAL: DETALLE DEL PARTICIPANTE */}
|
{/* MODAL: DETALLE DEL PARTICIPANTE */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={selectedAttendee !== null}
|
isOpen={selectedAttendee !== null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useParams, Link } from '@tanstack/react-router';
|
|||||||
import {
|
import {
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Loader2,
|
Loader2,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -32,6 +33,9 @@ export function JoinGroupView() {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Waitlist state (group is full)
|
||||||
|
const [waitlistInfo, setWaitlistInfo] = useState<{ message: string } | null>(null);
|
||||||
|
|
||||||
const inviteQuery = useQuery({
|
const inviteQuery = useQuery({
|
||||||
queryKey: ['public-invite', token],
|
queryKey: ['public-invite', token],
|
||||||
queryFn: () => getInviteInfo(token),
|
queryFn: () => getInviteInfo(token),
|
||||||
@@ -49,9 +53,15 @@ export function JoinGroupView() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
if (data.status === 'waitlisted') {
|
||||||
|
setRegisteredAttendee(null);
|
||||||
|
setWaitlistInfo({ message: data.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWaitlistInfo(null);
|
||||||
setRegisteredAttendee({
|
setRegisteredAttendee({
|
||||||
fullName: data.attendee.fullName,
|
fullName: data.attendee!.fullName,
|
||||||
phone: data.attendee.phone,
|
phone: data.attendee!.phone,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
@@ -163,8 +173,32 @@ export function JoinGroupView() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Waitlist Confirmation (group full) */}
|
||||||
|
{waitlistInfo && group ? (
|
||||||
|
<div className="rounded-2xl border border-accent/30 bg-surface p-6 sm:p-8 text-center shadow-xl space-y-5 animate-step-enter">
|
||||||
|
<div className="size-16 rounded-full bg-accent-soft text-accent flex items-center justify-center mx-auto">
|
||||||
|
<Clock className="size-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Badge variant="neutral" className="mb-2">
|
||||||
|
Lista de Espera
|
||||||
|
</Badge>
|
||||||
|
<h1 className="text-2xl font-bold text-primary">Cupo completo</h1>
|
||||||
|
<p className="mt-2 text-sm text-foreground/70 leading-relaxed">
|
||||||
|
{waitlistInfo.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-foreground/60">
|
||||||
|
El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '}
|
||||||
|
<strong className="text-primary font-semibold">{group.name}</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Inscription Form */}
|
{/* Inscription Form */}
|
||||||
{!registeredAttendee && group ? (
|
{!registeredAttendee && !waitlistInfo && group ? (
|
||||||
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8 shadow-xl space-y-6">
|
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8 shadow-xl space-y-6">
|
||||||
{/* Group Info Header */}
|
{/* Group Info Header */}
|
||||||
<div className="border-b border-border pb-5">
|
<div className="border-b border-border pb-5">
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ export const CreateAttendeeSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CreateAttendee = z.output<typeof CreateAttendeeSchema>;
|
export type CreateAttendee = z.output<typeof CreateAttendeeSchema>;
|
||||||
|
|
||||||
|
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<typeof CreateAttendeeResultSchema>;
|
||||||
|
|
||||||
export const JoinGroupViaInviteSchema = z.object({
|
export const JoinGroupViaInviteSchema = z.object({
|
||||||
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||||
lastName: z.string().trim().min(1, 'El apellido es obligatorio'),
|
lastName: z.string().trim().min(1, 'El apellido es obligatorio'),
|
||||||
@@ -50,6 +62,13 @@ export const JoinGroupViaInviteSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type JoinGroupViaInvite = z.output<typeof JoinGroupViaInviteSchema>;
|
export type JoinGroupViaInvite = z.output<typeof JoinGroupViaInviteSchema>;
|
||||||
|
|
||||||
|
export const JoinGroupViaInviteResultSchema = z.object({
|
||||||
|
status: z.enum(['registered', 'waitlisted']),
|
||||||
|
message: z.string(),
|
||||||
|
attendee: AttendeeDtoSchema.nullable(),
|
||||||
|
});
|
||||||
|
export type JoinGroupViaInviteResult = z.output<typeof JoinGroupViaInviteResultSchema>;
|
||||||
|
|
||||||
export const BulkAttendeeItemSchema = z.object({
|
export const BulkAttendeeItemSchema = z.object({
|
||||||
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||||
lastName: z.string().trim().optional().default(''),
|
lastName: z.string().trim().optional().default(''),
|
||||||
|
|||||||
@@ -29,3 +29,25 @@ export const WaitlistListSchema = z.object({
|
|||||||
pagination: paginationSchema,
|
pagination: paginationSchema,
|
||||||
});
|
});
|
||||||
export type WaitlistList = z.output<typeof WaitlistListSchema>;
|
export type WaitlistList = z.output<typeof WaitlistListSchema>;
|
||||||
|
|
||||||
|
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<typeof GroupWaitlistEntryDtoSchema>;
|
||||||
|
|
||||||
|
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<typeof CreateGroupWaitlistEntrySchema>;
|
||||||
Reference in New Issue
Block a user