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 => { 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(); 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(); for (const c of allCosts) { const arr = costsByTool.get(c.toolId) ?? []; arr.push(c); costsByTool.set(c.toolId, arr); } const manualEvalMap = new Map(); 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 = {}; 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 => { 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;