fix(gamification): add level/badges/badges-earned profile routes (#3484)

This commit is contained in:
diegosouzapw
2026-06-10 19:21:16 -03:00
parent 9ef4a61e9a
commit f06438feae
7 changed files with 212 additions and 3 deletions

View File

@@ -9,6 +9,7 @@
- **fix(usage):** the z.ai/GLM coding-plan quota card no longer shows "Monthly 0%" — coding plans have no monthly cap (only 5-hour windows), so the quota API reports the `TIME_LIMIT` ("Monthly") entry with `total=0`, and the `total>0 ? … : 0` fallback rendered a misleading 0% remaining (which can skew downstream model-choice). With no absolute cap the remaining percentage now falls back to the percentage-derived value (full/100% when 0% used). ([#3580](https://github.com/diegosouzapw/OmniRoute/issues/3580))
- **docs(discovery):** mark `DISCOVERY_TOOL_DESIGN.md`'s API Endpoints table with an explicit "⚠️ Not yet implemented — Phase 2" banner — the discovery routes are a design proposal (Phase-1 stub only), and the banner makes clear the `KNOWN_STALE_DOC_REFS` gate suppression is intentional, not stale drift. ([#3498](https://github.com/diegosouzapw/OmniRoute/issues/3498))
- **fix(agent-bridge):** add the missing `POST /api/tools/agent-bridge/upstream-ca/test` route — the UpstreamCaField "Test" button POSTed to it but it didn't exist (404). The new validate-only route checks the CA file exists and is a parseable PEM certificate (returns the subject/expiry) **without** persisting the path or activating it; it inherits the `/api/tools/agent-bridge/` LOCAL_ONLY classification. ([#3488](https://github.com/diegosouzapw/OmniRoute/issues/3488))
- **fix(gamification):** the dashboard Profile page no longer hits three 404s — added the missing `GET /api/gamification/{level,badges,badges/earned}` routes (management-scoped). The page is operator-wide (no `apiKeyId`), so `level`/`badges/earned` aggregate across all keys (with an optional `?apiKeyId` for a single key), and `badges` seeds the built-in catalog first (idempotent) so the grid is populated even on installs that never seeded it (see #3472). ([#3484](https://github.com/diegosouzapw/OmniRoute/issues/3484))
---

View File

@@ -24,9 +24,6 @@ const IGNORE = [
// inventada. CADA UM precisa de triagem: criar a rota, corrigir o path, ou remover a
// chamada morta. NÃO adicione novos aqui sem justificativa — esse é o ponto do gate.
const KNOWN_MISSING = new Set([
"/api/gamification/level", // profile/page.tsx — rota inexistente (gamification tem transfer/leaderboard/… mas não level)
"/api/gamification/badges", // profile/page.tsx — idem
"/api/gamification/badges/earned", // profile/page.tsx — idem
"/api/settings/obsidian/webdav", // ObsidianSourceCard.tsx — só existe /api/settings/obsidian
]);

View File

@@ -0,0 +1,24 @@
/**
* GET /api/gamification/badges/earned — badges earned by a key, or the operator-wide
* earned set (distinct across all keys) when no `apiKeyId` is supplied. (#3484)
*
* LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth.
*/
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { getBadges, getAllEarnedBadges } from "@/lib/db/gamification";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function OPTIONS() {
return handleCorsOptions();
}
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const apiKeyId = new URL(request.url).searchParams.get("apiKeyId");
const badges = apiKeyId ? getBadges(apiKeyId) : getAllEarnedBadges();
return NextResponse.json({ badges }, { headers: CORS_HEADERS });
}

View File

@@ -0,0 +1,28 @@
/**
* GET /api/gamification/badges — the full built-in badge catalog (definitions).
* Seeds the built-in badges first (idempotent INSERT OR IGNORE) so the profile
* grid is populated even on installs where seeding never ran. (#3484, see #3472)
*
* LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth.
*/
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { getBadgeDefinitions } from "@/lib/db/gamification";
import { seedBuiltinBadges } from "@/lib/gamification/badges";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function OPTIONS() {
return handleCorsOptions();
}
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
await seedBuiltinBadges();
const category = new URL(request.url).searchParams.get("category") || undefined;
const badges = getBadgeDefinitions(category);
return NextResponse.json({ badges }, { headers: CORS_HEADERS });
}

View File

@@ -0,0 +1,24 @@
/**
* GET /api/gamification/level — current XP/level for a key, or the operator-wide
* aggregate when no `apiKeyId` is supplied (the dashboard profile page case). (#3484)
*
* LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth.
*/
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { getXp, getAggregateXp } from "@/lib/db/gamification";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function OPTIONS() {
return handleCorsOptions();
}
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const apiKeyId = new URL(request.url).searchParams.get("apiKeyId");
const level = apiKeyId ? getXp(apiKeyId) : getAggregateXp();
return NextResponse.json({ level }, { headers: CORS_HEADERS });
}

View File

@@ -286,6 +286,62 @@ export function getBadgeDefinitions(category?: string): BadgeDefinition[] {
}));
}
/**
* Aggregate XP across every API key (the operator-wide profile view used by the
* dashboard profile page, which is not scoped to a single key). Sums total XP and
* takes the highest reached level. (#3484)
*/
export function getAggregateXp(): UserLevelRow {
const row = db()
.prepare(
`SELECT COALESCE(SUM(total_xp), 0) AS total_xp,
COALESCE(MAX(current_level), 1) AS current_level,
MAX(updated_at) AS updated_at
FROM user_levels`
)
.get() as { total_xp: number; current_level: number; updated_at: string | null };
return {
apiKeyId: "*",
totalXp: row?.total_xp ?? 0,
currentLevel: row?.current_level ?? 1,
updatedAt: row?.updated_at ?? "",
};
}
/**
* Distinct badges earned by any API key (operator-wide earned set for the profile
* page). Deduplicated by badge id, keeping the earliest unlock. (#3484)
*/
export function getAllEarnedBadges(): UserBadge[] {
const rows = db()
.prepare(
`SELECT ub.badge_id, MIN(ub.unlocked_at) AS unlocked_at,
bd.name, bd.description, bd.icon, bd.category, bd.rarity
FROM user_badges ub
JOIN badge_definitions bd ON bd.id = ub.badge_id
GROUP BY ub.badge_id`
)
.all() as Array<{
badge_id: string;
unlocked_at: string;
name: string;
description: string | null;
icon: string | null;
category: string | null;
rarity: string;
}>;
return rows.map((r) => ({
apiKeyId: "*",
badgeId: r.badge_id,
unlockedAt: r.unlocked_at,
badgeName: r.name,
badgeDescription: r.description,
badgeIcon: r.icon,
badgeCategory: r.category,
badgeRarity: r.rarity,
}));
}
// ──────────────── Token Ledger ────────────────
export function transferTokens(

View File

@@ -0,0 +1,79 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// #3484 — the dashboard profile page fetches /api/gamification/{level,badges,badges/earned}
// without an apiKeyId (operator-wide view). These helpers back the no-key case and the
// badge catalog must be seeded so the grid is populated (see #3472).
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-gami-3484-"));
process.env.DATA_DIR = TEST_DATA_DIR;
if (!process.env.API_KEY_SECRET) {
process.env.API_KEY_SECRET = "test-gami-3484-secret-" + Date.now();
}
const { getDbInstance, resetDbInstance } = await import("../../../src/lib/db/core.ts");
const gami = await import("../../../src/lib/db/gamification.ts");
const { seedBuiltinBadges, BUILTIN_BADGES } = await import("../../../src/lib/gamification/badges.ts");
test.after(() => {
try {
getDbInstance().close();
} catch {
/* ignore */
}
try {
resetDbInstance();
} catch {
/* ignore */
}
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#3484 getAggregateXp on an empty ledger → zero XP, level 1, no throw", () => {
const agg = gami.getAggregateXp();
assert.equal(agg.totalXp, 0);
assert.equal(agg.currentLevel, 1);
assert.equal(agg.apiKeyId, "*");
});
test("#3484 seedBuiltinBadges populates the catalog (getBadgeDefinitions non-empty)", async () => {
assert.equal(gami.getBadgeDefinitions().length, 0); // unseeded
await seedBuiltinBadges();
assert.equal(gami.getBadgeDefinitions().length, BUILTIN_BADGES.length);
await seedBuiltinBadges(); // idempotent — no duplicates
assert.equal(gami.getBadgeDefinitions().length, BUILTIN_BADGES.length);
});
test("#3484 getAggregateXp sums XP across keys and takes the highest level", () => {
const db = getDbInstance();
const upsert = db.prepare(
`INSERT OR REPLACE INTO user_levels (api_key_id, total_xp, current_level, updated_at)
VALUES (?, ?, ?, datetime('now'))`
);
upsert.run("key-a", 100, 2);
upsert.run("key-b", 250, 5);
const agg = gami.getAggregateXp();
assert.equal(agg.totalXp, 350);
assert.equal(agg.currentLevel, 5);
});
test("#3484 getAllEarnedBadges returns distinct badges earned by any key", () => {
const db = getDbInstance();
const [b0, b1] = BUILTIN_BADGES;
const award = db.prepare(
`INSERT OR IGNORE INTO user_badges (api_key_id, badge_id, unlocked_at)
VALUES (?, ?, datetime('now'))`
);
award.run("key-a", b0.id); // earned by A
award.run("key-b", b0.id); // same badge earned by B → must dedupe to one
award.run("key-b", b1.id); // distinct badge earned by B
const earned = gami.getAllEarnedBadges();
const ids = earned.map((e) => e.badgeId).sort();
assert.deepEqual(ids, [b0.id, b1.id].sort());
assert.ok(earned.every((e) => typeof e.badgeName === "string" && e.badgeName.length > 0));
});