fix: category cache refresh, API 404, redundancy mapping, cost/relation authz, voterToken exposure
Build & Push Docker Image / build (push) Successful in 8m33s
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:
@@ -60,6 +60,12 @@ app.use(
|
|||||||
|
|
||||||
app.use("/api", router);
|
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;
|
const staticDir = process.env.STATIC_DIR;
|
||||||
if (staticDir && existsSync(staticDir)) {
|
if (staticDir && existsSync(staticDir)) {
|
||||||
app.use(express.static(staticDir));
|
app.use(express.static(staticDir));
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
|||||||
const manualEvalMap = new Map<string, number>();
|
const manualEvalMap = new Map<string, number>();
|
||||||
for (const e of manualEvals) {
|
for (const e of manualEvals) {
|
||||||
const key = [Math.min(e.toolId, e.relatedToolId), Math.max(e.toolId, e.relatedToolId)].join(":");
|
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) {
|
function buildStats(t: typeof toolsTable.$inferSelect) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { db, toolsTable, toolCostsTable } from "@workspace/db";
|
import { db, toolsTable, toolCostsTable } from "@workspace/db";
|
||||||
import { requireAuth } from "../middleware/auth";
|
import { requireAuth, requireAdmin } from "../middleware/auth";
|
||||||
import { requireFeature } from "../middleware/feature";
|
import { requireFeature } from "../middleware/feature";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
|||||||
res.status(201).json(entry);
|
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);
|
const id = Number(req.params.id);
|
||||||
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
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);
|
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);
|
const id = Number(req.params.id);
|
||||||
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,15 @@ router.get("/tools/:id/ratings", async (req, res): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ratings = await db
|
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)
|
.from(ratingsTable)
|
||||||
.where(eq(ratingsTable.toolId, params.data.id))
|
.where(eq(ratingsTable.toolId, params.data.id))
|
||||||
.orderBy(ratingsTable.createdAt);
|
.orderBy(ratingsTable.createdAt);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
UpdateToolBody,
|
UpdateToolBody,
|
||||||
DeleteToolParams,
|
DeleteToolParams,
|
||||||
} from "@workspace/api-zod";
|
} from "@workspace/api-zod";
|
||||||
import { requireAuth } from "../middleware/auth";
|
import { requireAuth, requireAdmin } from "../middleware/auth";
|
||||||
import { requireFeature } from "../middleware/feature";
|
import { requireFeature } from "../middleware/feature";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools")
|
|||||||
res.status(201).json(relation);
|
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);
|
const id = Number(req.params.id);
|
||||||
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.patch("/users/:id", 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)) {
|
if (isNaN(id)) {
|
||||||
res.status(400).json({ error: "Invalid user id" });
|
res.status(400).json({ error: "Invalid user id" });
|
||||||
return;
|
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> => {
|
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)) {
|
if (isNaN(id)) {
|
||||||
res.status(400).json({ error: "Invalid user id" });
|
res.status(400).json({ error: "Invalid user id" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { useListCategories } from "@workspace/api-client-react";
|
import { useListCategories, getListCategoriesQueryKey } from "@workspace/api-client-react";
|
||||||
|
|
||||||
interface CategoryComboboxProps {
|
interface CategoryComboboxProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -26,7 +26,13 @@ interface CategoryComboboxProps {
|
|||||||
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
|
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState(value);
|
const [inputValue, setInputValue] = useState(value);
|
||||||
const categories = useListCategories();
|
const categories = useListCategories({
|
||||||
|
query: {
|
||||||
|
queryKey: getListCategoriesQueryKey(),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
staleTime: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const known: string[] = categories.data ?? [];
|
const known: string[] = categories.data ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface FeatureInputProps {
|
interface FeatureInputProps {
|
||||||
@@ -13,7 +13,13 @@ interface FeatureInputProps {
|
|||||||
export function FeatureInput({ value, onChange, placeholder, "data-testid": testId }: FeatureInputProps) {
|
export function FeatureInput({ value, onChange, placeholder, "data-testid": testId }: FeatureInputProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const allFeatures = useListAllFeatures();
|
const allFeatures = useListAllFeatures({
|
||||||
|
query: {
|
||||||
|
queryKey: getListAllFeaturesQueryKey(),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
staleTime: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const known: string[] = allFeatures.data ?? [];
|
const known: string[] = allFeatures.data ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
useUpdateTool,
|
useUpdateTool,
|
||||||
getGetToolQueryKey,
|
getGetToolQueryKey,
|
||||||
getListToolsQueryKey,
|
getListToolsQueryKey,
|
||||||
|
getListCategoriesQueryKey,
|
||||||
|
getListAllFeaturesQueryKey,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@@ -103,6 +105,8 @@ export default function ToolEdit() {
|
|||||||
toast({ title: "Tool updated", description: "Changes saved successfully." });
|
toast({ title: "Tool updated", description: "Changes saved successfully." });
|
||||||
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
|
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
|
||||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||||
setLocation(`/tools/${id}`);
|
setLocation(`/tools/${id}`);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useLocation } from "wouter";
|
|||||||
import { useForm, useFieldArray } from "react-hook-form";
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "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 { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
@@ -75,6 +75,8 @@ export default function ToolNew() {
|
|||||||
onSuccess: (newTool) => {
|
onSuccess: (newTool) => {
|
||||||
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
|
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
|
||||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||||
setLocation(`/tools/${newTool.id}`);
|
setLocation(`/tools/${newTool.id}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user