fix: security hardening, validation, cache and analytics fixes
Build & Push Docker Image / build (push) Successful in 4m32s
Build & Push Docker Image / build (push) Successful in 4m32s
Backend security: - Admin-gate /admin/redundancy (GET+POST) with zod validation and tool existence checks - Restrict CORS to same-origin (plus CORS_ORIGIN allowlist) and SameSite=Lax cookie - Validate returnTo to prevent open redirect in the OIDC flow - Validate/coerce relations body, reject self-relations and non-admin 'recommended' - Add central JSON error middleware (no more Express HTML 500s) - Fail fast at startup when SESSION_SECRET/VOTER_SECRET missing in production Backend correctness: - Stop leaking voterToken in the create-rating response - Allow clearing websiteUrl/iconUrl (nullable in UpdateToolBody, frontend sends null) - Regenerate session after login/callback (session fixation) and add OIDC state check - Block self-demotion and last-admin demotion in user PATCH - Set created_by to NULL on user delete (FK-safe) - Validate cost create/update bodies with zod - Unique index (tool_id, voter_token) + 409 on race duplicate ratings - Clamp audit limit, escape ilike wildcards in search, O(N) analytics queries Frontend: - tools-browse reads and syncs URL query params (fixes home 'View all' links) - Invalidate analytics/top-tools/categories/features caches after mutations - Sync category combobox input when the value changes externally - Hide Write a Review for anonymous users, drop unreachable rating guard
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import express, { type Express } from "express";
|
||||
import cors from "cors";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import pinoHttp from "pino-http";
|
||||
@@ -11,6 +10,15 @@ import "./types/session.d.ts";
|
||||
|
||||
const PgStore = ConnectPgSimple(session);
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
if (isProd && !process.env.SESSION_SECRET) {
|
||||
throw new Error("SESSION_SECRET must be set in production");
|
||||
}
|
||||
if (isProd && !process.env.VOTER_SECRET) {
|
||||
throw new Error("VOTER_SECRET must be set in production");
|
||||
}
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
app.set("trust proxy", 1);
|
||||
@@ -35,7 +43,36 @@ app.use(
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
const allowedOrigins = (process.env.CORS_ORIGIN ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
const host = req.headers.host;
|
||||
let sameOrigin = false;
|
||||
if (origin && host) {
|
||||
try {
|
||||
sameOrigin = new URL(origin).host === host;
|
||||
} catch {
|
||||
sameOrigin = false;
|
||||
}
|
||||
}
|
||||
const allow = !origin || sameOrigin || allowedOrigins.includes(origin);
|
||||
if (allow) {
|
||||
if (origin) res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Vary", "Origin");
|
||||
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.sendStatus(204);
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
@@ -50,10 +87,10 @@ app.use(
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: isProd,
|
||||
httpOnly: true,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
sameSite: "lax",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -66,6 +103,18 @@ app.use("/api", (_req, res) => {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
});
|
||||
|
||||
// Central JSON error handler (Express 5 forwards rejected async handlers here).
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const statusCode = (err as { status?: unknown })?.status ?? (err as { statusCode?: unknown })?.statusCode;
|
||||
const status = typeof statusCode === "number" && statusCode >= 400 && statusCode < 600 ? statusCode : 500;
|
||||
if (isProd) {
|
||||
logger.error({ err: (err as Error)?.message }, "Unhandled error");
|
||||
} else {
|
||||
logger.error({ err }, "Unhandled error");
|
||||
}
|
||||
res.status(status).json({ error: isProd ? "Internal server error" : (err as Error)?.message ?? "Internal server error" });
|
||||
});
|
||||
|
||||
const staticDir = process.env.STATIC_DIR;
|
||||
if (staticDir && existsSync(staticDir)) {
|
||||
app.use(express.static(staticDir));
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db";
|
||||
import { requireAuth } from "../middleware/auth";
|
||||
import { requireAdmin } from "../middleware/auth";
|
||||
import { writeAuditLog } from "../lib/audit";
|
||||
|
||||
const EvaluateBody = z.object({
|
||||
toolId: z.coerce.number().int().positive(),
|
||||
relatedToolId: z.coerce.number().int().positive(),
|
||||
betterToolId: z.coerce.number().int().positive(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
function buildRecommendation(a: any, b: any): { betterToolId: number; betterName: string; reason: string; certainty: "high" | "medium" | "low"; reasons: string[] } {
|
||||
@@ -62,7 +70,7 @@ function buildRecommendation(a: any, b: any): { betterToolId: number; betterName
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> => {
|
||||
router.get("/admin/redundancy", requireAdmin, async (_req, res): Promise<void> => {
|
||||
const tools = await db.select().from(toolsTable).orderBy(toolsTable.category, toolsTable.name);
|
||||
|
||||
const allRatings = await db
|
||||
@@ -158,10 +166,27 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post("/admin/redundancy/evaluate", requireAuth, async (req, res): Promise<void> => {
|
||||
const { toolId, relatedToolId, betterToolId, notes } = req.body;
|
||||
if (!toolId || !relatedToolId || !betterToolId) {
|
||||
res.status(400).json({ error: "toolId, relatedToolId, and betterToolId are required" });
|
||||
router.post("/admin/redundancy/evaluate", requireAdmin, async (req, res): Promise<void> => {
|
||||
const parsed = EvaluateBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const { toolId, relatedToolId, betterToolId, notes } = parsed.data;
|
||||
if (toolId === relatedToolId) {
|
||||
res.status(400).json({ error: "toolId and relatedToolId must differ" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [toolA] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, toolId)).limit(1);
|
||||
const [toolB] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, relatedToolId)).limit(1);
|
||||
if (!toolA || !toolB) {
|
||||
res.status(404).json({ error: "One or both tools not found" });
|
||||
return;
|
||||
}
|
||||
const [better] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, betterToolId)).limit(1);
|
||||
if (!better) {
|
||||
res.status(404).json({ error: "betterToolId not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,78 +73,64 @@ router.get("/analytics/top-tools", async (req, res): Promise<void> => {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const limit = parsed.data.limit ?? 10;
|
||||
const limit = Math.min(Math.max(parsed.data.limit ?? 10, 1), 50);
|
||||
const metric = parsed.data.metric ?? "combined";
|
||||
|
||||
const tools = await db.select().from(toolsTable);
|
||||
const allRatings = await db
|
||||
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||
.from(ratingsTable);
|
||||
const scoreExpr = metric === "usefulness"
|
||||
? sql`avg(${ratingsTable.usefulness})`
|
||||
: metric === "usability"
|
||||
? sql`avg(${ratingsTable.usability})`
|
||||
: sql`(avg(${ratingsTable.usefulness}) + avg(${ratingsTable.usability})) / 2`;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const toolsWithStats = tools.map((t) => {
|
||||
const ratings = ratingsByTool.get(t.id) ?? [];
|
||||
const rc = ratings.length;
|
||||
const au = rc > 0 ? ratings.reduce((s, r) => s + r.usefulness, 0) / rc : null;
|
||||
const aus = rc > 0 ? ratings.reduce((s, r) => s + r.usability, 0) / rc : null;
|
||||
const ac = au != null && aus != null ? (au + aus) / 2 : null;
|
||||
return { ...t, ratingCount: rc, avgUsefulness: au, avgUsability: aus, avgCombined: ac };
|
||||
});
|
||||
|
||||
const scored = toolsWithStats
|
||||
.filter((t) => t.ratingCount > 0)
|
||||
.map((t) => {
|
||||
let score = 0;
|
||||
if (metric === "usefulness") score = t.avgUsefulness ?? 0;
|
||||
else if (metric === "usability") score = t.avgUsability ?? 0;
|
||||
else score = t.avgCombined ?? 0;
|
||||
return { tool: t, score, ratingCount: t.ratingCount };
|
||||
const rows = await db
|
||||
.select({
|
||||
tool: toolsTable,
|
||||
ratingCount: sql<number>`count(${ratingsTable.id})::int`,
|
||||
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
|
||||
avgUsability: sql<number | null>`avg(${ratingsTable.usability})`,
|
||||
score: sql<number>`${scoreExpr}`,
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
.from(toolsTable)
|
||||
.innerJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id))
|
||||
.groupBy(toolsTable.id)
|
||||
.orderBy(desc(scoreExpr))
|
||||
.limit(limit);
|
||||
|
||||
const scored = rows.map(({ tool, ratingCount, avgUsefulness, avgUsability, score }) => {
|
||||
const avgCombined = avgUsefulness != null && avgUsability != null
|
||||
? (Number(avgUsefulness) + Number(avgUsability)) / 2
|
||||
: null;
|
||||
return {
|
||||
tool: { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined },
|
||||
score: Number(score),
|
||||
ratingCount,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(scored);
|
||||
});
|
||||
|
||||
router.get("/analytics/by-category", async (_req, res): Promise<void> => {
|
||||
const categories = await db
|
||||
.selectDistinct({ category: toolsTable.category })
|
||||
.from(toolsTable);
|
||||
|
||||
const result = await Promise.all(
|
||||
categories.map(async ({ category }) => {
|
||||
const [toolCount] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(toolsTable)
|
||||
.where(eq(toolsTable.category, category));
|
||||
|
||||
const [ratingStats] = await db
|
||||
.select({
|
||||
totalRatings: sql<number>`count(*)::int`,
|
||||
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
|
||||
avgUsability: sql<number | null>`avg(${ratingsTable.usability})`,
|
||||
})
|
||||
.from(ratingsTable)
|
||||
.innerJoin(toolsTable, eq(ratingsTable.toolId, toolsTable.id))
|
||||
.where(eq(toolsTable.category, category));
|
||||
|
||||
return {
|
||||
category,
|
||||
toolCount: toolCount?.count ?? 0,
|
||||
totalRatings: ratingStats?.totalRatings ?? 0,
|
||||
avgUsefulness: ratingStats?.avgUsefulness != null ? Number(ratingStats.avgUsefulness) : null,
|
||||
avgUsability: ratingStats?.avgUsability != null ? Number(ratingStats.avgUsability) : null,
|
||||
};
|
||||
const rows = await db
|
||||
.select({
|
||||
category: toolsTable.category,
|
||||
toolCount: sql<number>`count(distinct ${toolsTable.id})::int`,
|
||||
totalRatings: sql<number>`count(${ratingsTable.id})::int`,
|
||||
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
|
||||
avgUsability: sql<number | null>`avg(${ratingsTable.usability})`,
|
||||
})
|
||||
);
|
||||
.from(toolsTable)
|
||||
.leftJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id))
|
||||
.groupBy(toolsTable.category)
|
||||
.orderBy(toolsTable.category);
|
||||
|
||||
res.json(result);
|
||||
res.json(rows.map((r) => ({
|
||||
category: r.category,
|
||||
toolCount: r.toolCount ?? 0,
|
||||
totalRatings: r.totalRatings ?? 0,
|
||||
avgUsefulness: r.avgUsefulness != null ? Number(r.avgUsefulness) : null,
|
||||
avgUsability: r.avgUsability != null ? Number(r.avgUsability) : null,
|
||||
})));
|
||||
});
|
||||
|
||||
router.get("/analytics/rating-distribution", async (req, res): Promise<void> => {
|
||||
|
||||
@@ -7,7 +7,8 @@ const router: IRouter = Router();
|
||||
|
||||
router.get("/audit-logs", requireAdmin, async (req, res): Promise<void> => {
|
||||
const { entityType, entityId, limit } = req.query;
|
||||
const maxLimit = Math.min(parseInt(limit as string) || 100, 500);
|
||||
const parsedLimit = parseInt(limit as string, 10);
|
||||
const maxLimit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 500) : 100;
|
||||
|
||||
const conditions: ReturnType<typeof eq>[] = [];
|
||||
if (entityType && typeof entityType === "string") {
|
||||
|
||||
@@ -25,6 +25,15 @@ function getBaseUrl(req: Request): string {
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
function isSafeReturnTo(value: string): boolean {
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return false;
|
||||
try {
|
||||
return new URL(value, "http://localhost").origin === "http://localhost";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getClient(): Promise<Client | null> {
|
||||
if (cachedClient) return cachedClient;
|
||||
|
||||
@@ -125,6 +134,10 @@ router.post("/auth/login", async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.session.regenerate((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
|
||||
req.session.user = {
|
||||
sub: String(user.id),
|
||||
name: user.username,
|
||||
@@ -155,9 +168,11 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
||||
|
||||
const codeVerifier = generators.codeVerifier();
|
||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||
const state = generators.state();
|
||||
|
||||
req.session.codeVerifier = codeVerifier;
|
||||
if (req.query.returnTo && typeof req.query.returnTo === "string") {
|
||||
req.session.oidcState = state;
|
||||
if (req.query.returnTo && typeof req.query.returnTo === "string" && isSafeReturnTo(req.query.returnTo)) {
|
||||
req.session.returnTo = req.query.returnTo;
|
||||
}
|
||||
|
||||
@@ -167,6 +182,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
});
|
||||
|
||||
res.redirect(url);
|
||||
@@ -185,17 +201,29 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = typeof req.query.state === "string" ? req.query.state : "";
|
||||
if (!state || state !== req.session.oidcState) {
|
||||
res.status(400).json({ error: "Invalid OAuth state." });
|
||||
return;
|
||||
}
|
||||
delete req.session.oidcState;
|
||||
|
||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||
|
||||
try {
|
||||
const params = client.callbackParams(req);
|
||||
const tokenSet = await client.callback(redirectUri, params, {
|
||||
code_verifier: codeVerifier,
|
||||
state,
|
||||
});
|
||||
|
||||
const userinfo = await client.userinfo(tokenSet.access_token!);
|
||||
const dbUser = await upsertUserFromOidc(userinfo);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.session.regenerate((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
|
||||
req.session.user = {
|
||||
sub: dbUser.id.toString(),
|
||||
email: typeof userinfo.email === "string" ? userinfo.email : undefined,
|
||||
@@ -207,10 +235,10 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
||||
};
|
||||
delete req.session.codeVerifier;
|
||||
|
||||
const returnTo = req.session.returnTo || "/";
|
||||
const returnTo = req.session.returnTo ?? "/";
|
||||
delete req.session.returnTo;
|
||||
|
||||
res.redirect(returnTo);
|
||||
res.redirect(isSafeReturnTo(returnTo) ? returnTo : "/");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Keycloak callback failed");
|
||||
res.status(500).json({ error: "Authentication failed." });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, toolsTable, toolCostsTable } from "@workspace/db";
|
||||
import { requireAuth, requireAdmin } from "../middleware/auth";
|
||||
import { requireFeature } from "../middleware/feature";
|
||||
@@ -7,6 +8,27 @@ import { writeAuditLog } from "../lib/audit";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
const LicenseType = z.enum(["free", "subscription", "one_time", "usage_based"]);
|
||||
const BillingPeriod = z.enum(["monthly", "quarterly", "yearly"]);
|
||||
|
||||
const CostCreateBody = z.object({
|
||||
licenseType: LicenseType.optional(),
|
||||
billingPeriod: BillingPeriod.nullable().optional(),
|
||||
cost: z.coerce.number().finite().nonnegative().nullable().optional(),
|
||||
currency: z.string().min(1).max(10).optional(),
|
||||
renewalDate: z.coerce.date().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const CostUpdateBody = CostCreateBody.partial().extend({
|
||||
licenseType: LicenseType.optional(),
|
||||
billingPeriod: BillingPeriod.nullable().optional(),
|
||||
cost: z.coerce.number().finite().nonnegative().nullable().optional(),
|
||||
currency: z.string().min(1).max(10).optional(),
|
||||
renewalDate: z.coerce.date().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
router.get("/tools/:id/costs", async (req, res): Promise<void> => {
|
||||
const toolId = Number(req.params.id);
|
||||
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||
@@ -30,15 +52,20 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
||||
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, toolId));
|
||||
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; }
|
||||
|
||||
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = req.body;
|
||||
const parsed = CostCreateBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = parsed.data;
|
||||
|
||||
const [entry] = await db.insert(toolCostsTable).values({
|
||||
toolId,
|
||||
licenseType: licenseType ?? "free",
|
||||
billingPeriod: billingPeriod ?? null,
|
||||
cost: cost ?? null,
|
||||
cost: cost != null ? String(cost) : null,
|
||||
currency: currency ?? "EUR",
|
||||
renewalDate: renewalDate ? new Date(renewalDate) : null,
|
||||
renewalDate: renewalDate ?? null,
|
||||
notes: notes ?? null,
|
||||
createdBy: Number(req.session.user!.sub),
|
||||
}).returning();
|
||||
@@ -54,13 +81,18 @@ router.patch("/costs/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
const [existing] = await db.select().from(toolCostsTable).where(eq(toolCostsTable.id, id));
|
||||
if (!existing) { res.status(404).json({ error: "Cost entry not found" }); return; }
|
||||
|
||||
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = req.body;
|
||||
const parsed = CostUpdateBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = parsed.data;
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (licenseType !== undefined) updateData.licenseType = licenseType;
|
||||
if (billingPeriod !== undefined) updateData.billingPeriod = billingPeriod;
|
||||
if (cost !== undefined) updateData.cost = cost;
|
||||
if (cost !== undefined) updateData.cost = cost != null ? String(cost) : null;
|
||||
if (currency !== undefined) updateData.currency = currency;
|
||||
if (renewalDate !== undefined) updateData.renewalDate = renewalDate ? new Date(renewalDate) : null;
|
||||
if (renewalDate !== undefined) updateData.renewalDate = renewalDate;
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
|
||||
const [updated] = await db.update(toolCostsTable).set(updateData).where(eq(toolCostsTable.id, id)).returning();
|
||||
|
||||
@@ -79,14 +79,39 @@ router.post("/tools/:id/ratings", requireAuth, async (req, res): Promise<void> =
|
||||
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();
|
||||
let rating: {
|
||||
id: number;
|
||||
toolId: number;
|
||||
usefulness: number;
|
||||
usability: number;
|
||||
comment: string | null;
|
||||
reviewerName: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
try {
|
||||
[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({
|
||||
id: ratingsTable.id,
|
||||
toolId: ratingsTable.toolId,
|
||||
usefulness: ratingsTable.usefulness,
|
||||
usability: ratingsTable.usability,
|
||||
comment: ratingsTable.comment,
|
||||
reviewerName: ratingsTable.reviewerName,
|
||||
createdAt: ratingsTable.createdAt,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as { code?: string })?.code === "23505") {
|
||||
res.status(409).json({ error: "You have already reviewed this tool." });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
res.status(201).json(rating);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, ilike, desc, sql, and, not } from "drizzle-orm";
|
||||
import { eq, desc, sql, and, not } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
||||
import {
|
||||
ListToolsQueryParams,
|
||||
@@ -15,6 +16,14 @@ import { writeAuditLog } from "../lib/audit";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
const PUBLIC_RELATION_TYPES = ["similar", "replaces", "superseded_by"] as const;
|
||||
const RELATION_TYPES = [...PUBLIC_RELATION_TYPES, "recommended"] as const;
|
||||
const RelationBody = z.object({
|
||||
relatedToolId: z.coerce.number().int().positive(),
|
||||
relationType: z.enum(RELATION_TYPES).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
function buildToolWithStats(tool: typeof toolsTable.$inferSelect, ratings: { usefulness: number; usability: number }[]) {
|
||||
const ratingCount = ratings.length;
|
||||
const avgUsefulness = ratingCount > 0
|
||||
@@ -49,7 +58,8 @@ router.get("/tools", async (req, res): Promise<void> => {
|
||||
query = query.where(eq(toolsTable.category, category));
|
||||
}
|
||||
if (search) {
|
||||
query = query.where(ilike(toolsTable.name, `%${search}%`));
|
||||
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
||||
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
||||
}
|
||||
|
||||
const tools = await query.orderBy(desc(toolsTable.createdAt));
|
||||
@@ -326,8 +336,29 @@ router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools")
|
||||
const toolId = Number(req.params.id);
|
||||
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||
|
||||
const { relatedToolId, relationType, notes } = req.body;
|
||||
if (!relatedToolId) { res.status(400).json({ error: "relatedToolId is required" }); return; }
|
||||
const parsed = RelationBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const { relatedToolId, notes } = parsed.data;
|
||||
const relationType = parsed.data.relationType ?? "similar";
|
||||
|
||||
if (relationType === "recommended" && req.session.user?.role !== "admin") {
|
||||
res.status(403).json({ error: "Only admins can create recommended relations" });
|
||||
return;
|
||||
}
|
||||
if (toolId === relatedToolId) {
|
||||
res.status(400).json({ error: "A tool cannot be related to itself" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [toolA] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, toolId)).limit(1);
|
||||
const [toolB] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, relatedToolId)).limit(1);
|
||||
if (!toolA || !toolB) {
|
||||
res.status(404).json({ error: "One or both tools not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -340,7 +371,7 @@ router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools")
|
||||
const [relation] = await db.insert(toolRelationsTable).values({
|
||||
toolId,
|
||||
relatedToolId,
|
||||
relationType: relationType ?? "similar",
|
||||
relationType,
|
||||
notes: notes ?? null,
|
||||
createdBy: Number(req.session.user!.sub),
|
||||
}).returning();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db, usersTable } from "@workspace/db";
|
||||
import { requireAdmin } from "../middleware/auth";
|
||||
@@ -85,6 +85,32 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const [target] = await db
|
||||
.select({ id: usersTable.id, role: usersTable.role })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!target) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.role === "admin" && parsed.data.role === "user") {
|
||||
if (req.session.user?.sub === String(id)) {
|
||||
res.status(400).json({ error: "Cannot demote your own account" });
|
||||
return;
|
||||
}
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.role, "admin"));
|
||||
if (count <= 1) {
|
||||
res.status(400).json({ error: "Cannot demote the last admin" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.update(usersTable)
|
||||
.set({ role: parsed.data.role })
|
||||
@@ -97,11 +123,6 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
createdAt: usersTable.createdAt,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeAuditLog(req, "user", id, "update", { role: parsed.data.role });
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
+1
@@ -13,5 +13,6 @@ declare module "express-session" {
|
||||
};
|
||||
codeVerifier?: string;
|
||||
returnTo?: string;
|
||||
oidcState?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,6 +26,11 @@ interface CategoryComboboxProps {
|
||||
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
const categories = useListCategories({
|
||||
query: {
|
||||
queryKey: getListCategoriesQueryKey(),
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
getListToolRatingsQueryKey,
|
||||
useGetRatingDistribution,
|
||||
getGetRatingDistributionQueryKey,
|
||||
useCreateRating
|
||||
useCreateRating,
|
||||
getGetTopToolsQueryKey,
|
||||
getGetAnalyticsSummaryQueryKey,
|
||||
getListCategoriesQueryKey,
|
||||
getListAllFeaturesQueryKey
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -214,15 +218,6 @@ export default function ToolDetail() {
|
||||
});
|
||||
|
||||
const onSubmit = (data: RatingFormValues) => {
|
||||
if (data.usefulness === 0 || data.usability === 0) {
|
||||
toast({
|
||||
title: "Missing ratings",
|
||||
description: "Please rate both usefulness and usability.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createRating.mutate({ id, data }, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
@@ -236,6 +231,8 @@ export default function ToolDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
|
||||
queryClient.invalidateQueries({ queryKey: getListToolRatingsQueryKey(id) });
|
||||
queryClient.invalidateQueries({ queryKey: getGetRatingDistributionQueryKey({ toolId: id }) });
|
||||
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
@@ -277,6 +274,10 @@ export default function ToolDetail() {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Tool deleted" });
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
||||
setLocation("/tools");
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -711,7 +712,7 @@ export default function ToolDetail() {
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-2xl font-bold">Reviews</h3>
|
||||
{!isReviewFormOpen && (
|
||||
{!isReviewFormOpen && user && (
|
||||
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
getListToolsQueryKey,
|
||||
getListCategoriesQueryKey,
|
||||
getListAllFeaturesQueryKey,
|
||||
getGetTopToolsQueryKey,
|
||||
getGetAnalyticsSummaryQueryKey,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -92,8 +94,8 @@ export default function ToolEdit() {
|
||||
const onSubmit = (data: ToolFormValues) => {
|
||||
const payload = {
|
||||
...data,
|
||||
websiteUrl: data.websiteUrl || undefined,
|
||||
iconUrl: data.iconUrl || undefined,
|
||||
websiteUrl: data.websiteUrl?.trim() ? data.websiteUrl : null,
|
||||
iconUrl: data.iconUrl?.trim() ? data.iconUrl : null,
|
||||
features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""),
|
||||
tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""),
|
||||
};
|
||||
@@ -107,6 +109,8 @@ export default function ToolEdit() {
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
||||
setLocation(`/tools/${id}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -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, getListCategoriesQueryKey, getListAllFeaturesQueryKey } from "@workspace/api-client-react";
|
||||
import { useCreateTool, getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey, getGetTopToolsQueryKey, getGetAnalyticsSummaryQueryKey } from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Layout } from "@/components/layout";
|
||||
@@ -77,6 +77,8 @@ export default function ToolNew() {
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
||||
setLocation(`/tools/${newTool.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
useListTools,
|
||||
useListCategories,
|
||||
@@ -11,13 +11,35 @@ import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Search, Wrench, SlidersHorizontal, X } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Link, useLocation, useSearch } from "wouter";
|
||||
|
||||
const SORT_VALUES = new Set<string>([ListToolsSort.newest, ListToolsSort.top_rated, ListToolsSort.most_reviewed]);
|
||||
|
||||
export default function ToolsBrowse() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [category, setCategory] = useState<string>("all");
|
||||
const [sort, setSort] = useState<ListToolsSort>(ListToolsSort.newest);
|
||||
const [, navigate] = useLocation();
|
||||
const urlSearch = useSearch();
|
||||
|
||||
const initialParams = new URLSearchParams(urlSearch);
|
||||
const initialSearch = initialParams.get("search") ?? "";
|
||||
const initialCategory = initialParams.get("category") ?? "all";
|
||||
const initialSortParam = initialParams.get("sort") ?? "";
|
||||
const initialSort = SORT_VALUES.has(initialSortParam)
|
||||
? (initialSortParam as ListToolsSort)
|
||||
: ListToolsSort.newest;
|
||||
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const [searchInput, setSearchInput] = useState(initialSearch);
|
||||
const [category, setCategory] = useState<string>(initialCategory);
|
||||
const [sort, setSort] = useState<ListToolsSort>(initialSort);
|
||||
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams();
|
||||
if (search) p.set("search", search);
|
||||
if (category && category !== "all") p.set("category", category);
|
||||
if (sort && sort !== ListToolsSort.newest) p.set("sort", sort);
|
||||
const qs = p.toString();
|
||||
navigate(qs ? `/tools?${qs}` : "/tools", { replace: true });
|
||||
}, [search, category, sort]);
|
||||
|
||||
const { data: categories, isLoading: loadingCategories } = useListCategories();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user