diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8068dde0..05968bdc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) --- diff --git a/scripts/check/check-fetch-targets.mjs b/scripts/check/check-fetch-targets.mjs index 08b2d522ee..6c504376ac 100644 --- a/scripts/check/check-fetch-targets.mjs +++ b/scripts/check/check-fetch-targets.mjs @@ -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 ]); diff --git a/src/app/api/gamification/badges/earned/route.ts b/src/app/api/gamification/badges/earned/route.ts new file mode 100644 index 0000000000..65a0944a9d --- /dev/null +++ b/src/app/api/gamification/badges/earned/route.ts @@ -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 }); +} diff --git a/src/app/api/gamification/badges/route.ts b/src/app/api/gamification/badges/route.ts new file mode 100644 index 0000000000..6eeccf062e --- /dev/null +++ b/src/app/api/gamification/badges/route.ts @@ -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 }); +} diff --git a/src/app/api/gamification/level/route.ts b/src/app/api/gamification/level/route.ts new file mode 100644 index 0000000000..572081967c --- /dev/null +++ b/src/app/api/gamification/level/route.ts @@ -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 }); +} diff --git a/src/lib/db/gamification.ts b/src/lib/db/gamification.ts index 84522e939c..7bfb5e4e37 100644 --- a/src/lib/db/gamification.ts +++ b/src/lib/db/gamification.ts @@ -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( diff --git a/tests/unit/gamification/aggregate-profile-3484.test.ts b/tests/unit/gamification/aggregate-profile-3484.test.ts new file mode 100644 index 0000000000..59a1d156f1 --- /dev/null +++ b/tests/unit/gamification/aggregate-profile-3484.test.ts @@ -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)); +});