feat: add geolocation fields to session and implement geo stats endpoint
- Added country, city, countryCode, latitude, and longitude fields to the Session model. - Updated geoip service to fetch and store geolocation data based on user IP. - Created a new admin endpoint to retrieve geolocation statistics, including user counts by country and city. - Enhanced admin page to display geolocation statistics with expandable city details. - Introduced caching for geolocation data to optimize performance.
This commit is contained in:
@@ -20,15 +20,20 @@ model User {
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
token String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
token String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
country String?
|
||||
city String?
|
||||
countryCode String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([token])
|
||||
@@index([userId])
|
||||
|
||||
@@ -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;
|
||||
@@ -148,6 +148,33 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_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> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
@@ -495,6 +522,33 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_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> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1668,6 +1668,11 @@ export const SessionScalarFieldEnum = {
|
||||
updatedAt: 'updatedAt',
|
||||
ipAddress: 'ipAddress',
|
||||
userAgent: 'userAgent',
|
||||
country: 'country',
|
||||
city: 'city',
|
||||
countryCode: 'countryCode',
|
||||
latitude: 'latitude',
|
||||
longitude: 'longitude',
|
||||
userId: 'userId'
|
||||
} 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'
|
||||
*/
|
||||
@@ -2043,20 +2062,6 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -111,6 +111,11 @@ export const SessionScalarFieldEnum = {
|
||||
updatedAt: 'updatedAt',
|
||||
ipAddress: 'ipAddress',
|
||||
userAgent: 'userAgent',
|
||||
country: 'country',
|
||||
city: 'city',
|
||||
countryCode: 'countryCode',
|
||||
latitude: 'latitude',
|
||||
longitude: 'longitude',
|
||||
userId: 'userId'
|
||||
} as const
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ type IpGeoInfo = {
|
||||
city: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
};
|
||||
|
||||
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> {
|
||||
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);
|
||||
@@ -32,7 +41,7 @@ async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
||||
|
||||
try {
|
||||
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;
|
||||
|
||||
@@ -44,6 +53,8 @@ async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
||||
city: data.city || 'Desconocida',
|
||||
country: data.country || 'Desconocido',
|
||||
countryCode: data.countryCode || '',
|
||||
lat: data.lat ?? null,
|
||||
lon: data.lon ?? null,
|
||||
};
|
||||
|
||||
setCache(ip, info);
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { fetchGeoInfo } from '@/lib/geoip';
|
||||
import { db } from '@/lib/prisma';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
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) => {
|
||||
const session = await auth.api.getSession({
|
||||
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('session', session.session);
|
||||
|
||||
if (s.ipAddress && !s.country) {
|
||||
resolveSessionGeo(s.id, s.ipAddress);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware'
|
||||
import { blockUserHandler } from '@/modules/admin/handlers/block-user.handler';
|
||||
import { createPlanHandler } from '@/modules/admin/handlers/create-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 { listComplexesHandler } from '@/modules/admin/handlers/list-complexes.handler';
|
||||
import { listPlansAdminHandler } from '@/modules/admin/handlers/list-plans-admin.handler';
|
||||
@@ -40,6 +41,8 @@ adminRoutes.delete(
|
||||
deletePlanHandler
|
||||
);
|
||||
|
||||
adminRoutes.get('/geo-stats', getGeoStatsHandler);
|
||||
|
||||
adminRoutes.get('/users', listUsersHandler);
|
||||
adminRoutes.post(
|
||||
'/users/:id/block',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
67
apps/backend/src/modules/admin/services/admin-geo.service.ts
Normal file
67
apps/backend/src/modules/admin/services/admin-geo.service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user