feat: manage waitlist and remove group members

- Add group-waitlist module (list/promote/remove entries)
- Remove attendee with optional atomic promotion from waitlist
- Block removal when attendee has payments (attendee_has_payments)
- Waitlist detail modal matching members UI; optimistic add-to-waitlist
- Responsive tweaks for the members/waitlist tabs
This commit is contained in:
Jose Selesan
2026-09-22 17:04:41 -03:00
parent 785d54df7e
commit fc30927b8b
19 changed files with 1417 additions and 103 deletions

View File

@@ -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<PrismaClient, 'group'>,
groupId: string,
userId: string,
): Promise<Result<Group, ProblemDetails>> {
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<PrismaClient, 'group'>,
groupId: string,
userId: string,
): Promise<Result<Group, ProblemDetails>> {
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);
}

View File

@@ -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<PrismaClient, 'attendee' | 'groupWaitlistEntry'>;
type PromoteGroup = {
id: string;
capacity: number | null;
};
export async function findFirstPendingWaitlistEntry(
db: PromoteDb,
groupId: string,
): Promise<GroupWaitlistEntryRecord | null> {
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<Result<AttendeeDto, ProblemDetails>> {
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<Result<PromoteFirstResult | null, ProblemDetails>> {
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 });
}