Files
tool-evaluator/artifacts/api-server/src/routes/analytics.ts
T
opencode 95bf49509c
Build & Push Docker Image / build (push) Successful in 2m32s
fix: white home page - coerce pg numeric strings for top-tools ratings
2026-08-02 13:10:28 +02:00

178 lines
6.2 KiB
TypeScript

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<void> => {
const [toolStats] = await db
.select({ totalTools: sql<number>`count(*)::int` })
.from(toolsTable)
.where(sql`${toolsTable.deletedAt} IS NULL`);
const [ratingStats] = await db
.select({
totalRatings: sql<number>`count(*)::int`,
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
avgUsability: sql<number | null>`avg(${ratingsTable.usability})`,
})
.from(ratingsTable);
const [catStats] = await db
.select({ categoriesCount: sql<number>`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<number>`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<void> => {
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<number>`count(${ratingsTable.id})::int`,
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
avgUsability: sql<number | null>`avg(${ratingsTable.usability})`,
score: sql<number>`${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<void> => {
const rows = await db
.select({
category: toolsTable.category,
toolCount: sql<number>`count(distinct ${toolsTable.id})::int`,
totalRatings: sql<number>`count(${ratingsTable.id})::int`,
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
avgUsability: sql<number | null>`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<void> => {
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;