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,26 @@
import { db } from '@/lib/prisma';
import { AdminServiceError } from '../../shared/errors';
export async function blockUser(userId: string, banReason?: string): Promise<void> {
const target = await db.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (!target) {
throw new AdminServiceError('Usuario no encontrado.', 404);
}
if (target.role === 'super_admin') {
throw new AdminServiceError('No se puede bloquear un super_admin.', 403);
}
await db.user.update({
where: { id: userId },
data: {
banned: true,
bannedAt: new Date(),
banReason: banReason ?? null,
},
});
}

View File

@@ -0,0 +1,21 @@
import type { AppContext } from '@/types/hono';
import type { AdminBlockUserInput } from '@repo/api-contract';
import { AdminServiceError } from '../../shared/errors';
import { blockUser } from './block-user.business';
type BlockUserParams = { id: string };
export async function blockUserHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as BlockUserParams;
const body = c.req.valid('json' as never) as AdminBlockUserInput;
try {
await blockUser(id, body.banReason);
return c.json({ message: 'Usuario bloqueado correctamente.' });
} catch (error) {
if (error instanceof AdminServiceError) {
return c.json({ message: error.message }, error.status);
}
throw error;
}
}

View File

@@ -0,0 +1,32 @@
import { db } from '@/lib/prisma';
import type { AdminCreatePlanInput } from '@repo/api-contract';
import { v7 as uuidv7 } from 'uuid';
export async function createPlan(input: AdminCreatePlanInput) {
const existing = await db.plan.findUnique({
where: { code: input.code },
});
if (existing) {
return { ok: false as const, message: 'Ya existe un plan con ese código.' };
}
const plan = await db.plan.create({
data: {
code: input.code,
name: input.name,
price: input.price,
rules: input.rules,
},
});
return {
ok: true as const,
data: {
code: plan.code,
name: plan.name,
price: Number(plan.price),
rules: plan.rules,
},
};
}

View File

@@ -0,0 +1,15 @@
import type { AppContext } from '@/types/hono';
import type { AdminCreatePlanInput } from '@repo/api-contract';
import { createPlan } from './create-plan.business';
export async function createPlanHandler(c: AppContext) {
const body = c.req.valid('json' as never) as AdminCreatePlanInput;
const result = await createPlan(body);
if (!result.ok) {
return c.json({ message: result.message }, 409);
}
return c.json(result.data, 201);
}

View File

@@ -0,0 +1,25 @@
import { db } from '@/lib/prisma';
export async function deletePlan(code: string) {
const existing = await db.plan.findUnique({
where: { code },
include: {
_count: { select: { complexes: true } },
},
});
if (!existing) {
return { ok: false as const, message: 'Plan no encontrado.' };
}
if (existing._count.complexes > 0) {
return {
ok: false as const,
message: 'No se puede eliminar un plan que tiene complejos asignados.',
};
}
await db.plan.delete({ where: { code } });
return { ok: true as const, message: 'Plan eliminado correctamente.' };
}

View File

@@ -0,0 +1,17 @@
import type { AppContext } from '@/types/hono';
import { deletePlan } from './delete-plan.business';
type DeletePlanParams = { code: string };
export async function deletePlanHandler(c: AppContext) {
const { code } = c.req.valid('param' as never) as DeletePlanParams;
const result = await deletePlan(code);
if (!result.ok) {
const status = result.message === 'Plan no encontrado.' ? 404 : 409;
return c.json({ message: result.message }, status);
}
return c.json({ message: result.message });
}

View File

@@ -0,0 +1,67 @@
import { db } from '@/lib/prisma';
type GeoStatsCountry = {
country: string;
countryCode: string;
totalUsers: number;
cities: { city: string; userCount: number }[];
};
export async function getGeoStats(): Promise<{
countries: GeoStatsCountry[];
totalUniqueCountries: number;
}> {
const sessions = await db.session.findMany({
where: {
country: { not: null },
expiresAt: { gt: new Date() },
},
select: {
country: true,
countryCode: true,
city: true,
userId: true,
},
});
const countryMap = new Map<
string,
{ country: string; countryCode: string; users: Set<string>; cities: Map<string, Set<string>> }
>();
for (const s of sessions) {
const code = s.countryCode || 'XX';
let entry = countryMap.get(code);
if (!entry) {
entry = {
country: s.country || 'Desconocido',
countryCode: code,
users: new Set(),
cities: new Map(),
};
countryMap.set(code, entry);
}
entry.users.add(s.userId);
const cityName = s.city || 'Desconocida';
let cityUsers = entry.cities.get(cityName);
if (!cityUsers) {
cityUsers = new Set();
entry.cities.set(cityName, cityUsers);
}
cityUsers.add(s.userId);
}
const countries = Array.from(countryMap.values())
.map((entry) => ({
country: entry.country,
countryCode: entry.countryCode,
totalUsers: entry.users.size,
cities: Array.from(entry.cities.entries())
.map(([city, users]) => ({ city, userCount: users.size }))
.sort((a, b) => b.userCount - a.userCount),
}))
.sort((a, b) => b.totalUsers - a.totalUsers);
return { countries, totalUniqueCountries: countries.length };
}

