feat/admin-portal #17

Merged
jselesan merged 6 commits from feat/admin-portal into development 2026-06-10 16:08:58 +00:00
55 changed files with 3212 additions and 114 deletions
Showing only changes of commit dc8a10a612 - Show all commits

View File

@@ -27,6 +27,11 @@ model Session {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
ipAddress String? ipAddress String?
userAgent String? userAgent String?
country String?
city String?
countryCode String?
latitude Float?
longitude Float?
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "sessions" ADD COLUMN "city" TEXT,
ADD COLUMN "country" TEXT,
ADD COLUMN "countryCode" TEXT,
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;

View File

@@ -148,6 +148,33 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel> _max?: Prisma.NestedDateTimeFilter<$PrismaModel>
} }
export type FloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
export type UuidFilter<$PrismaModel = never> = { export type UuidFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -495,6 +522,33 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel> _max?: Prisma.NestedDateTimeFilter<$PrismaModel>
} }
export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
export type NestedUuidFilter<$PrismaModel = never> = { export type NestedUuidFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>

File diff suppressed because one or more lines are too long

View File

@@ -1668,6 +1668,11 @@ export const SessionScalarFieldEnum = {
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
ipAddress: 'ipAddress', ipAddress: 'ipAddress',
userAgent: 'userAgent', userAgent: 'userAgent',
country: 'country',
city: 'city',
countryCode: 'countryCode',
latitude: 'latitude',
longitude: 'longitude',
userId: 'userId' userId: 'userId'
} as const } as const
@@ -1960,6 +1965,20 @@ export type ListDateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaM
/**
* Reference to a field of type 'Float'
*/
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
/**
* Reference to a field of type 'Float[]'
*/
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
/** /**
* Reference to a field of type 'ComplexUserRole' * Reference to a field of type 'ComplexUserRole'
*/ */
@@ -2043,20 +2062,6 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'> export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
/**
* Reference to a field of type 'Float'
*/
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
/**
* Reference to a field of type 'Float[]'
*/
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
/** /**
* Batch Payload for updateMany & deleteMany & createMany * Batch Payload for updateMany & deleteMany & createMany
*/ */

View File

@@ -111,6 +111,11 @@ export const SessionScalarFieldEnum = {
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
ipAddress: 'ipAddress', ipAddress: 'ipAddress',
userAgent: 'userAgent', userAgent: 'userAgent',
country: 'country',
city: 'city',
countryCode: 'countryCode',
latitude: 'latitude',
longitude: 'longitude',
userId: 'userId' userId: 'userId'
} as const } as const

View File

