b8ba53598d
Update API, database schema, and frontend components to allow optional icons for tools, and adjust analytics chart rendering for better visibility. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 0ad0b686-eebd-46d2-8314-67e9e874e224 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/1p7jhzu Replit-Helium-Checkpoint-Created: true
188 lines
6.1 KiB
TypeScript
188 lines
6.1 KiB
TypeScript
import { Router, type IRouter } from "express";
|
|
import { eq, ilike, desc, sql } from "drizzle-orm";
|
|
import { db, toolsTable, ratingsTable } from "@workspace/db";
|
|
import {
|
|
ListToolsQueryParams,
|
|
CreateToolBody,
|
|
GetToolParams,
|
|
UpdateToolParams,
|
|
UpdateToolBody,
|
|
DeleteToolParams,
|
|
} from "@workspace/api-zod";
|
|
import { requireAuth } from "../middleware/auth";
|
|
|
|
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", requireAuth, 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,
|
|
iconUrl: parsed.data.iconUrl ?? 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", requireAuth, 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.iconUrl !== undefined) updateData.iconUrl = parsed.data.iconUrl;
|
|
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", requireAuth, 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));
|
|
});
|
|
|
|
router.get("/features/all", async (_req, res): Promise<void> => {
|
|
const tools = await db.select({ features: toolsTable.features }).from(toolsTable);
|
|
const featureSet = new Set<string>();
|
|
for (const t of tools) {
|
|
for (const f of t.features ?? []) {
|
|
if (f && f.trim()) featureSet.add(f.trim());
|
|
}
|
|
}
|
|
res.json([...featureSet].sort());
|
|
});
|
|
|
|
export default router;
|