import { Router, type IRouter } from "express"; import { eq, ilike, desc, sql } from "drizzle-orm"; import { db, toolsTable, ratingsTable } from "@workspace/db"; import { ListToolsQueryParams, CreateToolBody, GetToolParams, UpdateToolParams, UpdateToolBody, DeleteToolParams, } from "@workspace/api-zod"; import { requireAuth } from "../middleware/auth"; const router: IRouter = Router(); 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 }; } 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 } = parsed.data; let query = db.select().from(toolsTable).$dynamic(); if (category) { query = query.where(eq(toolsTable.category, category)); } if (search) { query = query.where(ilike(toolsTable.name, `%${search}%`)); } 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); } 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 [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, features: parsed.data.features ?? [], tags: parsed.data.tags ?? [], }).returning(); res.status(201).json(tool); }); 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(eq(toolsTable.id, params.data.id)); 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.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 parsed = UpdateToolBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const updateData: Record = {}; if (parsed.data.name !== undefined) updateData.name = parsed.data.name; if (parsed.data.description !== undefined) updateData.description = parsed.data.description; if (parsed.data.category !== undefined) updateData.category = parsed.data.category; if (parsed.data.websiteUrl !== undefined) updateData.websiteUrl = parsed.data.websiteUrl; if (parsed.data.iconUrl !== undefined) updateData.iconUrl = parsed.data.iconUrl; if (parsed.data.features !== undefined) updateData.features = parsed.data.features; if (parsed.data.tags !== undefined) 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; } 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 [tool] = await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id)).returning(); if (!tool) { res.status(404).json({ error: "Tool not found" }); return; } res.sendStatus(204); }); router.get("/categories", async (_req, res): Promise => { const rows = await db .selectDistinct({ category: toolsTable.category }) .from(toolsTable) .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); 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()); }); export default router;