diff --git a/frontend/app/api/sessions/activity/route.ts b/frontend/app/api/sessions/activity/route.ts index 354204e2..8e92c844 100644 --- a/frontend/app/api/sessions/activity/route.ts +++ b/frontend/app/api/sessions/activity/route.ts @@ -5,8 +5,11 @@ import { NextResponse } from 'next/server'; import { db } from '@/db'; import { activeSessions } from '@/db/schema/sessions'; +import { getRedisClient } from '@/lib/redis'; const SESSION_TIMEOUT_MINUTES = 15; +const SESSION_TIMEOUT_MS = SESSION_TIMEOUT_MINUTES * 60 * 1000; +const REDIS_KEY = 'online_sessions'; function getHeartbeatThrottleMs(): number { const raw = process.env.HEARTBEAT_THROTTLE_MS; @@ -17,8 +20,59 @@ function getHeartbeatThrottleMs(): number { return Math.max(floor, parsed); } -export async function POST() { +async function heartbeatViaRedis(sessionId: string): Promise { + const redis = getRedisClient(); + if (!redis) return null; + try { + const now = Date.now(); + const pipeline = redis.pipeline(); + pipeline.zadd(REDIS_KEY, { score: now, member: sessionId }); + pipeline.zremrangebyscore(REDIS_KEY, '-inf', now - SESSION_TIMEOUT_MS); + pipeline.zcard(REDIS_KEY); + + const results = await pipeline.exec(); + return results[2] as number; + } catch (err) { + console.warn('Redis heartbeat failed, falling back to DB:', err); + return null; + } +} + +async function heartbeatViaDb(sessionId: string): Promise { + const now = new Date(); + const heartbeatThreshold = new Date( + now.getTime() - getHeartbeatThrottleMs() + ); + + await db + .insert(activeSessions) + .values({ sessionId, lastActivity: now }) + .onConflictDoUpdate({ + target: activeSessions.sessionId, + set: { lastActivity: now }, + setWhere: lt(activeSessions.lastActivity, heartbeatThreshold), + }); + + if (Math.random() < 0.05) { + const cleanupThreshold = new Date(Date.now() - SESSION_TIMEOUT_MS); + db.delete(activeSessions) + .where(lt(activeSessions.lastActivity, cleanupThreshold)) + .catch(err => console.error('Cleanup error:', err)); + } + + const countThreshold = new Date(Date.now() - SESSION_TIMEOUT_MS); + const result = await db + .select({ total: sql`count(*)` }) + .from(activeSessions) + .where(gte(activeSessions.lastActivity, countThreshold)); + + return Number(result[0]?.total || 0); +} + + +export async function POST() { + try { const cookieStore = await cookies(); let sessionId = cookieStore.get('user_session_id')?.value; @@ -26,47 +80,10 @@ export async function POST() { sessionId = randomUUID(); } - const now = new Date(); - const heartbeatThreshold = new Date( - now.getTime() - getHeartbeatThrottleMs() - ); - - await db - .insert(activeSessions) - .values({ - sessionId, - lastActivity: now, - }) - .onConflictDoUpdate({ - target: activeSessions.sessionId, - set: { lastActivity: now }, - setWhere: lt(activeSessions.lastActivity, heartbeatThreshold), - }); - - if (Math.random() < 0.05) { - const cleanupThreshold = new Date( - Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000 - ); - - db.delete(activeSessions) - .where(lt(activeSessions.lastActivity, cleanupThreshold)) - .catch(err => console.error('Cleanup error:', err)); - } - - const countThreshold = new Date( - Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000 - ); + const redisCount = await heartbeatViaRedis(sessionId); + const online = redisCount ?? (await heartbeatViaDb(sessionId)); - const result = await db - .select({ - total: sql`count(*)`, - }) - .from(activeSessions) - .where(gte(activeSessions.lastActivity, countThreshold)); - - const response = NextResponse.json({ - online: Number(result[0]?.total || 0), - }); + const response = NextResponse.json({ online }); response.cookies.set('user_session_id', sessionId, { httpOnly: true, diff --git a/frontend/lib/about/stats.ts b/frontend/lib/about/stats.ts index c4ee20ef..e51afc9d 100644 --- a/frontend/lib/about/stats.ts +++ b/frontend/lib/about/stats.ts @@ -43,7 +43,7 @@ export const getPlatformStats = unstable_cache( const linkedinCount = process.env.LINKEDIN_FOLLOWER_COUNT ? parseInt(process.env.LINKEDIN_FOLLOWER_COUNT) - : 1700; + : 1800; let totalUsers = 243; let solvedTests = 1890;