feat: auth foundation, similar tools, costs, redundancy, anonymous voting

This commit is contained in:
root
2026-07-29 21:59:29 +02:00
parent 8b3b7c9955
commit 59badeaa48
22 changed files with 793 additions and 15 deletions
+121 -2
View File
@@ -1,6 +1,6 @@
import { Router, type IRouter } from "express";
import { eq, ilike, desc, sql } from "drizzle-orm";
import { db, toolsTable, ratingsTable } from "@workspace/db";
import { eq, ilike, desc, sql, and, not } from "drizzle-orm";
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
import {
ListToolsQueryParams,
CreateToolBody,
@@ -10,6 +10,7 @@ import {
DeleteToolParams,
} from "@workspace/api-zod";
import { requireAuth } from "../middleware/auth";
import { requireFeature } from "../middleware/feature";
import { writeAuditLog } from "../lib/audit";
const router: IRouter = Router();
@@ -242,4 +243,122 @@ router.get("/features/all", async (_req, res): Promise<void> => {
res.json([...featureSet].sort());
});
// ── Similar Tools ──────────────────────────────────────────
function computeSimilarityScore(
a: typeof toolsTable.$inferSelect,
b: typeof toolsTable.$inferSelect,
): number {
let score = 0;
if (a.category === b.category) score += 10;
const sharedTags = (a.tags ?? []).filter((t) => (b.tags ?? []).includes(t)).length;
score += sharedTags * 3;
const sharedFeatures = (a.features ?? []).filter((f) => (b.features ?? []).includes(f)).length;
score += sharedFeatures * 2;
return score;
}
router.get("/tools/:id/similar", async (req, res): Promise<void> => {
const { id } = req.params;
const toolId = Number(id);
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, toolId));
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; }
const allOthers = await db
.select()
.from(toolsTable)
.where(not(eq(toolsTable.id, toolId)));
const manualRelations = await db
.select({
id: toolRelationsTable.id,
relatedToolId: toolRelationsTable.relatedToolId,
relationType: toolRelationsTable.relationType,
notes: toolRelationsTable.notes,
})
.from(toolRelationsTable)
.where(eq(toolRelationsTable.toolId, toolId));
const manualIds = new Set(manualRelations.map((r) => r.relatedToolId));
const autoSimilar = allOthers
.filter((t) => !manualIds.has(t.id))
.map((t) => ({ tool: t, score: computeSimilarityScore(tool, t), source: "auto" as const }))
.filter((t) => t.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
const manualTools = allOthers.filter((t) => manualIds.has(t.id));
const allRatings = await db
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
.from(ratingsTable)
.where(sql`${ratingsTable.toolId} = ANY(${sql`ARRAY[${sql.join([...manualIds, ...autoSimilar.map((a) => a.tool.id)].map((id) => sql`${id}`), sql`, `)}]::int[]`})`);
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);
}
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;
return { ...t, ratingCount: count, avgUsefulness: avgU, avgUsability: avgUs, avgCombined: avgU != null && avgUs != null ? (avgU + avgUs) / 2 : null };
}
const manual = manualRelations.map((r) => {
const t = manualTools.find((mt) => mt.id === r.relatedToolId);
return t ? { ...buildStats(t), relationId: r.id, relationType: r.relationType, notes: r.notes, source: "manual" as const } : null;
}).filter(Boolean);
const auto = autoSimilar.map((a) => ({ ...buildStats(a.tool), score: a.score, source: a.source }));
res.json({ manual, auto });
});
router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools"), async (req, res): Promise<void> => {
const toolId = Number(req.params.id);
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
const { relatedToolId, relationType, notes } = req.body;
if (!relatedToolId) { res.status(400).json({ error: "relatedToolId is required" }); return; }
const [existing] = await db
.select()
.from(toolRelationsTable)
.where(and(eq(toolRelationsTable.toolId, toolId), eq(toolRelationsTable.relatedToolId, relatedToolId)))
.limit(1);
if (existing) { res.status(409).json({ error: "Relation already exists" }); return; }
const [relation] = await db.insert(toolRelationsTable).values({
toolId,
relatedToolId,
relationType: relationType ?? "similar",
notes: notes ?? null,
createdBy: Number(req.session.user!.sub),
}).returning();
await writeAuditLog(req, "tool_relation", relation.id, "create", { toolId, relatedToolId, relationType });
res.status(201).json(relation);
});
router.delete("/tools/relations/:id", requireAuth, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
const [existing] = await db.select().from(toolRelationsTable).where(eq(toolRelationsTable.id, id));
if (!existing) { res.status(404).json({ error: "Relation not found" }); return; }
await writeAuditLog(req, "tool_relation", id, "delete", { toolId: existing.toolId, relatedToolId: existing.relatedToolId });
await db.delete(toolRelationsTable).where(eq(toolRelationsTable.id, id));
res.sendStatus(204);
});
export default router;