0c6a35e841
Build & Push Docker Image / build (push) Successful in 8m33s
- 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)
198 lines
7.3 KiB
TypeScript
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;
|