View File

@@ -0,0 +1,8 @@
import type { AppEnv } from '@/types/hono';
import type { Handler } from 'hono';
import { getGeoStats } from './get-geo-stats.business';
export const getGeoStatsHandler: Handler<AppEnv> = async (c) => {
const stats = await getGeoStats();
return c.json(stats);
};

View File

@@ -0,0 +1,49 @@
import { fetchGeoInfo } from '@/lib/geoip';
import { db } from '@/lib/prisma';
type AdminUserSession = {
id: string;
createdAt: string;
expiresAt: string;
ipAddress: string | null;
userAgent: string | null;
city: string | null;
country: string | null;
countryCode: string | null;
};
export async function getUserSessions(userId: string): Promise<AdminUserSession[]> {
const sessions = await db.session.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
const uniqueIps = [...new Set(sessions.map((s) => s.ipAddress).filter(Boolean))] as string[];
const geoResults = await Promise.all(uniqueIps.map((ip) => fetchGeoInfo(ip)));
const geoMap = new Map<
string,
{ city: string | null; country: string | null; countryCode: string | null }
>();
for (let i = 0; i < uniqueIps.length; i++) {
const info = geoResults[i];
geoMap.set(uniqueIps[i], {
city: info?.city ?? null,
country: info?.country ?? null,
countryCode: info?.countryCode ?? null,
});
}
return sessions.map((s) => {
const geo = s.ipAddress ? geoMap.get(s.ipAddress) : null;
return {
id: s.id,
createdAt: s.createdAt.toISOString(),
expiresAt: s.expiresAt.toISOString(),
ipAddress: s.ipAddress,
userAgent: s.userAgent,
city: geo?.city ?? null,
country: geo?.country ?? null,
countryCode: geo?.countryCode ?? null,
};
});
}

View File

@@ -0,0 +1,12 @@
import type { AppContext } from '@/types/hono';
import { getUserSessions } from './get-user-sessions.business';
type UserSessionsParams = { id: string };
export async function getUserSessionsHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as UserSessionsParams;
const sessions = await getUserSessions(id);
return c.json(sessions);
}

View File

@@ -0,0 +1,78 @@
import { db } from '@/lib/prisma';
type ComplexStats = {
id: string;
complexName: string;
complexSlug: string;
city: string | null;
planCode: string | null;
planName: string | null;
userCount: number;
courtCount: number;
totalBookings: number;
avgBookingsPerDay: number;
bookingsByStatus: {
confirmed: number;
cancelled: number;
completed: number;
noshow: number;
};
paymentStatus: 'active' | 'no_plan' | 'expired';
};
export async function getComplexStatsList(): Promise<ComplexStats[]> {
const complexes = await db.complex.findMany({
include: {
plan: true,
users: true,
courts: {
include: { bookings: true },
},
},
});
return complexes.map((complex) => {
const courtCount = complex.courts.length;
const allBookings = complex.courts.flatMap((c) => c.bookings);
const bookingsByStatus = {
confirmed: allBookings.filter((b) => b.status === 'CONFIRMED').length,
cancelled: allBookings.filter((b) => b.status === 'CANCELLED').length,
completed: allBookings.filter((b) => b.status === 'COMPLETED').length,
noshow: allBookings.filter((b) => b.status === 'NOSHOW').length,
};
const totalBookings = allBookings.length;
let avgBookingsPerDay = 0;
if (allBookings.length > 0) {
const dates = allBookings.map((b) => b.bookingDate);
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const daysDiff = Math.max(
1,
Math.ceil((Date.now() - minDate.getTime()) / (1000 * 60 * 60 * 24))
);
avgBookingsPerDay = Math.round((totalBookings / daysDiff) * 100) / 100;
}
let paymentStatus: 'active' | 'no_plan' | 'expired' = 'no_plan';
if (complex.plan) {
paymentStatus = 'active';
}
return {
id: complex.id,
complexName: complex.complexName,
complexSlug: complex.complexSlug,
city: complex.city,
planCode: complex.planCode,
planName: complex.plan?.name ?? null,
userCount: complex.users.length,
courtCount,
totalBookings,
avgBookingsPerDay,
bookingsByStatus,
paymentStatus,
};
});
}

View File

