import "server-only"; import type Redis from "ioredis"; import { getRedisClient } from "@/lib/redis/client"; import { PRESENCE_TTL_MS } from "@/lib/presence-constants"; type AdminPresence = { userId: string; name: string; email: string; guideId: string; section: string | null; }; function prefix(): string { return process.env.REDIS_PRESENCE_PREFIX || "buzz:presence"; } async function purge( redis: Redis, activeKey: string, detailKey: string, now: number, ) { await redis.eval(` local expired = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 256) for _, id in ipairs(expired) do redis.call('ZREM', KEYS[1], id) redis.call('HDEL', KEYS[2], id) end redis.call('PEXPIRE', KEYS[1], ARGV[2]) redis.call('PEXPIRE', KEYS[2], ARGV[2]) `, 2, activeKey, detailKey, now, PRESENCE_TTL_MS * 2); } export async function updateVisitorPresence({ tabId, browserId, guideId, active, }: { tabId: string; browserId: string; guideId: string | null; active: boolean; }) { const redis = await getRedisClient(); const activeKey = `${prefix()}:visitors`; const scopeKey = `${prefix()}:visitor-scopes`; const browserKey = `${prefix()}:visitor-browsers`; const now = Date.now(); const serialized = await redis.eval(` local now = tonumber(ARGV[1]) local expired = redis.call('ZRANGEBYSCORE', KEYS[1], 0, now, 'LIMIT', 0, 256) for _, id in ipairs(expired) do redis.call('ZREM', KEYS[1], id) redis.call('HDEL', KEYS[2], id) redis.call('HDEL', KEYS[3], id) end if ARGV[5] == '1' then if redis.call('ZCARD', KEYS[1]) >= 10000 and not redis.call('ZSCORE', KEYS[1], ARGV[2]) then return redis.error_reply('presence capacity reached') end local previousScope = redis.call('HGET', KEYS[2], ARGV[2]) local previousBrowser = redis.call('HGET', KEYS[3], ARGV[2]) redis.call('ZADD', KEYS[1], now + tonumber(ARGV[4]), ARGV[2]) redis.call('HSET', KEYS[2], ARGV[2], ARGV[3]) redis.call('HSET', KEYS[3], ARGV[2], ARGV[6]) if previousScope ~= ARGV[3] or previousBrowser ~= ARGV[6] then redis.call('DEL', KEYS[4]) end else redis.call('ZREM', KEYS[1], ARGV[2]) redis.call('HDEL', KEYS[2], ARGV[2]) redis.call('HDEL', KEYS[3], ARGV[2]) redis.call('DEL', KEYS[4]) end redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[4]) * 2) redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[4]) * 2) redis.call('PEXPIRE', KEYS[3], tonumber(ARGV[4]) * 2) if #expired > 0 then redis.call('DEL', KEYS[4]) end local snapshot = redis.call('GET', KEYS[4]) if snapshot then return snapshot end local ids = redis.call('ZRANGEBYSCORE', KEYS[1], now + 1, '+inf') local counts = {} local browsers = {} local guideBrowsers = {} for _, id in ipairs(ids) do local browser = redis.call('HGET', KEYS[3], id) or id local scope = redis.call('HGET', KEYS[2], id) browsers[browser] = true if scope and scope ~= '' then local key = scope .. ':' .. browser if not guideBrowsers[key] then counts[scope] = (counts[scope] or 0) + 1 guideBrowsers[key] = true end end end local global = 0 for _ in pairs(browsers) do global = global + 1 end snapshot = cjson.encode({global = global, byGuide = counts}) redis.call('SET', KEYS[4], snapshot, 'PX', 5000) return snapshot `, 4, activeKey, scopeKey, browserKey, `${prefix()}:visitor-counts`, now, tabId, guideId ?? "", PRESENCE_TTL_MS, active ? "1" : "0", browserId) as string; const snapshot = JSON.parse(serialized) as { global: number; byGuide: Record }; return { ...snapshot, guide: guideId ? snapshot.byGuide[guideId] ?? 0 : null }; } export async function updateAdminPresence({ memberId, user, guideId, section, active, }: { memberId: string; user: { id: string; name?: string | null; email: string }; guideId: string | null; section: string | null; active: boolean; }) { const redis = await getRedisClient(); const activeKey = `${prefix()}:admins`; const detailKey = `${prefix()}:admin-details`; const now = Date.now(); if (active && guideId) { const detail: AdminPresence = { userId: user.id, name: user.name?.trim() || user.email, email: user.email, guideId, section, }; await redis.multi() .zadd(activeKey, now + PRESENCE_TTL_MS, memberId) .hset(detailKey, memberId, JSON.stringify(detail)) .exec(); } else { await redis.multi().zrem(activeKey, memberId).hdel(detailKey, memberId).exec(); } await purge(redis, activeKey, detailKey, now); const ids = await redis.zrangebyscore(activeKey, now + 1, "+inf"); const serialized = ids.length ? await redis.hmget(detailKey, ...ids) : []; const editorsByGuide: Record> = {}; for (const value of serialized) { if (!value) continue; try { const detail = JSON.parse(value) as AdminPresence; const editors = editorsByGuide[detail.guideId] ?? []; const existing = editors.find((editor) => editor.userId === detail.userId); if (existing && detail.section && !existing.sections.includes(detail.section)) { existing.sections.push(detail.section); } else if (!existing) { editors.push({ userId: detail.userId, name: detail.name || detail.email, sections: detail.section ? [detail.section] : [], }); } editorsByGuide[detail.guideId] = editors; } catch { // Ignore malformed ephemeral entries. } } return { editorsByGuide }; }