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 => { 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(); 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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(); 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 => { 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 = {}; const changes: Record = {}; function track(key: keyof typeof existing, value: unknown) { if (value !== undefined && value !== existing[key]) { changes[key] = { from: existing[key], to: value }; (updateData as Record)[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 => { 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 => { 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 => { const tools = await db.select({ features: toolsTable.features }).from(toolsTable).where(isNull(toolsTable.deletedAt)); const featureSet = new Set(); 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 => { const tools = await db.select({ tags: toolsTable.tags }).from(toolsTable).where(isNull(toolsTable.deletedAt)); const tagSet = new Set(); 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 => { 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(); 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 => { 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 => { 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;