fix: category cache refresh, API 404, redundancy mapping, cost/relation authz, voterToken exposure
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)
This commit is contained in:
opencode
2026-08-01 15:25:06 +02:00
parent 2b5f074a63
commit 0c6a35e841
10 changed files with 47 additions and 14 deletions
+6
View File
@@ -60,6 +60,12 @@ app.use(
app.use("/api", router);
// Any unmatched /api route should return a JSON 404 instead of falling
// through to the SPA catch-all below.
app.use("/api", (_req, res) => {
res.status(404).json({ error: "Not found" });
});
const staticDir = process.env.STATIC_DIR;
if (staticDir && existsSync(staticDir)) {
app.use(express.static(staticDir));
+2 -1
View File
@@ -95,7 +95,8 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
const manualEvalMap = new Map<string, number>();
for (const e of manualEvals) {
const key = [Math.min(e.toolId, e.relatedToolId), Math.max(e.toolId, e.relatedToolId)].join(":");
manualEvalMap.set(key, e.toolId);
const betterMatch = e.notes?.match(/Recommended:\s*(\d+)/);
manualEvalMap.set(key, betterMatch ? Number(betterMatch[1]) : e.toolId);
}
function buildStats(t: typeof toolsTable.$inferSelect) {
+3 -3
View File
@@ -1,7 +1,7 @@
import { Router, type IRouter } from "express";
import { eq, and } from "drizzle-orm";
import { db, toolsTable, toolCostsTable } from "@workspace/db";
import { requireAuth } from "../middleware/auth";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { requireFeature } from "../middleware/feature";
import { writeAuditLog } from "../lib/audit";
@@ -47,7 +47,7 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
res.status(201).json(entry);
});
router.patch("/costs/:id", requireAuth, async (req, res): Promise<void> => {
router.patch("/costs/:id", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
@@ -68,7 +68,7 @@ router.patch("/costs/:id", requireAuth, async (req, res): Promise<void> => {
res.json(updated);
});
router.delete("/costs/:id", requireAuth, async (req, res): Promise<void> => {
router.delete("/costs/:id", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
+9 -1
View File
@@ -31,7 +31,15 @@ router.get("/tools/:id/ratings", async (req, res): Promise<void> => {
}
const ratings = await db
.select()
.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);
+2 -2
View File
@@ -9,7 +9,7 @@ import {
UpdateToolBody,
DeleteToolParams,
} from "@workspace/api-zod";
import { requireAuth } from "../middleware/auth";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { requireFeature } from "../middleware/feature";
import { writeAuditLog } from "../lib/audit";
@@ -349,7 +349,7 @@ router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools")
res.status(201).json(relation);
});
router.delete("/tools/relations/:id", requireAuth, async (req, res): Promise<void> => {
router.delete("/tools/relations/:id", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
+2 -2
View File
@@ -73,7 +73,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
});
router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
const id = parseInt(req.params.id, 10);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) {
res.status(400).json({ error: "Invalid user id" });
return;
@@ -107,7 +107,7 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
});
router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
const id = parseInt(req.params.id, 10);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) {
res.status(400).json({ error: "Invalid user id" });
return;
@@ -15,7 +15,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useListCategories } from "@workspace/api-client-react";
import { useListCategories, getListCategoriesQueryKey } from "@workspace/api-client-react";
interface CategoryComboboxProps {
value: string;
@@ -26,7 +26,13 @@ interface CategoryComboboxProps {
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
const categories = useListCategories();
const categories = useListCategories({
query: {
queryKey: getListCategoriesQueryKey(),
refetchOnMount: "always",
staleTime: 0,
},
});
const known: string[] = categories.data ?? [];
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { useListAllFeatures } from "@workspace/api-client-react";
import { useListAllFeatures, getListAllFeaturesQueryKey } from "@workspace/api-client-react";
import { cn } from "@/lib/utils";
interface FeatureInputProps {
@@ -13,7 +13,13 @@ interface FeatureInputProps {
export function FeatureInput({ value, onChange, placeholder, "data-testid": testId }: FeatureInputProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const allFeatures = useListAllFeatures();
const allFeatures = useListAllFeatures({
query: {
queryKey: getListAllFeaturesQueryKey(),
refetchOnMount: "always",
staleTime: 0,
},
});
const known: string[] = allFeatures.data ?? [];
@@ -8,6 +8,8 @@ import {
useUpdateTool,
getGetToolQueryKey,
getListToolsQueryKey,
getListCategoriesQueryKey,
getListAllFeaturesQueryKey,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
@@ -103,6 +105,8 @@ export default function ToolEdit() {
toast({ title: "Tool updated", description: "Changes saved successfully." });
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
setLocation(`/tools/${id}`);
},
onError: (err) => {
+3 -1
View File
@@ -2,7 +2,7 @@ import { useLocation } from "wouter";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useCreateTool, getListToolsQueryKey } from "@workspace/api-client-react";
import { useCreateTool, getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { Layout } from "@/components/layout";
@@ -75,6 +75,8 @@ export default function ToolNew() {
onSuccess: (newTool) => {
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
setLocation(`/tools/${newTool.id}`);
},
onError: () => {