ai_api / src /lib /gamification /events.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
5.77 kB
/**
* Gamification event emitter — called from chat pipeline.
*
* @module lib/gamification/events
*/
import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("GAMIFICATION");
/**
* Emit a gamification event. All gamification updates happen here.
* Called from chatCore.ts after successful requests.
*
* This function is fire-and-forget — never blocks the request pipeline.
* All errors are caught and logged, never thrown.
*/
export async function emitGamificationEvent(params: {
apiKeyId: string;
action:
| "request"
| "provider_switch"
| "model_switch"
| "combo_create"
| "combo_use"
| "token_share"
| "invite_redeem"
| "daily_login";
metadata?: Record<string, unknown>;
}): Promise<void> {
const { apiKeyId, action, metadata } = params;
if (!apiKeyId) return; // Skip if no API key
try {
// 1. Award XP
const xpAmount = getXpForAction(action);
if (xpAmount > 0) {
const { addXp } = await import("../db/gamification");
addXp(apiKeyId, action, xpAmount, metadata ? JSON.stringify(metadata) : undefined);
// Update level
const { getXp, updateLevel } = await import("../db/gamification");
const xp = getXp(apiKeyId);
if (xp) {
const { calculateLevel } = await import("./xp");
const newLevel = calculateLevel(xp.totalXp);
if (newLevel !== xp.currentLevel) {
updateLevel(apiKeyId, newLevel);
log.info("events.level_up", { apiKeyId, oldLevel: xp.currentLevel, newLevel });
}
}
}
// 2. Update streak
if (action === "request") {
const { updateStreak } = await import("./streaks");
const streak = await updateStreak(apiKeyId);
// Check streak badges
if (streak >= 365) {
await checkAndUnlockBadge(apiKeyId, "unstoppable");
} else if (streak >= 30) {
await checkAndUnlockBadge(apiKeyId, "monthly-master");
} else if (streak >= 7) {
await checkAndUnlockBadge(apiKeyId, "weekly-warrior");
} else if (streak >= 3) {
await checkAndUnlockBadge(apiKeyId, "daily-user");
}
}
// 3. Update leaderboard
const { updateScore } = await import("./leaderboard");
await updateScore(apiKeyId, "global", xpAmount);
// Update weekly/monthly
await updateScore(apiKeyId, "weekly", xpAmount);
await updateScore(apiKeyId, "monthly", xpAmount);
// Update specific scopes
if (action === "token_share") {
await updateScore(apiKeyId, "tokens_shared", xpAmount);
}
// 4. Check action count badges
await checkActionCountBadges(apiKeyId, action);
} catch (err) {
// Never throw — gamification must not break the request pipeline
log.error("events.error", {
apiKeyId,
action,
error: err instanceof Error ? err.message : String(err),
});
}
}
/**
* Get XP amount for an action.
*/
function getXpForAction(action: string): number {
const rewards: Record<string, number> = {
request: 1,
provider_switch: 5,
model_switch: 3,
combo_create: 10,
combo_use: 2,
token_share: 1,
invite_redeem: 50,
daily_login: 5,
};
return rewards[action] || 0;
}
/**
* Check and unlock a specific badge.
*/
async function checkAndUnlockBadge(apiKeyId: string, badgeId: string): Promise<void> {
const { unlockBadge, hasBadge } = await import("../db/gamification");
// #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is
// empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on
// every request.
if (!hasBadge(apiKeyId, badgeId)) {
unlockBadge(apiKeyId, badgeId);
log.info("events.badge_unlocked", { apiKeyId, badgeId });
// Look up badge details from badge_definitions
const { getDbInstance } = await import("../db/core");
const badgeRow = getDbInstance()
.prepare("SELECT name, description, icon, rarity FROM badge_definitions WHERE id = ?")
.get(badgeId) as
| { name: string; description: string | null; icon: string | null; rarity: string }
| undefined;
// Record notification for SSE toast
const { recordBadgeUnlock } = await import("./notifications");
recordBadgeUnlock(apiKeyId, {
badgeId,
badgeName: badgeRow?.name ?? badgeId,
badgeDescription: badgeRow?.description ?? "",
badgeIcon: badgeRow?.icon ?? "award",
badgeRarity: badgeRow?.rarity ?? "common",
unlockedAt: new Date().toISOString(),
});
}
}
/**
* Check action count badges after an action.
*/
async function checkActionCountBadges(apiKeyId: string, action: string): Promise<void> {
const { getDbInstance } = await import("../db/core");
const db = getDbInstance();
// Count total actions of this type
const row = db
.prepare(
"SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?"
)
.get(apiKeyId, action) as { count: number };
const count = row.count;
// Badge thresholds
const thresholds: Record<string, Array<{ id: string; threshold: number }>> = {
request: [
{ id: "first-token", threshold: 1 },
{ id: "token-consumer", threshold: 1000 },
{ id: "token-machine", threshold: 10000 },
{ id: "token-whale", threshold: 100000 },
],
token_share: [
{ id: "generous", threshold: 1000 },
{ id: "philanthropist", threshold: 10000 },
{ id: "token-santa", threshold: 100000 },
{ id: "community-hero", threshold: 1000000 },
],
};
const badges = thresholds[action];
if (!badges) return;
for (const badge of badges) {
if (count >= badge.threshold) {
await checkAndUnlockBadge(apiKeyId, badge.id);
}
}
}