Refactor to vertical slice
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.' };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.` });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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.' });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user