e164d51574
- Raise compare limit from 8 to 9 (server + client, toast on overflow) - Make import textarea placeholder match selected format (CSV/JSON/YAML/auto) - Add GuideHelp links to admin create/edit user dialogs and tool import dialog
573 lines
21 KiB
TypeScript
573 lines
21 KiB
TypeScript
import { Router, type IRouter } from "express";
|
|
import { eq, desc, asc, sql, and, not, isNull, inArray, type SQL } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
|
import {
|
|
ListToolsQueryParams,
|
|
CreateToolBody,
|
|
GetToolParams,
|
|
UpdateToolParams,
|
|
UpdateToolBody,
|
|
DeleteToolParams,
|
|
TrashToolsBody,
|
|
} from "@workspace/api-zod";
|
|
import { requireAuth, requireAdmin } from "../middleware/auth";
|
|
import { requireFeature, hasFeature } from "../middleware/feature";
|
|
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
|
|
? 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 };
|
|
}
|
|
|
|
function canEditTool(req: import("express").Request, tool: { createdBy: string | null }): boolean {
|
|
const user = req.session.user;
|
|
if (!user) return false;
|
|
if (user.role === "admin") return true;
|
|
return tool.createdBy === user.sub || tool.createdBy === user.preferred_username;
|
|
}
|
|
|
|
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, tags, features, minRating } = parsed.data;
|
|
|
|
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
|
|
|
const conditions: SQL[] = [isNull(toolsTable.deletedAt)];
|
|
if (category) {
|
|
conditions.push(eq(toolsTable.category, category));
|
|
}
|
|
if (search) {
|
|
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
|
conditions.push(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
|
}
|
|
if (tagList.length > 0) {
|
|
conditions.push(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
|
|
}
|
|
if (featureList.length > 0) {
|
|
conditions.push(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
|
|
}
|
|
|
|
const query = db.select().from(toolsTable).where(and(...conditions));
|
|
|
|
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);
|
|
} else if (sort === "name_asc") {
|
|
result = result.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
|
} else if (sort === "name_desc") {
|
|
result = result.sort((a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: "base" }));
|
|
} else if (sort === "recently_updated") {
|
|
result = result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
}
|
|
|
|
if (minRating != null) {
|
|
result = result.filter((t) => (t.avgCombined ?? 0) >= minRating);
|
|
}
|
|
|
|
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 user = req.session.user!;
|
|
const createdBy = user.preferred_username || user.name || user.sub;
|
|
|
|
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,
|
|
createdBy,
|
|
features: parsed.data.features ?? [],
|
|
tags: parsed.data.tags ?? [],
|
|
}).returning();
|
|
|
|
await writeAuditLog(req, "tool", tool.id, "create", { name: tool.name, category: tool.category });
|
|
res.status(201).json(tool);
|
|
});
|
|
|
|
function deletedByUser(req: import("express").Request): string {
|
|
const user = req.session.user!;
|
|
return user.preferred_username || user.name || user.sub;
|
|
}
|
|
|
|
router.get("/tools/trash", requireAuth, requireFeature("trash"), async (req, res): Promise<void> => {
|
|
const { search } = req.query;
|
|
let query = db
|
|
.select()
|
|
.from(toolsTable)
|
|
.where(sql`${toolsTable.deletedAt} IS NOT NULL`)
|
|
.$dynamic();
|
|
if (search) {
|
|
const escaped = String(search).replace(/[%_\\]/g, (m) => `\\${m}`);
|
|
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
|
}
|
|
const tools = await query.orderBy(desc(toolsTable.deletedAt));
|
|
res.json(tools);
|
|
});
|
|
|
|
router.post("/tools/trash", requireAuth, requireFeature("trash"), requireAdmin, async (req, res): Promise<void> => {
|
|
const parsed = TrashToolsBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
const rows = await db.update(toolsTable)
|
|
.set({ deletedAt: new Date(), deletedBy: deletedByUser(req) })
|
|
.where(and(inArray(toolsTable.id, parsed.data.ids), isNull(toolsTable.deletedAt)))
|
|
.returning({ id: toolsTable.id });
|
|
for (const r of rows) {
|
|
await writeAuditLog(req, "tool", r.id, "trash", {});
|
|
}
|
|
res.json({ trashed: rows.length });
|
|
});
|
|
|
|
router.post("/tools/trash/restore", requireAuth, requireFeature("trash"), async (req, res): Promise<void> => {
|
|
const parsed = TrashToolsBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
const rows = await db.update(toolsTable)
|
|
.set({ deletedAt: null, deletedBy: null })
|
|
.where(and(inArray(toolsTable.id, parsed.data.ids), sql`${toolsTable.deletedAt} IS NOT NULL`))
|
|
.returning({ id: toolsTable.id });
|
|
for (const r of rows) {
|
|
await writeAuditLog(req, "tool", r.id, "restore", {});
|
|
}
|
|
res.json({ restored: rows.length });
|
|
});
|
|
|
|
router.delete("/tools/trash", requireAdmin, async (req, res): Promise<void> => {
|
|
const parsed = TrashToolsBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
const rows = await db.delete(toolsTable)
|
|
.where(and(inArray(toolsTable.id, parsed.data.ids), sql`${toolsTable.deletedAt} IS NOT NULL`))
|
|
.returning({ id: toolsTable.id, name: toolsTable.name });
|
|
for (const r of rows) {
|
|
await writeAuditLog(req, "tool", r.id, "permanent_delete", { name: r.name });
|
|
}
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
router.post("/tools/trash/empty", requireAdmin, async (req, res): Promise<void> => {
|
|
const rows = await db.delete(toolsTable)
|
|
.where(sql`${toolsTable.deletedAt} IS NOT NULL`)
|
|
.returning({ id: toolsTable.id });
|
|
await writeAuditLog(req, "tool", null, "empty_trash", { count: rows.length });
|
|
res.json({ deleted: rows.length });
|
|
});
|
|
|
|
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(and(eq(toolsTable.id, params.data.id), isNull(toolsTable.deletedAt)));
|
|
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.get("/tools/:id/rating-history", async (req, res): Promise<void> => {
|
|
const params = GetToolParams.safeParse(req.params);
|
|
if (!params.success) {
|
|
res.status(400).json({ error: params.error.message });
|
|
return;
|
|
}
|
|
const ratings = await db
|
|
.select({
|
|
date: ratingsTable.createdAt,
|
|
usefulness: ratingsTable.usefulness,
|
|
usability: ratingsTable.usability,
|
|
})
|
|
.from(ratingsTable)
|
|
.where(eq(ratingsTable.toolId, params.data.id))
|
|
.orderBy(asc(ratingsTable.createdAt));
|
|
res.json(
|
|
ratings.map((r) => ({
|
|
date: r.date,
|
|
usefulness: r.usefulness,
|
|
usability: r.usability,
|
|
combined: (r.usefulness + r.usability) / 2,
|
|
})),
|
|
);
|
|
});
|
|
|
|
router.get("/compare", requireFeature("compare"), async (req, res): Promise<void> => {
|
|
const ids = String(req.query.ids ?? "")
|
|
.split(",")
|
|
.map((s) => Number(s.trim()))
|
|
.filter((n) => Number.isInteger(n) && n > 0);
|
|
if (ids.length === 0) {
|
|
res.status(400).json({ error: "Provide at least one ids value, e.g. ?ids=1,2,3" });
|
|
return;
|
|
}
|
|
if (ids.length > 9) {
|
|
res.status(400).json({ error: "Maximum of 9 tools can be compared" });
|
|
return;
|
|
}
|
|
const tools = await db.select().from(toolsTable).where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids)));
|
|
if (tools.length === 0) {
|
|
res.status(404).json({ error: "No tools found" });
|
|
return;
|
|
}
|
|
const toolIds = tools.map((t) => t.id);
|
|
const ratings = await db
|
|
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
|
.from(ratingsTable)
|
|
.where(inArray(ratingsTable.toolId, toolIds));
|
|
const byTool = new Map<number, { usefulness: number; usability: number }[]>();
|
|
for (const r of ratings) {
|
|
const list = byTool.get(r.toolId) ?? [];
|
|
list.push({ usefulness: r.usefulness, usability: r.usability });
|
|
byTool.set(r.toolId, list);
|
|
}
|
|
const byId = new Map(tools.map((t) => [t.id, t]));
|
|
const ordered = ids.filter((id) => byId.has(id)).map((id) => buildToolWithStats(byId.get(id)!, byTool.get(id) ?? []));
|
|
res.json(ordered);
|
|
});
|
|
|
|
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 [existing] = await db
|
|
.select()
|
|
.from(toolsTable)
|
|
.where(eq(toolsTable.id, params.data.id));
|
|
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Tool not found" });
|
|
return;
|
|
}
|
|
|
|
if (!canEditTool(req, existing)) {
|
|
res.status(403).json({ error: "Not allowed to edit this tool" });
|
|
return;
|
|
}
|
|
|
|
const parsed = UpdateToolBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
|
|
const updateData: Record<string, unknown> = {};
|
|
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
|
|
|
function track(key: keyof typeof existing, value: unknown) {
|
|
if (value !== undefined && value !== existing[key]) {
|
|
changes[key] = { from: existing[key], to: value };
|
|
(updateData as Record<string, unknown>)[key] = value;
|
|
}
|
|
}
|
|
|
|
track("name", parsed.data.name);
|
|
track("description", parsed.data.description);
|
|
track("category", parsed.data.category);
|
|
track("websiteUrl", parsed.data.websiteUrl);
|
|
track("iconUrl", parsed.data.iconUrl);
|
|
if (parsed.data.features !== undefined) {
|
|
changes["features"] = { from: existing.features, to: parsed.data.features };
|
|
updateData.features = parsed.data.features;
|
|
}
|
|
if (parsed.data.tags !== undefined) {
|
|
changes["tags"] = { from: existing.tags, to: parsed.data.tags };
|
|
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;
|
|
}
|
|
|
|
if (Object.keys(changes).length > 0) {
|
|
await writeAuditLog(req, "tool", tool.id, "update", changes);
|
|
}
|
|
|
|
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 [existing] = await db
|
|
.select()
|
|
.from(toolsTable)
|
|
.where(eq(toolsTable.id, params.data.id));
|
|
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Tool not found" });
|
|
return;
|
|
}
|
|
|
|
if (!canEditTool(req, existing)) {
|
|
res.status(403).json({ error: "Not allowed to delete this tool" });
|
|
return;
|
|
}
|
|
|
|
const user = req.session.user!;
|
|
if (hasFeature(user.tier, "trash", user.role)) {
|
|
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name, trashed: true });
|
|
await db.update(toolsTable).set({
|
|
deletedAt: new Date(),
|
|
deletedBy: user.preferred_username || user.name || user.sub,
|
|
}).where(eq(toolsTable.id, params.data.id));
|
|
} else {
|
|
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name });
|
|
await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id));
|
|
}
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
router.get("/categories", async (_req, res): Promise<void> => {
|
|
const rows = await db
|
|
.selectDistinct({ category: toolsTable.category })
|
|
.from(toolsTable)
|
|
.where(isNull(toolsTable.deletedAt))
|
|
.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).where(isNull(toolsTable.deletedAt));
|
|
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());
|
|
});
|
|
|
|
router.get("/tags/all", async (_req, res): Promise<void> => {
|
|
const tools = await db.select({ tags: toolsTable.tags }).from(toolsTable).where(isNull(toolsTable.deletedAt));
|
|
const tagSet = new Set<string>();
|
|
for (const t of tools) {
|
|
for (const tag of t.tags ?? []) {
|
|
if (tag && tag.trim()) tagSet.add(tag.trim());
|
|
}
|
|
}
|
|
res.json([...tagSet].sort());
|
|
});
|
|
|
|
// ── Similar Tools ──────────────────────────────────────────
|
|
|
|
function computeSimilarityScore(
|
|
a: typeof toolsTable.$inferSelect,
|
|
b: typeof toolsTable.$inferSelect,
|
|
): number {
|
|
let score = 0;
|
|
if (a.category === b.category) score += 10;
|
|
const sharedTags = (a.tags ?? []).filter((t) => (b.tags ?? []).includes(t)).length;
|
|
score += sharedTags * 3;
|
|
const sharedFeatures = (a.features ?? []).filter((f) => (b.features ?? []).includes(f)).length;
|
|
score += sharedFeatures * 2;
|
|
return score;
|
|
}
|
|
|
|
router.get("/tools/:id/similar", async (req, res): Promise<void> => {
|
|
const { id } = req.params;
|
|
const toolId = Number(id);
|
|
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
|
|
|
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, toolId));
|
|
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; }
|
|
|
|
const allOthers = await db
|
|
.select()
|
|
.from(toolsTable)
|
|
.where(and(not(eq(toolsTable.id, toolId)), isNull(toolsTable.deletedAt)));
|
|
|
|
const manualRelations = await db
|
|
.select({
|
|
id: toolRelationsTable.id,
|
|
relatedToolId: toolRelationsTable.relatedToolId,
|
|
relationType: toolRelationsTable.relationType,
|
|
notes: toolRelationsTable.notes,
|
|
})
|
|
.from(toolRelationsTable)
|
|
.where(eq(toolRelationsTable.toolId, toolId));
|
|
|
|
const manualIds = new Set(manualRelations.map((r) => r.relatedToolId));
|
|
|
|
const autoSimilar = allOthers
|
|
.filter((t) => !manualIds.has(t.id))
|
|
.map((t) => ({ tool: t, score: computeSimilarityScore(tool, t), source: "auto" as const }))
|
|
.filter((t) => t.score > 0)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 5);
|
|
|
|
const manualTools = allOthers.filter((t) => manualIds.has(t.id));
|
|
|
|
const allRatings = await db
|
|
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
|
.from(ratingsTable)
|
|
.where(sql`${ratingsTable.toolId} = ANY(${sql`ARRAY[${sql.join([...manualIds, ...autoSimilar.map((a) => a.tool.id)].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);
|
|
}
|
|
|
|
function buildStats(t: typeof toolsTable.$inferSelect) {
|
|
const ratings = ratingsByTool.get(t.id) ?? [];
|
|
const count = ratings.length;
|
|
const avgU = count > 0 ? ratings.reduce((s, r) => s + r.usefulness, 0) / count : null;
|
|
const avgUs = count > 0 ? ratings.reduce((s, r) => s + r.usability, 0) / count : null;
|
|
return { ...t, ratingCount: count, avgUsefulness: avgU, avgUsability: avgUs, avgCombined: avgU != null && avgUs != null ? (avgU + avgUs) / 2 : null };
|
|
}
|
|
|
|
const manual = manualRelations.map((r) => {
|
|
const t = manualTools.find((mt) => mt.id === r.relatedToolId);
|
|
return t ? { ...buildStats(t), relationId: r.id, relationType: r.relationType, notes: r.notes, source: "manual" as const } : null;
|
|
}).filter(Boolean);
|
|
|
|
const auto = autoSimilar.map((a) => ({ ...buildStats(a.tool), score: a.score, source: a.source }));
|
|
|
|
res.json({ manual, auto });
|
|
});
|
|
|
|
router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools"), async (req, res): Promise<void> => {
|
|
const toolId = Number(req.params.id);
|
|
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); 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()
|
|
.from(toolRelationsTable)
|
|
.where(and(eq(toolRelationsTable.toolId, toolId), eq(toolRelationsTable.relatedToolId, relatedToolId)))
|
|
.limit(1);
|
|
|
|
if (existing) { res.status(409).json({ error: "Relation already exists" }); return; }
|
|
|
|
const [relation] = await db.insert(toolRelationsTable).values({
|
|
toolId,
|
|
relatedToolId,
|
|
relationType,
|
|
notes: notes ?? null,
|
|
createdBy: Number(req.session.user!.sub),
|
|
}).returning();
|
|
|
|
await writeAuditLog(req, "tool_relation", relation.id, "create", { toolId, relatedToolId, relationType });
|
|
res.status(201).json(relation);
|
|
});
|
|
|
|
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; }
|
|
|
|
const [existing] = await db.select().from(toolRelationsTable).where(eq(toolRelationsTable.id, id));
|
|
if (!existing) { res.status(404).json({ error: "Relation not found" }); return; }
|
|
|
|
await writeAuditLog(req, "tool_relation", id, "delete", { toolId: existing.toolId, relatedToolId: existing.relatedToolId });
|
|
await db.delete(toolRelationsTable).where(eq(toolRelationsTable.id, id));
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
export default router;
|