From 9545943219a5eb70ee567f5922808d0b8df69388 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sun, 22 Mar 2026 15:22:11 +0000 Subject: [PATCH 1/2] perf(sessions): move heartbeat from DB writes to Redis sorted set fix(about): update LinkedIn follower fallback to 1800 --- frontend/app/api/sessions/activity/route.ts | 99 ++++++++++++--------- frontend/lib/about/stats.ts | 2 +- 2 files changed, 59 insertions(+), 42 deletions(-) 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; From 6b4dfec01c7471557b3dc1e1db76397bb36e08bd Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 11:43:27 +0000 Subject: [PATCH 2/2] fix(db): require explicit APP_ENV and add runtime env diagnostics --- frontend/app/[locale]/layout.tsx | 1 + frontend/db/index.ts | 19 +++++++++++++++++-- .../db/queries/categories/admin-categories.ts | 8 ++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index e7a496fd..ddf43e1f 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -37,6 +37,7 @@ export default async function LocaleLayout({ title: string; }> = []; + const enableAdmin = ( process.env.ENABLE_ADMIN_API ?? diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 8648dd00..cb9b681e 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -12,14 +12,29 @@ dotenv.config(); type AppDatabase = PgDatabase; const APP_ENV = process.env.APP_ENV?.trim().toLowerCase(); + +if (!APP_ENV) { + throw new Error( + '[db] APP_ENV is required. Set APP_ENV=local in .env for development, or APP_ENV=develop/production for deployment' + ); +} + const IS_LOCAL_ENV = APP_ENV === 'local'; + +if (process.env.NODE_ENV !== 'test') { + console.log('[db] runtime env check', { + has_DATABASE_URL: Boolean(process.env.DATABASE_URL?.trim()), + has_DATABASE_URL_LOCAL: Boolean(process.env.DATABASE_URL_LOCAL?.trim()), + }); +} + const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; const REQUIRED_LOCAL_DB_URL = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL; if (STRICT_LOCAL_DB_GUARD) { if (!IS_LOCAL_ENV) { throw new Error( - `[db] SHOP_STRICT_LOCAL_DB=1 requires APP_ENV=local (got "${APP_ENV ?? 'undefined'}")` + `[db] SHOP_STRICT_LOCAL_DB=1 requires APP_ENV=local (got "${APP_ENV}")` ); } @@ -68,7 +83,7 @@ if (IS_LOCAL_ENV) { if (!url) { throw new Error( - `[db] APP_ENV=${APP_ENV ?? 'undefined'} requires DATABASE_URL to be set` + `[db] APP_ENV=${APP_ENV} requires DATABASE_URL to be set` ); } diff --git a/frontend/db/queries/categories/admin-categories.ts b/frontend/db/queries/categories/admin-categories.ts index 97a59934..2e92e358 100644 --- a/frontend/db/queries/categories/admin-categories.ts +++ b/frontend/db/queries/categories/admin-categories.ts @@ -20,13 +20,17 @@ export async function getAdminCategoryList(): Promise { title: categoryTranslations.title, }) .from(categories) - .innerJoin( + .leftJoin( categoryTranslations, sql`${categoryTranslations.categoryId} = ${categories.id} AND ${categoryTranslations.locale} = ${ADMIN_LOCALE}` ) .orderBy(categories.displayOrder); - return rows; + return rows.map(row => ({ + id: row.id, + slug: row.slug, + title: row.title ?? row.slug, + })); } export async function getMaxQuizDisplayOrder(