mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
fix(gamification): add level/badges/badges-earned profile routes (#3484)
This commit is contained in:
@@ -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))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
|
||||
|
||||
24
src/app/api/gamification/badges/earned/route.ts
Normal file
24
src/app/api/gamification/badges/earned/route.ts
Normal 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 });
|
||||
}
|
||||
28
src/app/api/gamification/badges/route.ts
Normal file
28
src/app/api/gamification/badges/route.ts
Normal 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 });
|
||||
}
|
||||
24
src/app/api/gamification/level/route.ts
Normal file
24
src/app/api/gamification/level/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
79
tests/unit/gamification/aggregate-profile-3484.test.ts
Normal file
79
tests/unit/gamification/aggregate-profile-3484.test.ts
Normal 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));
|
||||
});
|
||||
Reference in New Issue
Block a user