import { Router, type IRouter } from "express"; import { eq, sql, desc, and } from "drizzle-orm"; import { db, toolsTable, ratingsTable } from "@workspace/db"; import { GetTopToolsQueryParams, GetRatingDistributionQueryParams, } from "@workspace/api-zod"; const router: IRouter = Router(); router.get("/analytics/summary", async (_req, res): Promise => { const [toolStats] = await db .select({ totalTools: sql`count(*)::int` }) .from(toolsTable) .where(sql`${toolsTable.deletedAt} IS NULL`); const [ratingStats] = await db .select({ totalRatings: sql`count(*)::int`, avgUsefulness: sql`avg(${ratingsTable.usefulness})`, avgUsability: sql`avg(${ratingsTable.usability})`, }) .from(ratingsTable); const [catStats] = await db .select({ categoriesCount: sql`count(distinct ${toolsTable.category})::int` }) .from(toolsTable) .where(sql`${toolsTable.deletedAt} IS NULL`); const avgCombined = ratingStats.avgUsefulness != null && ratingStats.avgUsability != null ? (Number(ratingStats.avgUsefulness) + Number(ratingStats.avgUsability)) / 2 : null; // Most-rated tool const [mostRatedRow] = await db .select({ toolId: ratingsTable.toolId, ratingCount: sql`count(*)::int`, }) .from(ratingsTable) .groupBy(ratingsTable.toolId) .orderBy(desc(sql`count(*)`)) .limit(1); let mostRatedTool = null; if (mostRatedRow) { const [tool] = await db.select().from(toolsTable).where(and(eq(toolsTable.id, mostRatedRow.toolId), sql`${toolsTable.deletedAt} IS NULL`)); if (tool) { const ratingRows = await db .select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) .from(ratingsTable) .where(eq(ratingsTable.toolId, tool.id)); const rc = ratingRows.length; const au = rc > 0 ? ratingRows.reduce((s, r) => s + r.usefulness, 0) / rc : null; const aus = rc > 0 ? ratingRows.reduce((s, r) => s + r.usability, 0) / rc : null; const ac = au != null && aus != null ? (au + aus) / 2 : null; mostRatedTool = { ...tool, ratingCount: rc, avgUsefulness: au, avgUsability: aus, avgCombined: ac }; } } res.json({ totalTools: toolStats?.totalTools ?? 0, totalRatings: ratingStats?.totalRatings ?? 0, avgUsefulness: ratingStats?.avgUsefulness != null ? Number(ratingStats.avgUsefulness) : null, avgUsability: ratingStats?.avgUsability != null ? Number(ratingStats.avgUsability) : null, avgCombined, categoriesCount: catStats?.categoriesCount ?? 0, mostRatedTool, }); }); router.get("/analytics/top-tools", async (req, res): Promise => { const parsed = GetTopToolsQueryParams.safeParse(req.query); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const limit = Math.min(Math.max(parsed.data.limit ?? 10, 1), 50); const metric = parsed.data.metric ?? "combined"; const scoreExpr = metric === "usefulness" ? sql`avg(${ratingsTable.usefulness})` : metric === "usability" ? sql`avg(${ratingsTable.usability})` : sql`(avg(${ratingsTable.usefulness}) + avg(${ratingsTable.usability})) / 2`; const rows = await db .select({ tool: toolsTable, ratingCount: sql`count(${ratingsTable.id})::int`, avgUsefulness: sql`avg(${ratingsTable.usefulness})`, avgUsability: sql`avg(${ratingsTable.usability})`, score: sql`${scoreExpr}`, }) .from(toolsTable) .innerJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id)) .where(sql`${toolsTable.deletedAt} IS NULL`) .groupBy(toolsTable.id) .orderBy(desc(scoreExpr)) .limit(limit); const scored = rows.map(({ tool, ratingCount, avgUsefulness, avgUsability, score }) => { const avgCombined = avgUsefulness != null && avgUsability != null ? (Number(avgUsefulness) + Number(avgUsability)) / 2 : null; return { tool: { ...tool, ratingCount, avgUsefulness: avgUsefulness != null ? Number(avgUsefulness) : null, avgUsability: avgUsability != null ? Number(avgUsability) : null, avgCombined, }, score: Number(score), ratingCount, }; }); res.json(scored); }); router.get("/analytics/by-category", async (_req, res): Promise => { const rows = await db .select({ category: toolsTable.category, toolCount: sql`count(distinct ${toolsTable.id})::int`, totalRatings: sql`count(${ratingsTable.id})::int`, avgUsefulness: sql`avg(${ratingsTable.usefulness})`, avgUsability: sql`avg(${ratingsTable.usability})`, }) .from(toolsTable) .leftJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id)) .where(sql`${toolsTable.deletedAt} IS NULL`) .groupBy(toolsTable.category) .orderBy(toolsTable.category); res.json(rows.map((r) => ({ category: r.category, toolCount: r.toolCount ?? 0, totalRatings: r.totalRatings ?? 0, avgUsefulness: r.avgUsefulness != null ? Number(r.avgUsefulness) : null, avgUsability: r.avgUsability != null ? Number(r.avgUsability) : null, }))); }); router.get("/analytics/rating-distribution", async (req, res): Promise => { const parsed = GetRatingDistributionQueryParams.safeParse(req.query); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const toolIdFilter = parsed.data.toolId; const baseQuery = toolIdFilter ? db.select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) .from(ratingsTable) .where(eq(ratingsTable.toolId, toolIdFilter)) : db.select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) .from(ratingsTable); const allRatings = await baseQuery; const usefulnessBuckets = [1, 2, 3, 4, 5].map((score) => ({ score, count: allRatings.filter((r) => r.usefulness === score).length, })); const usabilityBuckets = [1, 2, 3, 4, 5].map((score) => ({ score, count: allRatings.filter((r) => r.usability === score).length, })); res.json({ usefulness: usefulnessBuckets, usability: usabilityBuckets }); }); export default router;