143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
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,
|
|
guideId,
|
|
active,
|
|
}: {
|
|
tabId: string;
|
|
guideId: string | null;
|
|
active: boolean;
|
|
}) {
|
|
const redis = await getRedisClient();
|
|
const activeKey = `${prefix()}:visitors`;
|
|
const scopeKey = `${prefix()}:visitor-scopes`;
|
|
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)
|
|
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
|
|
redis.call('ZADD', KEYS[1], now + tonumber(ARGV[4]), ARGV[2])
|
|
redis.call('HSET', KEYS[2], ARGV[2], ARGV[3])
|
|
else
|
|
redis.call('ZREM', KEYS[1], ARGV[2])
|
|
redis.call('HDEL', KEYS[2], ARGV[2])
|
|
end
|
|
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[4]) * 2)
|
|
redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[4]) * 2)
|
|
local snapshot = redis.call('GET', KEYS[3])
|
|
if snapshot then return snapshot end
|
|
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], now + 1, '+inf')
|
|
local counts = {}
|
|
for _, id in ipairs(ids) do
|
|
local scope = redis.call('HGET', KEYS[2], id)
|
|
if scope and scope ~= '' then counts[scope] = (counts[scope] or 0) + 1 end
|
|
end
|
|
snapshot = cjson.encode({global = #ids, byGuide = counts})
|
|
redis.call('SET', KEYS[3], snapshot, 'PX', 5000)
|
|
return snapshot
|
|
`, 3, activeKey, scopeKey, `${prefix()}:visitor-counts`, now, tabId, guideId ?? "", PRESENCE_TTL_MS, active ? "1" : "0") as string;
|
|
const snapshot = JSON.parse(serialized) as { global: number; byGuide: Record<string, number> };
|
|
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<string, Array<{ userId: string; name: string; sections: string[] }>> = {};
|
|
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 };
|
|
}
|