Refactor to vertical slice

This commit is contained in:
Jose Selesan
2026-06-26 14:05:37 -03:00
parent 3949c9add1
commit c8477de5d2
149 changed files with 5011 additions and 5127 deletions

View File

@@ -0,0 +1,67 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import type {
AcceptComplexInvitationsInput,
AcceptComplexInvitationsResponse,
} from '@repo/api-contract';
import { hashToken, normalizeEmail } from '../../shared/members-helpers';
export async function acceptComplexInvitations(
userId: string,
email: string,
input: AcceptComplexInvitationsInput = {}
): Promise<Result<AcceptComplexInvitationsResponse>> {
const normalizedEmail = normalizeEmail(email);
const tokenHash = input.inviteToken ? hashToken(input.inviteToken) : null;
const invitations = await db.complexInvitation.findMany({
where: {
email: normalizedEmail,
acceptedAt: null,
revokedAt: null,
expiresAt: { gte: new Date() },
...(tokenHash ? { tokenHash } : {}),
},
orderBy: { createdAt: 'asc' },
});
if (tokenHash && invitations.length === 0) {
return err(Errors.notFound('La invitación es inválida o ya venció.'));
}
let acceptedCount = 0;
for (const invitation of invitations) {
await db.$transaction(async (tx) => {
const existingMembership = await tx.complexUser.findUnique({
where: {
complexId_userId: {
complexId: invitation.complexId,
userId,
},
},
select: { userId: true },
});
if (!existingMembership) {
await tx.complexUser.create({
data: {
complexId: invitation.complexId,
userId,
role: 'EMPLOYEE',
},
});
}
await tx.complexInvitation.update({
where: { id: invitation.id },
data: { acceptedAt: new Date() },
});
});
acceptedCount += 1;
}
return ok({ acceptedCount });
}

View File

@@ -0,0 +1,11 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import type { AcceptComplexInvitationsInput } from '@repo/api-contract';
import { acceptComplexInvitations } from './accept-complex-invitations.business';
export async function acceptComplexInvitationsHandler(c: AppContext) {
const user = c.get('user');
const payload = c.req.valid('json' as never) as AcceptComplexInvitationsInput;
return handleResult(c, await acceptComplexInvitations(user.id, user.email, payload));
}

View File

@@ -0,0 +1,38 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import type { CancelComplexInvitationResponse } from '@repo/api-contract';
import { ensureComplexAdmin } from '../../shared/members-helpers';
export async function cancelComplexInvitation(
userId: string,
complexId: string,
invitationId: string
): Promise<Result<CancelComplexInvitationResponse>> {
const adminResult = await ensureComplexAdmin(userId, complexId);
if (!adminResult.ok) return adminResult;
const invitation = await db.complexInvitation.findUnique({
where: { id: invitationId },
select: { complexId: true, acceptedAt: true, revokedAt: true },
});
if (!invitation || invitation.complexId !== complexId) {
return err(Errors.notFound('La invitación no existe.'));
}
if (invitation.acceptedAt) {
return err(Errors.conflict('La invitación ya fue aceptada.'));
}
if (invitation.revokedAt) {
return ok({ ok: true });
}
await db.complexInvitation.update({
where: { id: invitationId },
data: { revokedAt: new Date() },
});
return ok({ ok: true });
}

View File

@@ -0,0 +1,13 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
import { cancelComplexInvitation } from './cancel-complex-invitation.business';
const cancelInvitationParamsSchema = z.object({ id: z.uuid(), invitationId: z.uuid() });
export async function cancelComplexInvitationHandler(c: AppContext) {
const user = c.get('user');
const params = cancelInvitationParamsSchema.parse(c.req.param());
return handleResult(c, await cancelComplexInvitation(user.id, params.id, params.invitationId));
}

View File

