Files
tool-evaluator/artifacts/api-server/src/routes/tools.ts
T
opencode e164d51574 feat: compare limit 9, format-aware import placeholder, help links
- Raise compare limit from 8 to 9 (server + client, toast on overflow)
- Make import textarea placeholder match selected format (CSV/JSON/YAML/auto)
- Add GuideHelp links to admin create/edit user dialogs and tool import dialog
2026-08-05 07:40:47 +02:00

573 lines
21 KiB
TypeScript

import { Router, type IRouter } from "express";
import { eq, desc, asc, sql, and, not, isNull, inArray, type SQL } from "drizzle-orm";
import { z } from "zod";
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
import {
ListToolsQueryParams,
CreateToolBody,
GetToolParams,
UpdateToolParams,
UpdateToolBody,
DeleteToolParams,
TrashToolsBody,
} from "@workspace/api-zod";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { requireFeature, hasFeature } from "../middleware/feature";
import { writeAuditLog } from "../lib/audit";
const router: IRouter = Router();
const PUBLIC_RELATION_TYPES = ["similar", "replaces", "superseded_by"] as const;
const RELATION_TYPES = [...PUBLIC_RELATION_TYPES, "recommended"] as const;
const RelationBody = z.object({
relatedToolId: z.coerce.number().int().positive(),
relationType: z.enum(RELATION_TYPES).optional(),
notes: z.string().optional(),
});
function buildToolWithStats(tool: typeof toolsTable.$inferSelect, ratings: { usefulness: number; usability: number }[]) {
const ratingCount = ratings.length;
const avgUsefulness = ratingCount > 0
? ratings.reduce((s, r) => s + r.usefulness, 0) / ratingCount
: null;
const avgUsability = ratingCount > 0
? ratings.reduce((s, r) => s + r.usability, 0) / ratingCount
: null;
const avgCombined = avgUsefulness != null && avgUsability != null
? (avgUsefulness + avgUsability) / 2
: null;
return { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined };
}
function canEditTool(req: import("express").Request, tool: { createdBy: string | null }): boolean {
const user = req.session.user;
if (!user) return false;
if (user.role === "admin") return true;
return tool.createdBy === user.sub || tool.createdBy === user.preferred_username;
}
router.get("/tools", async (req, res): Promise<void> => {
const parsed = ListToolsQueryParams.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { category, search, sort, tags, features, minRating } = parsed.data;
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
const conditions: SQL[] = [isNull(toolsTable.deletedAt)];
if (category) {
conditions.push(eq(toolsTable.category, category));
}
if (search) {
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
conditions.push(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
}
if (tagList.length > 0) {
conditions.push(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
}
if (featureList.length > 0) {
conditions.push(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
}
const query = db.select().from(toolsTable).where(and(...conditions));
const tools = await query.orderBy(desc(toolsTable.createdAt));
const toolIds = tools.map((t) => t.id);
let allRatings: { toolId: number; usefulness: number; usability: number }[] = [];
if (toolIds.length > 0) {
allRatings = await db
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
.from(ratingsTable)
.where(sql`${ratingsTable.toolId} = ANY(${sql`ARRAY[${sql.join(toolIds.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);
}
let result = tools.map((t) => buildToolWithStats(t, ratingsByTool.get(t.id) ?? []));
if (sort === "top_rated") {
result = result.sort((a, b) => (b.avgCombined ?? 0) - (a.avgCombined ?? 0));
} else if (sort === "most_reviewed") {
result = result.sort((a, b) => b.ratingCount - a.ratingCount);
} else if (sort === "name_asc") {
result = result.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
} else if (sort === "name_desc") {
result = result.sort((a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: "base" }));
} else if (sort === "recently_updated") {
result = result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
if (minRating != null) {
result = result.filter((t) => (t.avgCombined ?? 0) >= minRating);
}
res.json(result);
});
router.post("/tools", requireAuth, async (req, res): Promise<void> => {
const parsed = CreateToolBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const user = req.session.user!;
const createdBy = user.preferred_username || user.name || user.sub;
const [tool] = await db.insert(toolsTable).values({
name: parsed.data.name,
description: parsed.data.description,
category: parsed.data.category,
websiteUrl: parsed.data.websiteUrl ?? null,
iconUrl: parsed.data.iconUrl ?? null,
createdBy,
features: parsed.data.features ?? [],
tags: parsed.data.tags ?? [],
}).returning();
await writeAuditLog(req, "tool", tool.id, "create", { name: tool.name, category: tool.category });
res.status(201).json(tool);
});
function deletedByUser(req: import("express").Request): string {
const user = req.session.user!;
return user.preferred_username || user.name || user.sub;
}
router.get("/tools/trash", requireAuth, requireFeature("trash"), async (req, res): Promise<void> => {
const { search } = req.query;
let query = db
.select()
.from(toolsTable)
.where(sql`${toolsTable.deletedAt} IS NOT NULL`)
.$dynamic();
if (search) {
const escaped = String(search).replace(/[%_\\]/g, (m) => `\\${m}`);
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
}
const tools = await query.orderBy(desc(toolsTable.deletedAt));
res.json(tools);
});
router.post("/tools/trash", requireAuth, requireFeature("trash"), requireAdmin, async (req, res): Promise<void> => {
const parsed = TrashToolsBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const rows = await db.update(toolsTable)
.set({ deletedAt: new Date(), deletedBy: deletedByUser(req) })
.where(and(inArray(toolsTable.id, parsed.data.ids), isNull(toolsTable.deletedAt)))
.returning({ id: toolsTable.id });
for (const r of rows) {
await writeAuditLog(req, "tool", r.id, "trash", {});
}
res.json({ trashed: rows.length });
});
router.post("/tools/trash/restore", requireAuth, requireFeature("trash"), async (req, res): Promise<void> => {
const parsed = TrashToolsBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const rows = await db.update(toolsTable)
.set({ deletedAt: null, deletedBy: null })
.where(and(inArray(toolsTable.id, parsed.data.ids), sql`${toolsTable.deletedAt} IS NOT NULL`))
.returning({ id: toolsTable.id });
for (const r of rows) {
await writeAuditLog(req, "tool", r.id, "restore", {});
}
res.json({ restored: rows.length });
});
router.delete("/tools/trash", requireAdmin, async (req, res): Promise<void> => {
const parsed = TrashToolsBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const rows = await db.delete(toolsTable)
.where(and(inArray(toolsTable.id, parsed.data.ids), sql`${toolsTable.deletedAt} IS NOT NULL`))
.returning({ id: toolsTable.id, name: toolsTable.name });
for (const r of rows) {
await writeAuditLog(req, "tool", r.id, "permanent_delete", { name: r.name });
}
res.sendStatus(204);
});
router.post("/tools/trash/empty", requireAdmin, async (req, res): Promise<void> => {
const rows = await db.delete(toolsTable)
.where(sql`${toolsTable.deletedAt} IS NOT NULL`)
.returning({ id: toolsTable.id });
await writeAuditLog(req, "tool", null, "empty_trash", { count: rows.length });
res.json({ deleted: rows.length });
});
router.get("/tools/:id", async (req, res): Promise<void> => {
const params = GetToolParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [tool] = await db.select().from(toolsTable).where(and(eq(toolsTable.id, params.data.id), isNull(toolsTable.deletedAt)));
if (!tool) {
res.status(404).json({ error: "Tool not found" });
return;
}
const ratings = await db
.select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
.from(ratingsTable)
.where(eq(ratingsTable.toolId, params.data.id));
res.json(buildToolWithStats(tool, ratings));
});
router.get("/tools/:id/rating-history", async (req, res): Promise<void> => {
const params = GetToolParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const ratings = await db
.select({
date: ratingsTable.createdAt,
usefulness: ratingsTable.usefulness,
usability: ratingsTable.usability,
})
.from(ratingsTable)
.where(eq(ratingsTable.toolId, params.data.id))
.orderBy(asc(ratingsTable.createdAt));
res.json(
ratings.map((r) => ({
date: r.date,
usefulness: r.usefulness,
usability: r.usability,
combined: (r.usefulness + r.usability) / 2,
})),
);
});
router.get("/compare", requireFeature("compare"), async (req, res): Promise<void> => {
const ids = String(req.query.ids ?? "")
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => Number.isInteger(n) && n > 0);
if (ids.length === 0) {
res.status(400).json({ error: "Provide at least one ids value, e.g. ?ids=1,2,3" });
return;
}
if (ids.length > 9) {
res.status(400).json({ error: "Maximum of 9 tools can be compared" });
return;
}
const tools = await db.select().from(toolsTable).where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids)));
if (tools.length === 0) {
res.status(404).json({ error: "No tools found" });
return;
}
const toolIds = tools.map((t) => t.id);
const ratings = await db
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
.from(ratingsTable)
.where(inArray(ratingsTable.toolId, toolIds));
const byTool = new Map<number, { usefulness: number; usability: number }[]>();
for (const r of ratings) {
const list = byTool.get(r.toolId) ?? [];
list.push({ usefulness: r.usefulness, usability: r.usability });
byTool.set(r.toolId, list);
}
const byId = new Map(tools.map((t) => [t.id, t]));
const ordered = ids.filter((id) => byId.has(id)).map((id) => buildToolWithStats(byId.get(id)!, byTool.get(id) ?? []));
res.json(ordered);
});
router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
const params = UpdateToolParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [existing] = await db
.select()
.from(toolsTable)
.where(eq(toolsTable.id, params.data.id));
if (!existing) {
res.status(404).json({ error: "Tool not found" });
return;
}
if (!canEditTool(req, existing)) {
res.status(403).json({ error: "Not allowed to edit this tool" });
return;
}
const parsed = UpdateToolBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const updateData: Record<string, unknown> = {};
const changes: Record<string, { from: unknown; to: unknown }> = {};
function track(key: keyof typeof existing, value: unknown) {
if (value !== undefined && value !== existing[key]) {
changes[key] = { from: existing[key], to: value };
(updateData as Record<string, unknown>)[key] = value;
}
}
track("name", parsed.data.name);
track("description", parsed.data.description);
track("category", parsed.data.category);
track("websiteUrl", parsed.data.websiteUrl);
track("iconUrl", parsed.data.iconUrl);
if (parsed.data.features !== undefined) {
changes["features"] = { from: existing.features, to: parsed.data.features };
updateData.features = parsed.data.features;
}
if (parsed.data.tags !== undefined) {
changes["tags"] = { from: existing.tags, to: parsed.data.tags };
updateData.tags = parsed.data.tags;
}
const [tool] = await db
.update(toolsTable)
.set(updateData)
.where(eq(toolsTable.id, params.data.id))
.returning();
if (!tool) {
res.status(404).json({ error: "Tool not found" });
return;
}
if (Object.keys(changes).length > 0) {
await writeAuditLog(req, "tool", tool.id, "update", changes);
}
res.json(tool);
});
router.delete("/tools/:id", requireAuth, async (req, res): Promise<void> => {
const params = DeleteToolParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [existing] = await db
.select()
.from(toolsTable)
.where(eq(toolsTable.id, params.data.id));
if (!existing) {
res.status(404).json({ error: "Tool not found" });
return;
}
if (!canEditTool(req, existing)) {
res.status(403).json({ error: "Not allowed to delete this tool" });
return;
}
const user = req.session.user!;
if (hasFeature(user.tier, "trash", user.role)) {
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name, trashed: true });
await db.update(toolsTable).set({
deletedAt: new Date(),
deletedBy: user.preferred_username || user.name || user.sub,
}).where(eq(toolsTable.id, params.data.id));
} else {
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name });
await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id));
}
res.sendStatus(204);
});
router.get("/categories", async (_req, res): Promise<void> => {
const rows = await db
.selectDistinct({ category: toolsTable.category })
.from(toolsTable)
.where(isNull(toolsTable.deletedAt))
.orderBy(toolsTable.category);
res.json(rows.map((r) => r.category));
});
router.get("/features/all", async (_req, res): Promise<void> => {
const tools = await db.select({ features: toolsTable.features }).from(toolsTable).where(isNull(toolsTable.deletedAt));
const featureSet = new Set<string>();
for (const t of tools) {
for (const f of t.features ?? []) {
if (f && f.trim()) featureSet.add(f.trim());
}
}
res.json([...featureSet].sort());
});
router.get("/tags/all", async (_req, res): Promise<void> => {
const tools = await db.select({ tags: toolsTable.tags }).from(toolsTable).where(isNull(toolsTable.deletedAt));
const tagSet = new Set<string>();
for (const t of tools) {
for (const tag of t.tags ?? []) {
if (tag && tag.trim()) tagSet.add(tag.trim());
}
}
res.json([...tagSet].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(and(not(eq(toolsTable.id, toolId)), isNull(toolsTable.deletedAt)));
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 parsed = RelationBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { relatedToolId, notes } = parsed.data;
const relationType = parsed.data.relationType ?? "similar";
if (relationType === "recommended" && req.session.user?.role !== "admin") {
res.status(403).json({ error: "Only admins can create recommended relations" });
return;
}
if (toolId === relatedToolId) {
res.status(400).json({ error: "A tool cannot be related to itself" });
return;
}
const [toolA] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, toolId)).limit(1);
const [toolB] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, relatedToolId)).limit(1);
if (!toolA || !toolB) {
res.status(404).json({ error: "One or both tools not found" });
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,
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", requireAdmin, 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;