Initial attendee management

This commit is contained in:
Jose Selesan
2026-09-18 09:42:37 -03:00
parent fa30fe2cff
commit d7ebb5b234
111 changed files with 13784 additions and 36 deletions

View File

@@ -0,0 +1,21 @@
import { type BulkCreateAttendees, BulkCreateAttendeesSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { BulkCreateAttendeesUseCase } from './use-case';
const route = new Hono();
route.post('/:groupId/attendees/bulk', validate.json(BulkCreateAttendeesSchema), 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 BulkCreateAttendees;
const useCase = new BulkCreateAttendeesUseCase();
const result = await useCase.execute(groupId, user.id, body);
return resultJson(c, result, { status: 201 });
});
export default route;

View File

@@ -0,0 +1,111 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
BulkCreateAttendees,
BulkCreateAttendeesResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
type BulkCreateAttendeesDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee'>;
};
export class BulkCreateAttendeesUseCase {
constructor(private readonly deps: BulkCreateAttendeesDeps = {}) {}
async execute(
groupId: string,
userId: string,
payload: BulkCreateAttendees,
): Promise<Result<BulkCreateAttendeesResult, 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());
}
// Cargar teléfonos existentes del grupo
const existingAttendees = await db.attendee.findMany({
where: { groupId },
select: { phone: true },
});
const existingPhones = new Set<string>();
for (const a of existingAttendees) {
if (a.phone) {
existingPhones.add(normalizePhone(a.phone));
}
}
let duplicatesCount = 0;
let invalidCount = 0;
const toCreateData: Array<{
groupId: string;
fullName: string;
phone: string;
email: string | null;
notes: string | null;
}> = [];
for (const item of payload.attendees) {
const phone = normalizePhone(item.phone || '');
const rawFullName = item.fullName?.trim() || `${item.firstName?.trim() ?? ''} ${item.lastName?.trim() ?? ''}`.trim();
if (!rawFullName || !phone) {
invalidCount++;
continue;
}
if (existingPhones.has(phone)) {
duplicatesCount++;
continue;
}
existingPhones.add(phone);
toCreateData.push({
groupId,
fullName: rawFullName,
phone,
email: item.email?.trim() || null,
notes: item.notes?.trim() || null,
});
}
const createdRecords: AttendeeRecord[] = [];
for (const data of toCreateData) {
const record = await db.attendee.create({ data });
createdRecords.push(record as unknown as AttendeeRecord);
}
const createdCount = createdRecords.length;
const message = duplicatesCount > 0
? `${createdCount} alumnos agregados con éxito, ${duplicatesCount} duplicados omitidos.`
: `${createdCount} alumnos agregados con éxito.`;
return ok({
totalProcessed: payload.attendees.length,
createdCount,
duplicatesCount,
invalidCount,
message,
created: createdRecords.map(toAttendeeDto),
});
}
}

View File

@@ -0,0 +1,21 @@
import { type CreateAttendee, CreateAttendeeSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { CreateAttendeeUseCase } from './use-case';
const route = new Hono();
route.post('/:groupId/attendees', validate.json(CreateAttendeeSchema), 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 CreateAttendee;
const useCase = new CreateAttendeeUseCase();
const result = await useCase.execute(groupId, user.id, body);
return resultJson(c, result, { status: 201 });
});
export default route;

View File

@@ -0,0 +1,72 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { AttendeeDto, CreateAttendee, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { conflictProblem, noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
type CreateAttendeeDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee'>;
};
export class CreateAttendeeUseCase {
constructor(private readonly deps: CreateAttendeeDeps = {}) {}
async execute(
groupId: string,
userId: string,
payload: CreateAttendee,
): Promise<Result<AttendeeDto, 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 existing = await db.attendee.findFirst({
where: {
groupId,
OR: [
{ phone },
{ phone: payload.phone.trim() },
],
},
});
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 fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
const attendee = await db.attendee.create({
data: {
groupId,
fullName,
phone,
email: payload.email?.trim() || null,
notes: payload.notes?.trim() || null,
},
});
return ok(toAttendeeDto(attendee as unknown as AttendeeRecord));
}
}

View File

@@ -1,57 +1,34 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { AttendeeDto, AttendeeList, AttendeeQuery, ProblemDetails, Result } from '@gruperly/shared';
import type { AttendeeList, AttendeeQuery, ProblemDetails, Result } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma';
import { type AttendeeRecord, toAttendeeDto } from '../../lib/helpers';
type ListAttendeesDeps = {
db?: Pick<PrismaClient, 'attendee'>;
};
type AttendeeRecord = {
id: string;
groupId: string;
fullName: string;
email: string | null;
phone: string | null;
guardianName: string | null;
guardianPhone: string | null;
notes: string | null;
createdAt: Date;
updatedAt: Date;
};
function toAttendeeDto(record: AttendeeRecord): AttendeeDto {
return {
id: record.id,
groupId: record.groupId,
fullName: record.fullName,
email: record.email,
phone: record.phone,
guardianName: record.guardianName,
guardianPhone: record.guardianPhone,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
export class ListAttendees {
constructor(private readonly deps: ListAttendeesDeps = {}) {}
async execute(query: AttendeeQuery): Promise<Result<AttendeeList, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const where = query.groupId ? { groupId: query.groupId } : undefined;
const [records, total] = await Promise.all([
db.attendee.findMany({
...(where ? { where } : {}),
skip: getPaginationOffset(query),
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
db.attendee.count(),
where ? db.attendee.count({ where }) : db.attendee.count(),
]);
return ok({
data: records.map(toAttendeeDto),
data: records.map((r) => toAttendeeDto(r as AttendeeRecord)),
pagination: getPaginationMetadata(query, total),
});
}