@@ -0,0 +1,101 @@
import { db } from '@/lib/prisma';
import { buildUniqueSlug, slugify } from '@/lib/slug';
import { ensureActiveSport } from '@/modules/court/shared/guards';
import type { CreateComplexInput } from '@repo/api-contract';
import { v7 as uuidv7 } from 'uuid';
type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY';
type CreateComplexInternalInput = CreateComplexInput & {
adminEmail: string;
userId?: string;
};
function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes;
}
function assertAvailabilityRanges(
availability: Array<{
dayOfWeek: DayOfWeek;
startTime: string;
endTime: string;
}>
) {
for (const range of availability) {
const start = toMinutes(range.startTime);
const end = toMinutes(range.endTime);
if (start >= end) {
throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`);
}
}
}
export async function createComplex(input: CreateComplexInternalInput) {
return db.$transaction(async (tx) => {
const base = slugify(input.complexName);
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
const complexSlug = await buildUniqueSlug(fallback, (slug) =>
tx.complex.findFirst({ where: { complexSlug: slug }, select: { id: true } })
);
const complex = await tx.complex.create({
data: {
id: uuidv7(),
complexName: input.complexName,
physicalAddress: input.physicalAddress.trim(),
city: input.city?.trim() || null,
state: input.state?.trim() || null,
country: input.country?.trim() || null,
complexSlug,
adminEmail: input.adminEmail,
planCode: input.planCode,
},
});
if (input.userId) {
await tx.complexUser.create({
data: {
complexId: complex.id,
userId: input.userId,
role: 'ADMIN',
},
});
}
if (input.setupCourts && input.courtSportId) {
const sport = await ensureActiveSport(input.courtSportId);
const availability = (input.courtDaysOfWeek ?? []).map((day) => ({
dayOfWeek: day as DayOfWeek,
startTime: input.courtStartTime ?? '08:00',
endTime: input.courtEndTime ?? '22:00',
}));
assertAvailabilityRanges(availability);
const court = await tx.court.create({
data: {
id: uuidv7(),
complexId: complex.id,
sportId: input.courtSportId,
name: `${sport.name} 1`,
slotDurationMinutes: 60,
basePrice: 0,
},
});
await tx.courtAvailability.createMany({
data: availability.map((avail) => ({
id: uuidv7(),
courtId: court.id,
dayOfWeek: avail.dayOfWeek,
startTime: avail.startTime,
endTime: avail.endTime,
})),
});
}
return complex;
});
}

View File

@@ -0,0 +1,51 @@
import { db } from '@/lib/prisma';
import type { AppContext } from '@/types/hono';
import type { CreateComplexInput } from '@repo/api-contract';
import { createComplex } from './create-complex.business';
export async function createComplexHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as CreateComplexInput;
const user = c.get('user');
let adminEmail = user?.email;
let userId = user?.id;
if (!adminEmail) {
const body = await c.req.json().catch(() => ({}));
adminEmail = body.adminEmail;
userId = body.userId;
}
if (!adminEmail) {
return c.json({ message: 'Se requiere email del administrador.' }, 400);
}
if (!userId && adminEmail) {
const existingUser = await db.user.findUnique({
where: { email: adminEmail },
select: { id: true },
});
if (!existingUser) {
return c.json({ message: 'El usuario no existe.' }, 404);
}
userId = existingUser.id;
}
const complex = await createComplex({
complexName: payload.complexName,
physicalAddress: payload.physicalAddress,
adminEmail,
userId,
planCode: payload.planCode,
city: payload.city,
state: payload.state,
country: payload.country,
setupCourts: payload.setupCourts,
courtSportId: payload.courtSportId,
courtStartTime: payload.courtStartTime,
courtEndTime: payload.courtEndTime,
courtDaysOfWeek: payload.courtDaysOfWeek,
});
return c.json(complex, 201);
}

View File

@@ -0,0 +1,5 @@
import { db } from '@/lib/prisma';
export async function getComplexById(id: string) {
return db.complex.findUnique({ where: { id } });
}

View File

@@ -0,0 +1,16 @@
import type { AppContext } from '@/types/hono';
import { getComplexById } from './get-complex-by-id.business';
type ComplexIdParams = { id: string };
export async function getComplexByIdHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as ComplexIdParams;
const complex = await getComplexById(id);
if (!complex) {
return c.json({ message: 'Complejo no encontrado.' }, 404);
}
return c.json(complex);
}

View File

@@ -0,0 +1,5 @@
import { db } from '@/lib/prisma';
export async function getComplexBySlug(slug: string) {
return db.complex.findUnique({ where: { complexSlug: slug } });
}

View File

@@ -0,0 +1,16 @@
import type { AppContext } from '@/types/hono';
import { getComplexBySlug } from './get-complex-by-slug.business';
type ComplexSlugParams = { slug: string };
export async function getComplexBySlugHandler(c: AppContext) {
const { slug } = c.req.valid('param' as never) as ComplexSlugParams;
const complex = await getComplexBySlug(slug);
if (!complex) {
return c.json({ message: 'Complejo no encontrado.' }, 404);
}
return c.json(complex);
}

View File

@@ -0,0 +1,34 @@
import { db } from '@/lib/prisma';
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
export async function getCurrentComplex(userId: string, complexId: string) {
const complexUser = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId,
},
},
include: {
complex: {
include: {
plan: true,
},
},
},
});
if (!complexUser) return null;
return {
...complexUser.complex,
role: complexUser.role,
planFeatures: complexUser.complex.plan
? parsePlanRules(complexUser.complex.plan.rules).features
: null,
};
}
export async function selectComplex(userId: string, complexId: string) {
return getCurrentComplex(userId, complexId);
}

View File

@@ -0,0 +1,22 @@
import type { AppContext } from '@/types/hono';
import { getCookie } from 'hono/cookie';
import { getCurrentComplex } from './get-current-complex.business';
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
export async function getCurrentComplexHandler(c: AppContext) {
const user = c.get('user');
const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE);
if (!complexId) {
return c.json(null);
}
const complex = await getCurrentComplex(user.id, complexId);
if (!complex) {
return c.json(null);
}
return c.json(complex);
}

View File

@@ -0,0 +1,99 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import type { InviteComplexUserInput, InviteComplexUserResponse } from '@repo/api-contract';
import { v7 as uuidv7 } from 'uuid';
import {
createInvitationToken,
ensureComplexAdmin,
formatInvitation,
hashToken,
normalizeEmail,
nowPlusDays,
sendInvitationEmail,
} from '../../shared/members-helpers';
export async function inviteComplexUser(
userId: string,
complexId: string,
input: InviteComplexUserInput
): Promise<Result<InviteComplexUserResponse>> {
const adminResult = await ensureComplexAdmin(userId, complexId);
if (!adminResult.ok) return adminResult;
const email = normalizeEmail(input.email);
const complex = await db.complex.findUnique({
where: { id: complexId },
select: { complexName: true },
});
if (!complex) {
return err(Errors.notFound('El complejo no existe.'));
}
const existingUser = await db.user.findUnique({
where: { email },
select: { id: true },
});
if (existingUser) {
const existingMembership = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId: existingUser.id,
},
},
select: { userId: true },
});
if (existingMembership) {
return err(Errors.conflict('Ese usuario ya pertenece al complejo.'));
}
}
const token = createInvitationToken();
const invitation = await db.$transaction(async (tx) => {
const pendingInvitation = await tx.complexInvitation.findFirst({
where: {
complexId,
email,
acceptedAt: null,
revokedAt: null,
expiresAt: { gte: new Date() },
},
orderBy: { createdAt: 'desc' },
});
if (pendingInvitation) {
return tx.complexInvitation.update({
where: { id: pendingInvitation.id },
data: {
tokenHash: hashToken(token),
expiresAt: nowPlusDays(Number(Bun.env.COMPLEX_INVITATION_TTL_DAYS ?? 7)),
acceptedAt: null,
revokedAt: null,
},
});
}
return tx.complexInvitation.create({
data: {
id: uuidv7(),
complexId,
email,
tokenHash: hashToken(token),
expiresAt: nowPlusDays(Number(Bun.env.COMPLEX_INVITATION_TTL_DAYS ?? 7)),
},
});
});
await sendInvitationEmail({
complexName: complex.complexName,
inviteeEmail: email,
token,
});
return ok({ invitation: formatInvitation(invitation) });
}

View File

@@ -0,0 +1,25 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import type { InviteComplexUserInput } from '@repo/api-contract';
import { z } from 'zod';
import { inviteComplexUser as inviteComplexUserBusiness } from './invite-complex-user.business';
const complexParamsSchema = z.object({ id: z.uuid() });
type Deps = {
inviteComplexUser: typeof inviteComplexUserBusiness;
};
export function createInviteComplexUserHandler(
deps: Deps = { inviteComplexUser: inviteComplexUserBusiness }
) {
return async function inviteComplexUserHandler(c: AppContext) {
const user = c.get('user');
const params = complexParamsSchema.parse(c.req.param());
const payload = c.req.valid('json' as never) as InviteComplexUserInput;
return handleResult(c, await deps.inviteComplexUser(user.id, params.id, payload), 201);
};
}
export const inviteComplexUserHandler = createInviteComplexUserHandler();

View File

@@ -0,0 +1,44 @@
import { resolveAvatarUrl } from '@/lib/avatar';
import { db } from '@/lib/prisma';
import { ensureComplexMember, formatInvitation } from '../../shared/members-helpers';
export async function listComplexUsers(complexId: string) {
const [members, invitations] = await Promise.all([
db.complexUser.findMany({
where: { complexId },
include: { user: true },
orderBy: { createdAt: 'asc' },
}),
db.complexInvitation.findMany({
where: {
complexId,
acceptedAt: null,
revokedAt: null,
expiresAt: { gte: new Date() },
},
orderBy: { createdAt: 'desc' },
}),
]);
const membersWithAvatars = members.map((member) => ({
userId: member.userId,
fullName: member.user.name,
email: member.user.email,
avatarUrl: resolveAvatarUrl({
email: member.user.email,
image: member.user.image,
}),
role: member.role,
createdAt: member.createdAt.toISOString(),
}));
return {
members: membersWithAvatars,
invitations: invitations.map(formatInvitation),
};
}
export async function getComplexUsersOverview(userId: string, complexId: string) {
await ensureComplexMember(userId, complexId);
return listComplexUsers(complexId);
}

View File

@@ -0,0 +1,12 @@
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
import { getComplexUsersOverview } from './list-complex-users.business';
const complexParamsSchema = z.object({ id: z.uuid() });
export async function listComplexUsersHandler(c: AppContext) {
const user = c.get('user');
const params = complexParamsSchema.parse(c.req.param());
const result = await getComplexUsersOverview(user.id, params.id);
return c.json(result);
}

View File

@@ -0,0 +1,24 @@
import { db } from '@/lib/prisma';
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
export async function listMyComplexes(userId: string) {
const complexUsers = await db.complexUser.findMany({
where: { userId },
include: {
complex: {
include: {
plan: true,
},
},
},
orderBy: { createdAt: 'asc' },
});
return complexUsers.map((complexUser) => ({
...complexUser.complex,
role: complexUser.role,
planFeatures: complexUser.complex.plan
? parsePlanRules(complexUser.complex.plan.rules).features
: null,
}));
}

View File

@@ -0,0 +1,8 @@
import type { AppContext } from '@/types/hono';
import { listMyComplexes } from './list-my-complexes.business';
export async function listMyComplexesHandler(c: AppContext) {
const user = c.get('user');
const complexes = await listMyComplexes(user.id);
return c.json(complexes);
}

View File

@@ -0,0 +1,58 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import type { InviteComplexUserResponse } from '@repo/api-contract';
import {
ensureComplexAdmin,
formatInvitation,
refreshPendingInvitation,
sendInvitationEmail,
} from '../../shared/members-helpers';
export async function resendComplexInvitation(
userId: string,
complexId: string,
invitationId: string
): Promise<Result<InviteComplexUserResponse>> {
const adminResult = await ensureComplexAdmin(userId, complexId);
if (!adminResult.ok) return adminResult;
const pendingInvitation = await db.complexInvitation.findUnique({
where: { id: invitationId },
include: {
complex: { select: { complexName: true } },
},
});
if (!pendingInvitation || pendingInvitation.complexId !== complexId) {
return err(Errors.notFound('La invitación no existe.'));
}
if (pendingInvitation.acceptedAt) {
return err(Errors.conflict('La invitación ya fue aceptada.'));
}
if (pendingInvitation.revokedAt) {
return err(Errors.conflict('La invitación fue cancelada.'));
}
const refreshed = await refreshPendingInvitation(invitationId);
await sendInvitationEmail({
complexName: refreshed.invitation.complex.complexName,
inviteeEmail: pendingInvitation.email,
token: refreshed.token,
});
return ok({
invitation: formatInvitation({
id: refreshed.invitation.id,
complexId: refreshed.invitation.complexId,
email: refreshed.invitation.email,
expiresAt: refreshed.invitation.expiresAt,
acceptedAt: refreshed.invitation.acceptedAt,
revokedAt: refreshed.invitation.revokedAt,
createdAt: refreshed.invitation.createdAt,
}),
});
}

View File

@@ -0,0 +1,13 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
import { resendComplexInvitation } from './resend-complex-invitation.business';
const resendInvitationParamsSchema = z.object({ id: z.uuid(), invitationId: z.uuid() });
export async function resendComplexInvitationHandler(c: AppContext) {
const user = c.get('user');
const params = resendInvitationParamsSchema.parse(c.req.param());
return handleResult(c, await resendComplexInvitation(user.id, params.id, params.invitationId));
}

View File

@@ -0,0 +1,43 @@
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { type Result, err, ok } from '@/lib/result';
import type { RevokeComplexUserInput } from '@repo/api-contract';
import { ensureComplexAdmin } from '../../shared/members-helpers';
export async function revokeComplexUser(
userId: string,
complexId: string,
input: RevokeComplexUserInput
): Promise<Result<{ ok: boolean }>> {
const adminResult = await ensureComplexAdmin(userId, complexId);
if (!adminResult.ok) return adminResult;
const membership = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId: input.userId,
},
},
select: { role: true },
});
if (!membership) {
return err(Errors.notFound('Ese usuario no pertenece al complejo.'));
}
if (membership.role !== 'EMPLOYEE') {
return err(Errors.conflict('Solo podés revocar usuarios con rol EMPLOYEE.'));
}
await db.complexUser.delete({
where: {
complexId_userId: {
complexId,
userId: input.userId,
},
},
});
return ok({ ok: true });
}

View File

@@ -0,0 +1,13 @@
import { handleResult } from '@/lib/http/handle-result';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
import { revokeComplexUser } from './revoke-complex-user.business';
const revokeParamsSchema = z.object({ id: z.uuid(), userId: z.string().min(1) });
export async function revokeComplexUserHandler(c: AppContext) {
const user = c.get('user');
const params = revokeParamsSchema.parse(c.req.param());
return handleResult(c, await revokeComplexUser(user.id, params.id, { userId: params.userId }));
}

View File

@@ -0,0 +1,41 @@
import {
getCurrentComplex,
selectComplex,
} from '@/modules/complex/features/get-current-complex/get-current-complex.business';
import type { AppContext } from '@/types/hono';
import { setCookie } from 'hono/cookie';
import { z } from 'zod';
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
const selectComplexSchema = z.object({
complexId: z.string().uuid(),
});
export async function selectComplexHandler(c: AppContext) {
const user = c.get('user');
const body = await c.req.json();
const result = selectComplexSchema.safeParse(body);
if (!result.success) {
return c.json({ message: 'Invalid payload', issues: result.error.issues }, { status: 400 });
}
const { complexId } = result.data;
const complex = await selectComplex(user.id, complexId);
if (!complex) {
return c.json({ message: 'Complex not found or not associated with user' }, { status: 404 });
}
setCookie(c, SELECTED_COMPLEX_COOKIE, complexId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: COOKIE_MAX_AGE,
});
return c.json(complex);
}

View File

@@ -0,0 +1,62 @@
import { Prisma } from '@/generated/prisma/client';
import { db } from '@/lib/prisma';
import { buildUniqueSlug, slugify } from '@/lib/slug';
import type { UpdateComplexInput } from '@repo/api-contract';
export async function getComplexById(id: string) {
return db.complex.findUnique({ where: { id } });
}
export async function updateComplex(id: string, input: UpdateComplexInput) {
const data: Record<string, unknown> = {};
if (input.complexName) {
data.complexName = input.complexName;
const base = slugify(input.complexName);
data.complexSlug = await buildUniqueSlug(base, (slug) =>
db.complex.findFirst({
where: { complexSlug: slug, id: { not: id } },
select: { id: true },
})
);
}
if (input.complexSlug) {
const base = slugify(input.complexSlug);
data.complexSlug = await buildUniqueSlug(base, (slug) =>
db.complex.findFirst({
where: { complexSlug: slug, id: { not: id } },
select: { id: true },
})
);
}
if (input.adminEmail) {
data.adminEmail = input.adminEmail;
}
if (input.planCode !== undefined) {
data.planCode = input.planCode;
}
if (input.physicalAddress !== undefined) {
data.physicalAddress = input.physicalAddress?.trim() ?? null;
}
if (input.city !== undefined) {
data.city = input.city?.trim() ?? null;
}
if (input.state !== undefined) {
data.state = input.state?.trim() ?? null;
}
if (input.country !== undefined) {
data.country = input.country?.trim() ?? null;
}
return db.complex.update({
where: { id },
data: data as Prisma.ComplexUncheckedUpdateInput,
});
}

View File

@@ -0,0 +1,27 @@
import { Prisma } from '@/generated/prisma/client';
import type { AppContext } from '@/types/hono';
import type { UpdateComplexInput } from '@repo/api-contract';
import { getComplexById, updateComplex } from './update-complex.business';
type ComplexIdParams = { id: string };
export async function updateComplexHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as ComplexIdParams;
const payload = c.req.valid('json' as never) as UpdateComplexInput;
const existing = await getComplexById(id);
if (!existing) {
return c.json({ message: 'Complejo no encontrado.' }, 404);
}
try {
const complex = await updateComplex(id, payload);
return c.json(complex);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return c.json({ message: 'No se pudo actualizar el complejo.' }, 409);
}
throw error;
}
}