@@ -0,0 +1,7 @@
import type { AppContext } from '@/types/hono';
import { getComplexStatsList } from './list-complexes.business';
export async function listComplexesHandler(c: AppContext) {
const stats = await getComplexStatsList();
return c.json(stats);
}

View File

@@ -0,0 +1,19 @@
import { db } from '@/lib/prisma';
export async function listPlansAdmin() {
const plans = await db.plan.findMany({
orderBy: { price: 'asc' },
include: {
_count: { select: { complexes: true } },
},
});
return plans.map((plan) => ({
code: plan.code,
name: plan.name,
price: Number(plan.price),
rules: plan.rules,
lastUpdatedAt: plan.lastUpdatedAt.toISOString(),
complexCount: plan._count.complexes,
}));
}

View File

@@ -0,0 +1,7 @@
import type { AppContext } from '@/types/hono';
import { listPlansAdmin } from './list-plans-admin.business';
export async function listPlansAdminHandler(c: AppContext) {
const plans = await listPlansAdmin();
return c.json(plans);
}

View File

@@ -0,0 +1,44 @@
import { db } from '@/lib/prisma';
type AdminUser = {
id: string;
name: string;
email: string;
role: string;
banned: boolean;
bannedAt: string | null;
banReason: string | null;
createdAt: string;
complexCount: number;
activeSessions: number;
};
export async function listAdminUsers(search?: string): Promise<AdminUser[]> {
const users = await db.user.findMany({
where: search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
}
: undefined,
include: {
_count: { select: { complexes: true, sessions: true } },
},
orderBy: { createdAt: 'desc' },
});
return users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
banned: user.banned ?? false,
bannedAt: user.bannedAt?.toISOString() ?? null,
banReason: user.banReason ?? null,
createdAt: user.createdAt.toISOString(),
complexCount: user._count.complexes,
activeSessions: user._count.sessions,
}));
}

View File

@@ -0,0 +1,8 @@
import type { AppContext } from '@/types/hono';
import { listAdminUsers } from './list-users.business';
export async function listUsersHandler(c: AppContext) {
const search = c.req.query('search');
const users = await listAdminUsers(search);
return c.json(users);
}

View File

@@ -0,0 +1,9 @@
import { db } from '@/lib/prisma';
export async function revokeAllUserSessions(userId: string): Promise<number> {
const result = await db.session.deleteMany({
where: { userId },
});
return result.count;
}

View File

@@ -0,0 +1,12 @@
import type { AppContext } from '@/types/hono';
import { revokeAllUserSessions } from './revoke-all-sessions.business';
type RevokeSessionsParams = { id: string };
export async function revokeAllSessionsHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as RevokeSessionsParams;
const count = await revokeAllUserSessions(id);
return c.json({ message: `${count} sesiones cerradas correctamente.` });
}

View File

@@ -0,0 +1,12 @@
import { db } from '@/lib/prisma';
export async function unblockUser(userId: string): Promise<void> {
await db.user.update({
where: { id: userId },
data: {
banned: false,
bannedAt: null,
banReason: null,
},
});
}

View File

@@ -0,0 +1,12 @@
import type { AppContext } from '@/types/hono';
import { unblockUser } from './unblock-user.business';
type UnblockUserParams = { id: string };
export async function unblockUserHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as UnblockUserParams;
await unblockUser(id);
return c.json({ message: 'Usuario desbloqueado correctamente.' });
}

View File

@@ -0,0 +1,30 @@
import { db } from '@/lib/prisma';
import type { AdminUpdatePlanInput } from '@repo/api-contract';
export async function updatePlan(code: string, input: AdminUpdatePlanInput) {
const existing = await db.plan.findUnique({ where: { code } });
if (!existing) {
return { ok: false as const, message: 'Plan no encontrado.' };
}
const plan = await db.plan.update({
where: { code },
data: {
...(input.name !== undefined && { name: input.name }),
...(input.price !== undefined && { price: input.price }),
...(input.rules !== undefined && { rules: input.rules }),
lastUpdatedAt: new Date(),
},
});
return {
ok: true as const,
data: {
code: plan.code,
name: plan.name,
price: Number(plan.price),
rules: plan.rules,
},
};
}

View File

@@ -0,0 +1,18 @@
import type { AppContext } from '@/types/hono';
import type { AdminUpdatePlanInput } from '@repo/api-contract';
import { updatePlan } from './update-plan.business';
type UpdatePlanParams = { code: string };
export async function updatePlanHandler(c: AppContext) {
const { code } = c.req.valid('param' as never) as UpdatePlanParams;
const body = c.req.valid('json' as never) as AdminUpdatePlanInput;
const result = await updatePlan(code, body);
if (!result.ok) {
return c.json({ message: result.message }, 404);
}
return c.json(result.data);
}