Files
tool-evaluator/artifacts/api-server/src/routes/admin.ts
T
opencode 0c6a35e841
Build & Push Docker Image / build (push) Successful in 8m33s
fix: category cache refresh, API 404, redundancy mapping, cost/relation authz, voterToken exposure
- Invalidate categories/features queries after creating/editing tools so new
  categories appear immediately in search, browse dropdown and tool form
- Always refetch categories/features when the combobox/suggestion inputs mount
- Return JSON 404 for unmatched /api routes instead of the SPA index.html
- Read the manually confirmed 'better tool' from the recommendation notes
  instead of using the min tool id in the redundancy dashboard
- Require admin for cost/relation update+delete endpoints
- Stop exposing the voter token in the ratings list response
- Fix parseInt type error on user id params (Express 5 params typing)
2026-08-01 15:25:06 +02:00

198 lines
7.3 KiB
TypeScript

import { Router, type IRouter } from "express";
import { eq, and, sql } from "drizzle-orm";
import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db";
import { requireAuth } from "../middleware/auth";
import { writeAuditLog } from "../lib/audit";
const router: IRouter = Router();
function buildRecommendation(a: any, b: any): { betterToolId: number; betterName: string; reason: string; certainty: "high" | "medium" | "low"; reasons: string[] } {
const reasons: string[] = [];
let aScore = 0;
let bScore = 0;
const aRating = a.avgCombined ?? 0;
const bRating = b.avgCombined ?? 0;
if (aRating > bRating && aRating > 0) {
reasons.push(`higher rating (${aRating.toFixed(1)} vs ${bRating.toFixed(1)})`);
aScore += 3;
} else if (bRating > aRating && bRating > 0) {
reasons.push(`higher rating (${bRating.toFixed(1)} vs ${aRating.toFixed(1)})`);
bScore += 3;
}
const aCost = a.totalMonthly ?? 0;
const bCost = b.totalMonthly ?? 0;
if (aCost < bCost && bCost > 0) {
reasons.push(`lower cost (${aCost.toFixed(2)}/mo vs ${bCost.toFixed(2)}/mo)`);
aScore += 2;
} else if (bCost < aCost && aCost > 0) {
reasons.push(`lower cost (${bCost.toFixed(2)}/mo vs ${aCost.toFixed(2)}/mo)`);
bScore += 2;
} else if (aCost === 0 && bCost > 0) {
reasons.push("free");
aScore += 1;
} else if (bCost === 0 && aCost > 0) {
reasons.push("free");
bScore += 1;
}
const aFeatCount = a.features?.length ?? 0;
const bFeatCount = b.features?.length ?? 0;
if (aFeatCount > bFeatCount && aFeatCount > 0) {
reasons.push(`more features (${aFeatCount} vs ${bFeatCount})`);
aScore += 1;
} else if (bFeatCount > aFeatCount && bFeatCount > 0) {
reasons.push(`more features (${bFeatCount} vs ${aFeatCount})`);
bScore += 1;
}
const better = aScore >= bScore ? a : b;
let certainty: "high" | "medium" | "low" = "low";
const diff = Math.abs(aScore - bScore);
if (diff >= 4) certainty = "high";
else if (diff >= 2) certainty = "medium";
return {
betterToolId: better.id,
betterName: better.name,
reason: reasons.length > 0 ? reasons.join(", ") : "insufficient data",
certainty,
reasons,
};
}
router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> => {
const tools = await db.select().from(toolsTable).orderBy(toolsTable.category, toolsTable.name);
const allRatings = await db
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
.from(ratingsTable);
const allCosts = await db
.select()
.from(toolCostsTable);
const manualEvals = await db
.select()
.from(toolRelationsTable)
.where(eq(toolRelationsTable.relationType, "recommended"));
const ratingsByTool = new Map<number, { usefulness: number; usability: number }[]>();
for (const r of allRatings) {
const arr = ratingsByTool.get(r.toolId) ?? [];
arr.push({ usefulness: r.usefulness, usability: r.usability });
ratingsByTool.set(r.toolId, arr);
}
const costsByTool = new Map<number, typeof allCosts>();
for (const c of allCosts) {
const arr = costsByTool.get(c.toolId) ?? [];
arr.push(c);
costsByTool.set(c.toolId, arr);
}
const manualEvalMap = new Map<string, number>();
for (const e of manualEvals) {
const key = [Math.min(e.toolId, e.relatedToolId), Math.max(e.toolId, e.relatedToolId)].join(":");
const betterMatch = e.notes?.match(/Recommended:\s*(\d+)/);
manualEvalMap.set(key, betterMatch ? Number(betterMatch[1]) : e.toolId);
}
function buildStats(t: typeof toolsTable.$inferSelect) {
const ratings = ratingsByTool.get(t.id) ?? [];
const count = ratings.length;
const avgU = count > 0 ? ratings.reduce((s, r) => s + r.usefulness, 0) / count : null;
const avgUs = count > 0 ? ratings.reduce((s, r) => s + r.usability, 0) / count : null;
const costs = costsByTool.get(t.id) ?? [];
const totalMonthly = costs.reduce((sum, c) => {
if (c.licenseType === "free" || c.cost == null) return sum;
const amount = Number(c.cost);
if (c.licenseType === "one_time") return sum;
if (c.billingPeriod === "yearly") return sum + amount / 12;
if (c.billingPeriod === "quarterly") return sum + amount / 3;
return sum + amount;
}, 0);
return {
id: t.id, name: t.name, category: t.category, features: t.features ?? [], tags: t.tags ?? [],
description: t.description,
ratingCount: count,
avgUsefulness: avgU, avgUsability: avgUs,
avgCombined: avgU != null && avgUs != null ? (avgU + avgUs) / 2 : null,
costs,
totalMonthly,
};
}
const grouped: Record<string, any[]> = {};
for (const t of tools) {
const g = grouped[t.category] ?? [];
g.push(buildStats(t));
grouped[t.category] = g;
}
const result = Object.entries(grouped).map(([category, items]) => {
const pairs: any[] = [];
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const aFeat = new Set(items[i].features);
const bFeat = new Set(items[j].features);
const shared = [...aFeat].filter((f) => bFeat.has(f)).length;
const total = new Set([...aFeat, ...bFeat]).size;
const overlap = total > 0 ? Math.round((shared / total) * 100) : 0;
const scoreDiff = (items[j].avgCombined ?? 0) - (items[i].avgCombined ?? 0);
const costDiff = (items[j].totalMonthly ?? 0) - (items[i].totalMonthly ?? 0);
const key = [items[i].id, items[j].id].sort().join(":");
const manualBetterId = manualEvalMap.get(key) ?? null;
const recommendation = buildRecommendation(items[i], items[j]);
pairs.push({ a: items[i], b: items[j], overlap, scoreDiff, costDiff, recommendation, manualBetterId });
}
}
pairs.sort((a, b) => b.overlap - a.overlap || Math.abs(b.scoreDiff) - Math.abs(a.scoreDiff));
const totalMonthlyCost = items.reduce((s, t) => s + (t.totalMonthly ?? 0), 0);
return { category, tools: items, pairs: pairs.slice(0, 10), totalMonthlyCost };
});
res.json(result);
});
router.post("/admin/redundancy/evaluate", requireAuth, async (req, res): Promise<void> => {
const { toolId, relatedToolId, betterToolId, notes } = req.body;
if (!toolId || !relatedToolId || !betterToolId) {
res.status(400).json({ error: "toolId, relatedToolId, and betterToolId are required" });
return;
}
const existing = await db
.select()
.from(toolRelationsTable)
.where(and(
eq(toolRelationsTable.toolId, Math.min(toolId, relatedToolId)),
eq(toolRelationsTable.relatedToolId, Math.max(toolId, relatedToolId)),
eq(toolRelationsTable.relationType, "recommended"),
))
.limit(1);
if (existing.length > 0) {
await db
.update(toolRelationsTable)
.set({ notes: notes ?? `Recommended: ${betterToolId}`, createdBy: Number(req.session.user!.sub) })
.where(eq(toolRelationsTable.id, existing[0].id));
} else {
await db.insert(toolRelationsTable).values({
toolId: Math.min(toolId, relatedToolId),
relatedToolId: Math.max(toolId, relatedToolId),
relationType: "recommended",
notes: notes ?? `Recommended: ${betterToolId}`,
createdBy: Number(req.session.user!.sub),
});
}
await writeAuditLog(req, "redundancy_evaluation", 0, "evaluate", { toolId, relatedToolId, betterToolId });
res.json({ ok: true });
});
export default router;