Initial attendee management
This commit is contained in:
@@ -14,6 +14,7 @@ import { attendeesRoutes } from './modules/attendees';
|
||||
import { authRoutes } from './modules/auth';
|
||||
import { groupsRoutes } from './modules/groups';
|
||||
import { healthCheckRoutes } from './modules/health-check';
|
||||
import { invitationsRoutes } from './modules/invitations';
|
||||
import { onboardingRoutes } from './modules/onboarding';
|
||||
import { paymentsRoutes } from './modules/payments';
|
||||
import { waitlistRoutes } from './modules/waitlist';
|
||||
@@ -33,6 +34,7 @@ api.use('*', sessionAuthMiddleware);
|
||||
|
||||
api.route('/auth', authRoutes);
|
||||
api.route('/health', healthCheckRoutes);
|
||||
api.route('/invitations', invitationsRoutes);
|
||||
api.route('/groups', groupsRoutes);
|
||||
api.route('/onboarding', onboardingRoutes);
|
||||
api.route('/attendees', attendeesRoutes);
|
||||
|
||||
@@ -31,6 +31,7 @@ export function isPublicApiRequest(method: string, path: string): boolean {
|
||||
normalizedPath === '/api/v1/health'
|
||||
|| matchesPublicPrefix(normalizedPath, '/api/v1/auth')
|
||||
|| matchesPublicPrefix(normalizedPath, '/api/auth')
|
||||
|| matchesPublicPrefix(normalizedPath, '/api/v1/invitations')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
21
apps/backend/src/modules/attendees/features/create/route.ts
Normal file
21
apps/backend/src/modules/attendees/features/create/route.ts
Normal 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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
40
apps/backend/src/modules/attendees/lib/helpers.ts
Normal file
40
apps/backend/src/modules/attendees/lib/helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { AttendeeDto } from '@gruperly/shared';
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export 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(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza números de teléfono eliminando espacios, guiones, paréntesis y puntos.
|
||||
* Conserva el '+' si lo tiene al inicio para números internacionales.
|
||||
*/
|
||||
export function normalizePhone(rawPhone: string): string {
|
||||
const trimmed = rawPhone.trim();
|
||||
const hasPlus = trimmed.startsWith('+');
|
||||
const digitsOnly = trimmed.replace(/\D/g, '');
|
||||
return hasPlus ? `+${digitsOnly}` : digitsOnly;
|
||||
}
|
||||
18
apps/backend/src/modules/groups/features/get-by-id/route.ts
Normal file
18
apps/backend/src/modules/groups/features/get-by-id/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetGroupById } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
const groupId = c.req.param('groupId');
|
||||
const useCase = new GetGroupById();
|
||||
const result = await useCase.execute(groupId, user.id);
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { GroupDto, 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 GroupRecord, toGroupDto } from '../../lib';
|
||||
|
||||
type GetGroupByIdDeps = {
|
||||
db?: Pick<PrismaClient, 'group'>;
|
||||
};
|
||||
|
||||
export class GetGroupById {
|
||||
constructor(private readonly deps: GetGroupByIdDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<GroupDto, 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());
|
||||
}
|
||||
|
||||
return ok(toGroupDto(group as unknown as GroupRecord));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetOrCreateInviteToken } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId/invite-token', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
const groupId = c.req.param('groupId');
|
||||
const useCase = new GetOrCreateInviteToken();
|
||||
const result = await useCase.execute(groupId, user.id);
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
route.post('/:groupId/invite-token', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
const groupId = c.req.param('groupId');
|
||||
const regenerate = c.req.query('regenerate') === 'true';
|
||||
const useCase = new GetOrCreateInviteToken();
|
||||
const result = await useCase.execute(groupId, user.id, { regenerate });
|
||||
return resultJson(c, result, { status: 201 });
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { InviteTokenResult, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
type InviteTokenDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'groupMember'>;
|
||||
};
|
||||
|
||||
export class GetOrCreateInviteToken {
|
||||
constructor(private readonly deps: InviteTokenDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
options?: { regenerate?: boolean },
|
||||
): Promise<Result<InviteTokenResult, 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());
|
||||
}
|
||||
|
||||
let token = group.inviteToken;
|
||||
if (!token || options?.regenerate) {
|
||||
token = nanoid(12);
|
||||
await db.group.update({
|
||||
where: { id: groupId },
|
||||
data: { inviteToken: token },
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = process.env.WEB_URL ?? 'http://localhost:6173';
|
||||
const inviteUrl = `${baseUrl}/join/${token}`;
|
||||
|
||||
return ok({ token, inviteUrl });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type AttendeeQuery, AttendeeQuerySchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { resultJson } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { ListAttendees } from '@/modules/attendees/features/get-all/use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId/attendees', validate.query(AttendeeQuerySchema), async (c) => {
|
||||
const groupId = c.req.param('groupId');
|
||||
const query = c.req.valid('query') as AttendeeQuery;
|
||||
const useCase = new ListAttendees();
|
||||
const result = await useCase.execute({ ...query, groupId });
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -18,6 +18,7 @@ export type GroupRecord = {
|
||||
price: PriceLike | number | null;
|
||||
billingType: BillingType | null;
|
||||
dueDay: number | null;
|
||||
inviteToken?: string | null;
|
||||
};
|
||||
|
||||
export function toGroupDto(record: GroupRecord): GroupDto {
|
||||
@@ -34,6 +35,7 @@ export function toGroupDto(record: GroupRecord): GroupDto {
|
||||
price: record.price == null ? null : Number(record.price.toString()),
|
||||
billingType: record.billingType,
|
||||
dueDay: record.dueDay,
|
||||
inviteToken: record.inviteToken ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { Hono } from 'hono';
|
||||
import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
|
||||
import createAttendeeRoute from '../attendees/features/create/route';
|
||||
import createFromOrganizationRoute from './features/create-from-organization/route';
|
||||
import getAllRoute from './features/get-all/route';
|
||||
import getByIdRoute from './features/get-by-id/route';
|
||||
import inviteTokenRoute from './features/invite-token/route';
|
||||
import listAttendeesRoute from './features/list-attendees/route';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.route('/', getAllRoute);
|
||||
routes.route('/from-organization', createFromOrganizationRoute);
|
||||
routes.route('/', inviteTokenRoute);
|
||||
routes.route('/', listAttendeesRoute);
|
||||
routes.route('/', createAttendeeRoute);
|
||||
routes.route('/', bulkCreateAttendeesRoute);
|
||||
routes.route('/', getByIdRoute);
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Hono } from 'hono';
|
||||
import { resultJson } from '@/http/problem-details';
|
||||
import { GetInviteInfo } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
const useCase = new GetInviteInfo();
|
||||
const result = await useCase.execute(token);
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { GroupInviteInfoDto, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
type GetInviteInfoDeps = {
|
||||
db?: Pick<PrismaClient, 'group'>;
|
||||
};
|
||||
|
||||
export class GetInviteInfo {
|
||||
constructor(private readonly deps: GetInviteInfoDeps = {}) {}
|
||||
|
||||
async execute(token: string): Promise<Result<GroupInviteInfoDto, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const group = await db.group.findUnique({
|
||||
where: { inviteToken: token },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
return err(notFoundResourceProblem('Invitación', token));
|
||||
}
|
||||
|
||||
return ok({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
teacherName: group.owner.name,
|
||||
days: group.days,
|
||||
time: group.time,
|
||||
capacity: group.capacity,
|
||||
price: group.price == null ? null : Number(group.price.toString()),
|
||||
billingType: group.billingType,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type JoinGroupViaInvite, JoinGroupViaInviteSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { resultJson } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { JoinViaInvite } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/:token/join', validate.json(JoinGroupViaInviteSchema), async (c) => {
|
||||
const token = c.req.param('token');
|
||||
const body = c.req.valid('json') as JoinGroupViaInvite;
|
||||
const useCase = new JoinViaInvite();
|
||||
const result = await useCase.execute(token, body);
|
||||
return resultJson(c, result, { status: 201 });
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { AttendeeDto, JoinGroupViaInvite, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { conflictProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers';
|
||||
|
||||
type JoinViaInviteDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'attendee'>;
|
||||
};
|
||||
|
||||
export type JoinViaInviteResult = {
|
||||
attendee: AttendeeDto;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export class JoinViaInvite {
|
||||
constructor(private readonly deps: JoinViaInviteDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
token: string,
|
||||
payload: JoinGroupViaInvite,
|
||||
): Promise<Result<JoinViaInviteResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const group = await db.group.findUnique({
|
||||
where: { inviteToken: token },
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
return err(notFoundResourceProblem('Invitación', token));
|
||||
}
|
||||
|
||||
const phone = normalizePhone(payload.phone);
|
||||
const existing = await db.attendee.findFirst({
|
||||
where: {
|
||||
groupId: group.id,
|
||||
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 created = await db.attendee.create({
|
||||
data: {
|
||||
groupId: group.id,
|
||||
fullName,
|
||||
phone,
|
||||
email: payload.email?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return ok({
|
||||
attendee: toAttendeeDto(created as unknown as AttendeeRecord),
|
||||
message: 'Inscripción realizada con éxito',
|
||||
});
|
||||
}
|
||||
}
|
||||
1
apps/backend/src/modules/invitations/index.ts
Normal file
1
apps/backend/src/modules/invitations/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as invitationsRoutes } from './routes';
|
||||
10
apps/backend/src/modules/invitations/routes.ts
Normal file
10
apps/backend/src/modules/invitations/routes.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import getInviteInfoRoute from './features/get-invite-info/route';
|
||||
import joinViaInviteRoute from './features/join-via-invite/route';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.route('/', getInviteInfoRoute);
|
||||
routes.route('/', joinViaInviteRoute);
|
||||
|
||||
export default routes;
|
||||
Reference in New Issue
Block a user