0c6a35e841
Build & Push Docker Image / build (push) Successful in 8m33s
- Invalidate categories/features queries after creating/editing tools so new categories appear immediately in search, browse dropdown and tool form - Always refetch categories/features when the combobox/suggestion inputs mount - Return JSON 404 for unmatched /api routes instead of the SPA index.html - Read the manually confirmed 'better tool' from the recommendation notes instead of using the min tool id in the redundancy dashboard - Require admin for cost/relation update+delete endpoints - Stop exposing the voter token in the ratings list response - Fix parseInt type error on user id params (Express 5 params typing)
95 lines
2.7 KiB
TypeScript
95 lines
2.7 KiB
TypeScript
import { Router, type IRouter } from "express";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { db, toolsTable, ratingsTable } from "@workspace/db";
|
|
import {
|
|
ListToolRatingsParams,
|
|
CreateRatingParams,
|
|
CreateRatingBody,
|
|
} from "@workspace/api-zod";
|
|
import { requireAuth } from "../middleware/auth";
|
|
import crypto from "crypto";
|
|
|
|
const router: IRouter = Router();
|
|
|
|
const VOTER_SECRET = process.env.VOTER_SECRET || "dev-voter-secret-change-in-production";
|
|
|
|
function computeVoterToken(userId: string): string {
|
|
return crypto.createHmac("sha256", VOTER_SECRET).update(userId).digest("hex");
|
|
}
|
|
|
|
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({
|
|
id: ratingsTable.id,
|
|
toolId: ratingsTable.toolId,
|
|
usefulness: ratingsTable.usefulness,
|
|
usability: ratingsTable.usability,
|
|
comment: ratingsTable.comment,
|
|
reviewerName: ratingsTable.reviewerName,
|
|
createdAt: ratingsTable.createdAt,
|
|
})
|
|
.from(ratingsTable)
|
|
.where(eq(ratingsTable.toolId, params.data.id))
|
|
.orderBy(ratingsTable.createdAt);
|
|
|
|
res.json(ratings);
|
|
});
|
|
|
|
router.post("/tools/:id/ratings", requireAuth, 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 token = computeVoterToken(req.session.user!.sub);
|
|
|
|
const [existing] = await db
|
|
.select()
|
|
.from(ratingsTable)
|
|
.where(and(eq(ratingsTable.toolId, params.data.id), eq(ratingsTable.voterToken, token)))
|
|
.limit(1);
|
|
|
|
if (existing) {
|
|
res.status(409).json({ error: "You have already reviewed this tool." });
|
|
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,
|
|
voterToken: token,
|
|
}).returning();
|
|
|
|
res.status(201).json(rating);
|
|
});
|
|
|
|
export default router;
|