From 9545943219a5eb70ee567f5922808d0b8df69388 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sun, 22 Mar 2026 15:22:11 +0000 Subject: [PATCH 1/9] 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/9] 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( From 35a423e110e50e747786e642f34eafeffe7a7192 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 12:23:58 +0000 Subject: [PATCH 3/9] fix(db): replace APP_ENV throw with runtime env diagnostics for Netlify debugging --- frontend/db/index.ts | 14 ++++++-------- frontend/db/queries/categories/admin-categories.ts | 2 +- frontend/vitest.config.ts | 3 +++ frontend/vitest.shop.config.ts | 3 +++ 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/db/index.ts b/frontend/db/index.ts index cb9b681e..5e76ca23 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -13,21 +13,19 @@ 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', { + APP_ENV: process.env.APP_ENV ?? '', has_DATABASE_URL: Boolean(process.env.DATABASE_URL?.trim()), has_DATABASE_URL_LOCAL: Boolean(process.env.DATABASE_URL_LOCAL?.trim()), + NETLIFY: process.env.NETLIFY ?? '', + CONTEXT: process.env.CONTEXT ?? '', + NODE_ENV: process.env.NODE_ENV ?? '', }); } +const IS_LOCAL_ENV = APP_ENV === 'local'; + const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; const REQUIRED_LOCAL_DB_URL = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL; diff --git a/frontend/db/queries/categories/admin-categories.ts b/frontend/db/queries/categories/admin-categories.ts index 2e92e358..cbc2bf6a 100644 --- a/frontend/db/queries/categories/admin-categories.ts +++ b/frontend/db/queries/categories/admin-categories.ts @@ -29,7 +29,7 @@ export async function getAdminCategoryList(): Promise { return rows.map(row => ({ id: row.id, slug: row.slug, - title: row.title ?? row.slug, + title: row.title?.trim() || row.slug })); } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 5c42afa4..3f49e0ab 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ }, }, test: { + env: { + APP_ENV: 'local', + }, environment: 'node', include: [ 'lib/tests/**/*.test.ts', diff --git a/frontend/vitest.shop.config.ts b/frontend/vitest.shop.config.ts index 9ecb7b5f..8cc77d6b 100644 --- a/frontend/vitest.shop.config.ts +++ b/frontend/vitest.shop.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ }, }, test: { + env: { + APP_ENV: 'local', + }, environment: 'node', include: ['lib/tests/shop/**/*.test.ts'], globals: true, From 03dee65a332cdf6b84c2154e2b8ebd4269cca6c0 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 14:17:25 +0000 Subject: [PATCH 4/9] fix(deploy): inline all server env vars at build time for Netlify SSR runtime --- frontend/app/[locale]/layout.tsx | 18 +++++------------- frontend/next.config.ts | 9 +++++---- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index ddf43e1f..3cd94956 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -10,7 +10,7 @@ import { CookieBanner } from '@/components/shared/CookieBanner'; import Footer from '@/components/shared/Footer'; import { ScrollWatcher } from '@/components/shared/ScrollWatcher'; import { ThemeProvider } from '@/components/theme/ThemeProvider'; -// import { getCachedBlogCategories } from '@/db/queries/blog/blog-categories'; +import { getCachedBlogCategories } from '@/db/queries/blog/blog-categories'; import { AuthProvider } from '@/hooks/useAuth'; import { locales } from '@/i18n/config'; @@ -25,18 +25,10 @@ export default async function LocaleLayout({ if (!locales.includes(locale as any)) notFound(); - // const [messages, blogCategories] = await Promise.all([ - // getMessages({ locale }), - // getCachedBlogCategories(locale), - // ]); - - const messages = await getMessages({ locale }); - const blogCategories: Array<{ - id: string; - slug: string; - title: string; - }> = []; - + const [messages, blogCategories] = await Promise.all([ + getMessages({ locale }), + getCachedBlogCategories(locale), + ]); const enableAdmin = ( diff --git a/frontend/next.config.ts b/frontend/next.config.ts index a4b22934..9cbd1824 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -5,10 +5,11 @@ import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); const nextConfig: NextConfig = { - env: { - APP_ENV: process.env.APP_ENV, - DATABASE_URL: process.env.DATABASE_URL, - }, + env: Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith('NEXT_PUBLIC_') && !key.startsWith('npm_') + ) + ), images: { remotePatterns: [ { From 12d44398cb7be10e12f721fc829b660cb6d4ed4c Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 14:49:58 +0000 Subject: [PATCH 5/9] fix(deploy): add netlify-plugin-bundle-env to inject env vars into SSR runtime --- frontend/db/index.ts | 2 +- frontend/next.config.ts | 5 ----- netlify.toml | 3 +++ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 5e76ca23..57d807e1 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -26,7 +26,7 @@ if (process.env.NODE_ENV !== 'test') { const IS_LOCAL_ENV = APP_ENV === 'local'; -const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; +0const 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) { diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 9cbd1824..017c8b8c 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -5,11 +5,6 @@ import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); const nextConfig: NextConfig = { - env: Object.fromEntries( - Object.entries(process.env).filter( - ([key]) => !key.startsWith('NEXT_PUBLIC_') && !key.startsWith('npm_') - ) - ), images: { remotePatterns: [ { diff --git a/netlify.toml b/netlify.toml index a7c4d600..8c30c95f 100644 --- a/netlify.toml +++ b/netlify.toml @@ -8,3 +8,6 @@ [[plugins]] package = "@netlify/plugin-nextjs" + +[[plugins]] + package = "netlify-plugin-bundle-env" From 7b9212bca4595552b3f26a5ea30f093d7c0ff216 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 14:59:28 +0000 Subject: [PATCH 6/9] fix(deploy): typo fix --- frontend/db/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 57d807e1..5e76ca23 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -26,7 +26,7 @@ if (process.env.NODE_ENV !== 'test') { const IS_LOCAL_ENV = APP_ENV === 'local'; -0const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; +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) { From 50c6789d7e285201bc6cf97ca925c2a82d88caa2 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 15:46:38 +0000 Subject: [PATCH 7/9] fix(netlify): deliver env vars to SSR runtime via .env generation at build time --- frontend/db/index.ts | 2 +- netlify.toml | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 57d807e1..5e76ca23 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -26,7 +26,7 @@ if (process.env.NODE_ENV !== 'test') { const IS_LOCAL_ENV = APP_ENV === 'local'; -0const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; +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) { diff --git a/netlify.toml b/netlify.toml index 8c30c95f..fa7b322a 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,13 +1,10 @@ [build] base = "frontend" - command = "npm ci --include=optional && npm run build" + command = "printenv > .env && npm ci --include=optional && npm run build" publish = ".next" [build.environment] NODE_VERSION = "20.19.0" [[plugins]] - package = "@netlify/plugin-nextjs" - -[[plugins]] - package = "netlify-plugin-bundle-env" + package = "@netlify/plugin-nextjs" \ No newline at end of file From 2510901e1b8f58657d4bc8a0ffab2613e7fba017 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 15:56:12 +0000 Subject: [PATCH 8/9] fix(netlify): generate allowlist-based .env for SSR runtime env vars --- frontend/scripts/generate-env.sh | 7 +++++++ netlify.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 frontend/scripts/generate-env.sh diff --git a/frontend/scripts/generate-env.sh b/frontend/scripts/generate-env.sh new file mode 100644 index 00000000..953d8216 --- /dev/null +++ b/frontend/scripts/generate-env.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Generate .env from .env.example allowlist using current environment. +# Only variables listed in .env.example are included — no platform internals leak. +grep '^[A-Z]' .env.example | cut -d= -f1 | while read -r var; do + val="${!var}" + [ -n "$val" ] && printf '%s=%s\n' "$var" "$val" +done > .env diff --git a/netlify.toml b/netlify.toml index fa7b322a..e071879d 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,6 @@ [build] base = "frontend" - command = "printenv > .env && npm ci --include=optional && npm run build" + command = "bash scripts/generate-env.sh && npm ci --include=optional && npm run build" publish = ".next" [build.environment] From 667b860b639d481c192fc93c943ed40f10893508 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 17:07:03 +0000 Subject: [PATCH 9/9] fix(auth): load dotenv in auth.ts for Netlify SSR OAuth env vars --- frontend/lib/env/auth.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/lib/env/auth.ts b/frontend/lib/env/auth.ts index f20d2c39..9b79c9f4 100644 --- a/frontend/lib/env/auth.ts +++ b/frontend/lib/env/auth.ts @@ -1,3 +1,6 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); + type AppEnv = 'local' | 'develop' | 'production'; const validAppEnvs = ['local', 'develop', 'production'];