Add API endpoints and frontend components for tool management and analytics
Implement CRUD operations for tools and ratings, introduce analytics endpoints, and develop frontend components for displaying tools, ratings, and analytics data. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: feaa4ce1-5aed-4cc0-bcea-47855b615b48 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/rx9K7bW Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, sql, desc } 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);
|
||||
|
||||
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);
|
||||
|
||||
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(eq(toolsTable.id, mostRatedRow.toolId));
|
||||
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 = parsed.data.limit ?? 10;
|
||||
const metric = parsed.data.metric ?? "combined";
|
||||
|
||||
const tools = await db.select().from(toolsTable);
|
||||
const allRatings = await db
|
||||
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||
.from(ratingsTable);
|
||||
|
||||
const ratingsByTool = new Map<number, { usefulness: number; usability: number }[]>();
|
||||
for (const r of allRatings) {
|
||||
const arr = ratingsByTool.get(r.toolId) ?? [];
|
||||
arr.push({ usefulness: r.usefulness, usability: r.usability });
|
||||
ratingsByTool.set(r.toolId, arr);
|
||||
}
|
||||
|
||||
const toolsWithStats = tools.map((t) => {
|
||||
const ratings = ratingsByTool.get(t.id) ?? [];
|
||||
const rc = ratings.length;
|
||||
const au = rc > 0 ? ratings.reduce((s, r) => s + r.usefulness, 0) / rc : null;
|
||||
const aus = rc > 0 ? ratings.reduce((s, r) => s + r.usability, 0) / rc : null;
|
||||
const ac = au != null && aus != null ? (au + aus) / 2 : null;
|
||||
return { ...t, ratingCount: rc, avgUsefulness: au, avgUsability: aus, avgCombined: ac };
|
||||
});
|
||||
|
||||
const scored = toolsWithStats
|
||||
.filter((t) => t.ratingCount > 0)
|
||||
.map((t) => {
|
||||
let score = 0;
|
||||
if (metric === "usefulness") score = t.avgUsefulness ?? 0;
|
||||
else if (metric === "usability") score = t.avgUsability ?? 0;
|
||||
else score = t.avgCombined ?? 0;
|
||||
return { tool: t, score, ratingCount: t.ratingCount };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
|
||||
res.json(scored);
|
||||
});
|
||||
|
||||
router.get("/analytics/by-category", async (_req, res): Promise<void> => {
|
||||
const categories = await db
|
||||
.selectDistinct({ category: toolsTable.category })
|
||||
.from(toolsTable);
|
||||
|
||||
const result = await Promise.all(
|
||||
categories.map(async ({ category }) => {
|
||||
const [toolCount] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(toolsTable)
|
||||
.where(eq(toolsTable.category, category));
|
||||
|
||||
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)
|
||||
.innerJoin(toolsTable, eq(ratingsTable.toolId, toolsTable.id))
|
||||
.where(eq(toolsTable.category, category));
|
||||
|
||||
return {
|
||||
category,
|
||||
toolCount: toolCount?.count ?? 0,
|
||||
totalRatings: ratingStats?.totalRatings ?? 0,
|
||||
avgUsefulness: ratingStats?.avgUsefulness != null ? Number(ratingStats.avgUsefulness) : null,
|
||||
avgUsability: ratingStats?.avgUsability != null ? Number(ratingStats.avgUsability) : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import healthRouter from "./health";
|
||||
import toolsRouter from "./tools";
|
||||
import ratingsRouter from "./ratings";
|
||||
import analyticsRouter from "./analytics";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(healthRouter);
|
||||
router.use(toolsRouter);
|
||||
router.use(ratingsRouter);
|
||||
router.use(analyticsRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, toolsTable, ratingsTable } from "@workspace/db";
|
||||
import {
|
||||
ListToolRatingsParams,
|
||||
CreateRatingParams,
|
||||
CreateRatingBody,
|
||||
} from "@workspace/api-zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.get("/tools/:id/ratings", async (req, res): Promise<void> => {
|
||||
const params = ListToolRatingsParams.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()
|
||||
.from(ratingsTable)
|
||||
.where(eq(ratingsTable.toolId, params.data.id))
|
||||
.orderBy(ratingsTable.createdAt);
|
||||
|
||||
res.json(ratings);
|
||||
});
|
||||
|
||||
router.post("/tools/:id/ratings", async (req, res): Promise<void> => {
|
||||
const params = CreateRatingParams.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 parsed = CreateRatingBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const [rating] = await db.insert(ratingsTable).values({
|
||||
toolId: params.data.id,
|
||||
usefulness: parsed.data.usefulness,
|
||||
usability: parsed.data.usability,
|
||||
comment: parsed.data.comment ?? null,
|
||||
reviewerName: parsed.data.reviewerName ?? null,
|
||||
}).returning();
|
||||
|
||||
res.status(201).json(rating);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, ilike, desc, sql, avg, count } from "drizzle-orm";
|
||||
import { db, toolsTable, ratingsTable } from "@workspace/db";
|
||||
import {
|
||||
ListToolsQueryParams,
|
||||
CreateToolBody,
|
||||
GetToolParams,
|
||||
UpdateToolParams,
|
||||
UpdateToolBody,
|
||||
DeleteToolParams,
|
||||
} from "@workspace/api-zod";
|
||||
|
||||
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<void> => {
|
||||
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<number, { usefulness: number; usability: number }[]>();
|
||||
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", async (req, res): Promise<void> => {
|
||||
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,
|
||||
features: parsed.data.features ?? [],
|
||||
tags: parsed.data.tags ?? [],
|
||||
}).returning();
|
||||
|
||||
res.status(201).json(tool);
|
||||
});
|
||||
|
||||
router.get("/tools/:id", async (req, res): Promise<void> => {
|
||||
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", async (req, res): Promise<void> => {
|
||||
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<string, unknown> = {};
|
||||
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.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", async (req, res): Promise<void> => {
|
||||
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<void> => {
|
||||
const rows = await db
|
||||
.selectDistinct({ category: toolsTable.category })
|
||||
.from(toolsTable)
|
||||
.orderBy(toolsTable.category);
|
||||
res.json(rows.map((r) => r.category));
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user