mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
119 lines
3.2 KiB
TypeScript
119 lines
3.2 KiB
TypeScript
/**
|
|
* Badge unlock notification system.
|
|
* Emits events that the dashboard can listen to for toast notifications.
|
|
*
|
|
* @module lib/gamification/notifications
|
|
*/
|
|
|
|
export interface BadgeUnlockEvent {
|
|
badgeId: string;
|
|
badgeName: string;
|
|
badgeDescription: string;
|
|
badgeIcon: string;
|
|
badgeRarity: string;
|
|
unlockedAt: string;
|
|
}
|
|
|
|
// In-memory event buffer for SSE streaming
|
|
const recentUnlocks: Map<string, { event: BadgeUnlockEvent; addedAt: number }[]> = new Map();
|
|
const MAX_BUFFER_SIZE = 50;
|
|
const BUFFER_TTL_MS = 60_000; // 1 minute
|
|
const STALE_KEY_TTL_MS = 120_000; // 2 minutes for stale key cleanup
|
|
|
|
/**
|
|
* Record a badge unlock event for notification.
|
|
* Also cleans stale entries across all keys to prevent memory leaks.
|
|
*/
|
|
export function recordBadgeUnlock(apiKeyId: string, event: BadgeUnlockEvent): void {
|
|
if (!recentUnlocks.has(apiKeyId)) {
|
|
recentUnlocks.set(apiKeyId, []);
|
|
}
|
|
const list = recentUnlocks.get(apiKeyId)!;
|
|
list.push({ event, addedAt: Date.now() });
|
|
|
|
// Trim old entries for this key
|
|
const cutoff = Date.now() - BUFFER_TTL_MS;
|
|
while (list.length > 0 && list[0].addedAt < cutoff) {
|
|
list.shift();
|
|
}
|
|
if (list.length > MAX_BUFFER_SIZE) {
|
|
list.splice(0, list.length - MAX_BUFFER_SIZE);
|
|
}
|
|
|
|
// Periodic stale key cleanup (on each record, check all keys)
|
|
const staleCutoff = Date.now() - STALE_KEY_TTL_MS;
|
|
for (const [key, entries] of recentUnlocks) {
|
|
// Remove old entries
|
|
const fresh = entries.filter((e) => e.addedAt >= staleCutoff);
|
|
if (fresh.length === 0) {
|
|
recentUnlocks.delete(key);
|
|
} else if (fresh.length !== entries.length) {
|
|
recentUnlocks.set(key, fresh);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get and clear recent badge unlocks for an API key.
|
|
*/
|
|
export function consumeBadgeUnlocks(apiKeyId: string): BadgeUnlockEvent[] {
|
|
const entries = recentUnlocks.get(apiKeyId) || [];
|
|
recentUnlocks.delete(apiKeyId);
|
|
return entries.map((e) => e.event);
|
|
}
|
|
|
|
/**
|
|
* Create a ReadableStream for badge unlock notifications via SSE.
|
|
*/
|
|
export function createBadgeNotificationStream(
|
|
apiKeyId: string,
|
|
signal?: AbortSignal
|
|
): ReadableStream {
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
const encoder = new TextEncoder();
|
|
let closed = false;
|
|
|
|
const safeEnqueue = (chunk: Uint8Array) => {
|
|
if (closed) return;
|
|
try {
|
|
controller.enqueue(chunk);
|
|
} catch {
|
|
closed = true;
|
|
clearInterval(interval);
|
|
clearInterval(heartbeat);
|
|
}
|
|
};
|
|
|
|
// Check for unlocks every 2s
|
|
const interval = setInterval(() => {
|
|
const events = consumeBadgeUnlocks(apiKeyId);
|
|
for (const event of events) {
|
|
safeEnqueue(encoder.encode(`event: badge_unlock\ndata: ${JSON.stringify(event)}\n\n`));
|
|
}
|
|
}, 2000);
|
|
|
|
// Heartbeat every 15s
|
|
const heartbeat = setInterval(() => {
|
|
safeEnqueue(encoder.encode(`: heartbeat ${Date.now()}\n\n`));
|
|
}, 15_000);
|
|
|
|
// Cleanup on abort
|
|
const cleanup = () => {
|
|
closed = true;
|
|
clearInterval(interval);
|
|
clearInterval(heartbeat);
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
};
|
|
|
|
if (signal) {
|
|
signal.addEventListener("abort", cleanup);
|
|
}
|
|
},
|
|
});
|
|
}
|