@@ -3,6 +3,8 @@ type IpGeoInfo = {
city: string; city: string;
country: string; country: string;
countryCode: string; countryCode: string;
lat: number | null;
lon: number | null;
}; };
const cache = new Map<string, { data: IpGeoInfo; expiresAt: number }>(); const cache = new Map<string, { data: IpGeoInfo; expiresAt: number }>();
@@ -24,7 +26,14 @@ function setCache(ip: string, info: IpGeoInfo): void {
async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> { async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) { if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
return { ip, city: 'Red local', country: 'Red local', countryCode: 'LOCAL' }; return {
ip,
city: 'Red local',
country: 'Red local',
countryCode: 'LOCAL',
lat: null,
lon: null,
};
} }
const cached = getCached(ip); const cached = getCached(ip);
@@ -32,7 +41,7 @@ async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
try { try {
const response = await fetch( const response = await fetch(
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,query` `http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,lat,lon,query`
); );
if (!response.ok) return null; if (!response.ok) return null;
@@ -44,6 +53,8 @@ async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
city: data.city || 'Desconocida', city: data.city || 'Desconocida',
country: data.country || 'Desconocido', country: data.country || 'Desconocido',
countryCode: data.countryCode || '', countryCode: data.countryCode || '',
lat: data.lat ?? null,
lon: data.lon ?? null,
}; };
setCache(ip, info); setCache(ip, info);

View File

@@ -1,7 +1,20 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { fetchGeoInfo } from '@/lib/geoip';
import { db } from '@/lib/prisma';
import type { AppEnv } from '@/types/hono'; import type { AppEnv } from '@/types/hono';
import { createMiddleware } from 'hono/factory'; import { createMiddleware } from 'hono/factory';
type SessionWithGeo = {
id: string;
ipAddress: string | null;
userAgent: string | null;
country: string | null;
city: string | null;
countryCode: string | null;
latitude: number | null;
longitude: number | null;
};
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => { export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: c.req.raw.headers, headers: c.req.raw.headers,
@@ -23,8 +36,33 @@ export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
); );
} }
const s = session.session as unknown as SessionWithGeo;
c.set('user', session.user); c.set('user', session.user);
c.set('session', session.session); c.set('session', session.session);
if (s.ipAddress && !s.country) {
resolveSessionGeo(s.id, s.ipAddress);
}
await next(); await next();
}); });
async function resolveSessionGeo(sessionId: string, ipAddress: string) {
try {
const geo = await fetchGeoInfo(ipAddress);
if (!geo) return;
await db.session.update({
where: { id: sessionId },
data: {
country: geo.country,
city: geo.city,
countryCode: geo.countryCode,
latitude: geo.lat,
longitude: geo.lon,
},
});
} catch {
// Silently fail — geo enrichment is best-effort
}
}

View File

@@ -4,6 +4,7 @@ import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware'
import { blockUserHandler } from '@/modules/admin/handlers/block-user.handler'; import { blockUserHandler } from '@/modules/admin/handlers/block-user.handler';
import { createPlanHandler } from '@/modules/admin/handlers/create-plan.handler'; import { createPlanHandler } from '@/modules/admin/handlers/create-plan.handler';
import { deletePlanHandler } from '@/modules/admin/handlers/delete-plan.handler'; import { deletePlanHandler } from '@/modules/admin/handlers/delete-plan.handler';
import { getGeoStatsHandler } from '@/modules/admin/handlers/get-geo-stats.handler';
import { getUserSessionsHandler } from '@/modules/admin/handlers/get-user-sessions.handler'; import { getUserSessionsHandler } from '@/modules/admin/handlers/get-user-sessions.handler';
import { listComplexesHandler } from '@/modules/admin/handlers/list-complexes.handler'; import { listComplexesHandler } from '@/modules/admin/handlers/list-complexes.handler';
import { listPlansAdminHandler } from '@/modules/admin/handlers/list-plans-admin.handler'; import { listPlansAdminHandler } from '@/modules/admin/handlers/list-plans-admin.handler';
@@ -40,6 +41,8 @@ adminRoutes.delete(
deletePlanHandler deletePlanHandler
); );
adminRoutes.get('/geo-stats', getGeoStatsHandler);
adminRoutes.get('/users', listUsersHandler); adminRoutes.get('/users', listUsersHandler);
adminRoutes.post( adminRoutes.post(
'/users/:id/block', '/users/:id/block',

View File

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

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

@@ -1,11 +1,30 @@
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link, useLocation } from '@tanstack/react-router'; import { Link, useLocation } from '@tanstack/react-router';
import { Building2, CalendarDays, Crosshair, Users } from 'lucide-react'; import {
Building2,
CalendarDays,
ChevronDown,
ChevronRight,
Crosshair,
Globe,
Users,
} from 'lucide-react';
import { Fragment, useState } from 'react';
import { AdminLayout } from './admin-layout'; import { AdminLayout } from './admin-layout';
function countryFlag(countryCode: string): string {
if (countryCode === 'LOCAL' || countryCode === 'XX') return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map((c) => 0x1f1e6 + c.charCodeAt(0) - 65);
return String.fromCodePoint(...codePoints);
}
export function AdminPage() { export function AdminPage() {
const location = useLocation(); const location = useLocation();
const [expandedCountry, setExpandedCountry] = useState<string | null>(null);
const complexesQuery = useQuery({ const complexesQuery = useQuery({
queryKey: ['admin-complexes'], queryKey: ['admin-complexes'],
@@ -17,6 +36,11 @@ export function AdminPage() {
queryFn: () => apiClient.admin.listUsers(), queryFn: () => apiClient.admin.listUsers(),
}); });
const geoQuery = useQuery({
queryKey: ['admin-geo-stats'],
queryFn: () => apiClient.admin.getGeoStats(),
});
const stats = { const stats = {
totalComplexes: complexesQuery.data?.length ?? 0, totalComplexes: complexesQuery.data?.length ?? 0,
totalUsers: usersQuery.data?.length ?? 0, totalUsers: usersQuery.data?.length ?? 0,
@@ -125,6 +149,106 @@ export function AdminPage() {
</Link> </Link>
</div> </div>
</div> </div>
<div>
<h2 className="mb-3 text-lg font-semibold">Usuarios por país / ciudad</h2>
{geoQuery.isLoading && <p className="text-sm text-muted-foreground">Cargando...</p>}
{geoQuery.data && geoQuery.data.countries.length === 0 && (
<div className="rounded-2xl border border-border/60 bg-card p-8 text-center shadow-sm">
<Globe className="mx-auto mb-2 size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Aún no hay datos de geolocalización. Aparecerán a medida que los usuarios inicien
sesión.
</p>
</div>
)}
{geoQuery.data && geoQuery.data.countries.length > 0 && (
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/60">
<th className="w-8 px-4 py-3" />
<th className="px-4 py-3 text-left font-medium text-muted-foreground">País</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
Usuarios
</th>
</tr>
</thead>
<tbody>
{geoQuery.data.countries.map((country) => {
const isExpanded = expandedCountry === country.countryCode;
const flag = countryFlag(country.countryCode);
return (
<Fragment key={country.countryCode}>
<tr
className={`cursor-pointer border-b border-border/40 transition-colors hover:bg-accent/50 ${isExpanded ? 'bg-accent/30' : ''}`}
onClick={() => setExpandedCountry(isExpanded ? null : country.countryCode)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpandedCountry(isExpanded ? null : country.countryCode);
}
}}
tabIndex={0}
role="button"
>
<td className="px-4 py-3">
{country.cities.length > 1 ||
country.cities[0]?.city !== country.country ? (
<button type="button" className="text-muted-foreground">
{isExpanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
) : (
<span className="inline-block w-4" />
)}
</td>
<td className="px-4 py-3">
<span className="mr-2">{flag}</span>
{country.country}
</td>
<td className="px-4 py-3 text-right font-medium">{country.totalUsers}</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={3} className="bg-accent/20 p-0">
<table className="w-full text-xs">
<tbody>
{country.cities.map((city) => (
<tr key={city.city} className="border-b border-border/20">
<td className="w-8 px-4 py-2" />
<td className="px-4 py-2 text-muted-foreground">{city.city}</td>
<td className="px-4 py-2 text-right font-medium">
{city.userCount}
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
<div className="border-t border-border/40 px-4 py-2 text-xs text-muted-foreground">
{geoQuery.data.totalUniqueCountries}{' '}
{geoQuery.data.totalUniqueCountries === 1 ? 'país' : 'países'} · solo sesiones activas
con geolocalización resuelta
</div>
</div>
)}
</div>
</AdminLayout> </AdminLayout>
); );
} }

View File

@@ -2,6 +2,7 @@ import type {
AdminBlockUserInput, AdminBlockUserInput,
AdminComplexListItem, AdminComplexListItem,
AdminCreatePlanInput, AdminCreatePlanInput,
AdminGeoStats,
AdminGlobalStats, AdminGlobalStats,
AdminUpdatePlanInput, AdminUpdatePlanInput,
AdminUser, AdminUser,
@@ -77,6 +78,11 @@ export async function getGlobalStats() {
return response.data; return response.data;
} }
export async function getGeoStats() {
const response = await http.get<AdminGeoStats>('/api/admin/geo-stats');
return response.data;
}
export async function getUserSessions(userId: string) { export async function getUserSessions(userId: string) {
const response = await http.get<AdminUserSession[]>(`/api/admin/users/${userId}/sessions`); const response = await http.get<AdminUserSession[]>(`/api/admin/users/${userId}/sessions`);
return response.data; return response.data;

View File

@@ -93,3 +93,24 @@ export type AdminBlockUserInput = z.infer<typeof adminBlockUserSchema>
export type AdminGlobalStats = z.infer<typeof adminGlobalStatsSchema> export type AdminGlobalStats = z.infer<typeof adminGlobalStatsSchema>
export type AdminPaymentStatus = z.infer<typeof adminPaymentStatusSchema> export type AdminPaymentStatus = z.infer<typeof adminPaymentStatusSchema>
export type AdminUserSession = z.infer<typeof adminUserSessionSchema> export type AdminUserSession = z.infer<typeof adminUserSessionSchema>
export const adminGeoCitySchema = z.object({
city: z.string(),
userCount: z.number().int().nonnegative(),
})
export const adminGeoCountrySchema = z.object({
country: z.string(),
countryCode: z.string(),
totalUsers: z.number().int().nonnegative(),
cities: z.array(adminGeoCitySchema),
})
export const adminGeoStatsSchema = z.object({
countries: z.array(adminGeoCountrySchema),
totalUniqueCountries: z.number().int().nonnegative(),
})
export type AdminGeoCity = z.infer<typeof adminGeoCitySchema>
export type AdminGeoCountry = z.infer<typeof adminGeoCountrySchema>
export type AdminGeoStats = z.infer<typeof adminGeoStatsSchema>

View File

@@ -139,6 +139,9 @@ export {
adminGlobalStatsSchema, adminGlobalStatsSchema,
adminPaymentStatusSchema, adminPaymentStatusSchema,
adminUserSessionSchema, adminUserSessionSchema,
adminGeoCitySchema,
adminGeoCountrySchema,
adminGeoStatsSchema,
} from './admin' } from './admin'
export type { export type {
AdminComplexListItem, AdminComplexListItem,
@@ -150,4 +153,7 @@ export type {
AdminGlobalStats, AdminGlobalStats,
AdminPaymentStatus, AdminPaymentStatus,
AdminUserSession, AdminUserSession,
AdminGeoCity,
AdminGeoCountry,
AdminGeoStats,
} from './admin' } from './admin'