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:
cheffe01
2026-05-25 13:04:53 +00:00
parent 1de22a6979
commit 036973ea32
111 changed files with 11006 additions and 96 deletions
+173
View File
@@ -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;