178 lines
6.2 KiB
TypeScript
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;
|