From 59badeaa48a52858828bdc5d0235a45c73673000 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 21:59:29 +0200 Subject: [PATCH] feat: auth foundation, similar tools, costs, redundancy, anonymous voting --- artifacts/api-server/src/index.ts | 71 +++++++ .../api-server/src/middleware/feature.ts | 26 +++ artifacts/api-server/src/routes/admin.ts | 62 ++++++ artifacts/api-server/src/routes/auth.ts | 51 ++++- artifacts/api-server/src/routes/costs.ts | 81 +++++++ artifacts/api-server/src/routes/index.ts | 4 + artifacts/api-server/src/routes/ratings.ts | 23 +- artifacts/api-server/src/routes/tools.ts | 123 ++++++++++- artifacts/api-server/src/types/session.d.ts | 1 + artifacts/toolrate/src/App.tsx | 2 + artifacts/toolrate/src/components/layout.tsx | 3 +- artifacts/toolrate/src/hooks/use-auth.ts | 2 + artifacts/toolrate/src/pages/admin.tsx | 15 +- artifacts/toolrate/src/pages/redundancy.tsx | 107 ++++++++++ artifacts/toolrate/src/pages/tool-detail.tsx | 197 +++++++++++++++++- .../src/generated/api.schemas.ts | 1 + lib/api-client-react/src/index.ts | 2 +- lib/db/src/schema/index.ts | 2 + lib/db/src/schema/ratings.ts | 1 + lib/db/src/schema/tool-costs.ts | 15 ++ lib/db/src/schema/tool-relations.ts | 13 ++ lib/db/src/schema/users.ts | 6 +- 22 files changed, 793 insertions(+), 15 deletions(-) create mode 100644 artifacts/api-server/src/middleware/feature.ts create mode 100644 artifacts/api-server/src/routes/admin.ts create mode 100644 artifacts/api-server/src/routes/costs.ts create mode 100644 artifacts/toolrate/src/pages/redundancy.tsx create mode 100644 lib/db/src/schema/tool-costs.ts create mode 100644 lib/db/src/schema/tool-relations.ts diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index 4b50b83..944efc8 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -55,6 +55,7 @@ async function seedAdminUser(): Promise { username: adminUsername, passwordHash, role: "admin", + tier: "enterprise", }); logger.info({ username: adminUsername }, "Admin user created"); } catch (err) { @@ -62,9 +63,79 @@ async function seedAdminUser(): Promise { } } +async function ensureToolRelationsTable(): Promise { + try { + const exists = await db.execute( + sql`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'tool_relations')`, + ); + const rows = exists.rows as [{ exists: boolean }]; + if (rows[0]?.exists) return; + + await db.execute( + sql`CREATE TABLE "tool_relations" ("id" serial NOT NULL, "tool_id" integer NOT NULL REFERENCES "tools"("id") ON DELETE CASCADE, "related_tool_id" integer NOT NULL REFERENCES "tools"("id") ON DELETE CASCADE, "relation_type" text NOT NULL DEFAULT 'similar', "notes" text, "created_by" integer REFERENCES "users"("id"), "created_at" timestamp with time zone NOT NULL DEFAULT NOW())`, + ); + await db.execute(sql`ALTER TABLE "tool_relations" ADD PRIMARY KEY ("id")`); + logger.info("tool_relations table created"); + } catch (err) { + logger.error({ err }, "Failed to ensure tool_relations table"); + } +} + +async function ensureToolCostsTable(): Promise { + try { + const exists = await db.execute( + sql`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'tool_costs')`, + ); + const rows = exists.rows as [{ exists: boolean }]; + if (rows[0]?.exists) return; + + await db.execute( + sql`CREATE TABLE "tool_costs" ("id" serial NOT NULL, "tool_id" integer NOT NULL REFERENCES "tools"("id") ON DELETE CASCADE, "license_type" text NOT NULL DEFAULT 'free', "cost" numeric(10,2), "currency" text DEFAULT 'EUR', "renewal_date" timestamp with time zone, "notes" text, "created_by" integer REFERENCES "users"("id"), "created_at" timestamp with time zone NOT NULL DEFAULT NOW())`, + ); + await db.execute(sql`ALTER TABLE "tool_costs" ADD PRIMARY KEY ("id")`); + logger.info("tool_costs table created"); + } catch (err) { + logger.error({ err }, "Failed to ensure tool_costs table"); + } +} + +async function ensureVoterTokenColumn(): Promise { + try { + const exists = await db.execute( + sql`SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name = 'ratings' AND column_name = 'voter_token')`, + ); + const rows = exists.rows as [{ exists: boolean }]; + if (rows[0]?.exists) return; + + await db.execute(sql`ALTER TABLE "ratings" ADD COLUMN "voter_token" text`); + logger.info("voter_token column added to ratings"); + } catch (err) { + logger.error({ err }, "Failed to add voter_token column"); + } +} + +async function ensureAdminTier(): Promise { + try { + const result = await db + .update(usersTable) + .set({ tier: "enterprise" }) + .where(sql`role = 'admin' AND (tier IS NULL OR tier = 'free')`) + .returning({ id: usersTable.id, username: usersTable.username }); + if (result.length > 0) { + logger.info({ count: result.length }, "Admin users upgraded to enterprise tier"); + } + } catch (err) { + logger.error({ err }, "Failed to upgrade admin tier"); + } +} + async function start(): Promise { await ensureSessionsTable(); + await ensureToolRelationsTable(); + await ensureToolCostsTable(); + await ensureVoterTokenColumn(); await seedAdminUser(); + await ensureAdminTier(); app.listen(port, (err) => { if (err) { diff --git a/artifacts/api-server/src/middleware/feature.ts b/artifacts/api-server/src/middleware/feature.ts new file mode 100644 index 0000000..5783841 --- /dev/null +++ b/artifacts/api-server/src/middleware/feature.ts @@ -0,0 +1,26 @@ +import { type Request, type Response, type NextFunction } from "express"; + +const TIER_FEATURES: Record = { + free: ["browse", "rate", "search"], + premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced"], + enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "sso", "audit-export", "api-access"], +}; + +export function hasFeature(tier: string | undefined, feature: string): boolean { + const features = TIER_FEATURES[tier ?? "free"] ?? TIER_FEATURES.free; + return features.includes(feature); +} + +export function requireFeature(feature: string) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.session.user) { + res.status(401).json({ error: "Authentication required" }); + return; + } + if (!hasFeature(req.session.user.tier, feature)) { + res.status(403).json({ error: `Feature "${feature}" requires a higher tier` }); + return; + } + next(); + }; +} diff --git a/artifacts/api-server/src/routes/admin.ts b/artifacts/api-server/src/routes/admin.ts new file mode 100644 index 0000000..9c6877b --- /dev/null +++ b/artifacts/api-server/src/routes/admin.ts @@ -0,0 +1,62 @@ +import { Router, type IRouter } from "express"; +import { sql } from "drizzle-orm"; +import { db, toolsTable, ratingsTable } from "@workspace/db"; +import { requireAuth } from "../middleware/auth"; + +const router: IRouter = Router(); + +router.get("/admin/redundancy", requireAuth, async (_req, res): Promise => { + const tools = await db.select().from(toolsTable).orderBy(toolsTable.category, toolsTable.name); + + const allRatings = await db + .select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) + .from(ratingsTable); + + const ratingsByTool = new Map(); + 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 { + id: t.id, name: t.name, category: t.category, features: t.features ?? [], tags: t.tags ?? [], + ratingCount: count, + avgUsefulness: avgU, avgUsability: avgUs, + avgCombined: avgU != null && avgUs != null ? (avgU + avgUs) / 2 : null, + }; + } + + const grouped: Record = {}; + for (const t of tools) { + const g = grouped[t.category] ?? []; + g.push(buildStats(t)); + grouped[t.category] = g; + } + + const result = Object.entries(grouped).map(([category, items]) => { + const pairs: { a: any; b: any; overlap: number; scoreDiff: number }[] = []; + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const aFeat = new Set(items[i].features); + const bFeat = new Set(items[j].features); + const shared = [...aFeat].filter((f) => bFeat.has(f)).length; + const total = new Set([...aFeat, ...bFeat]).size; + const overlap = total > 0 ? Math.round((shared / total) * 100) : 0; + const scoreDiff = (items[j].avgCombined ?? 0) - (items[i].avgCombined ?? 0); + pairs.push({ a: items[i], b: items[j], overlap, scoreDiff }); + } + } + pairs.sort((a, b) => b.overlap - a.overlap || Math.abs(b.scoreDiff) - Math.abs(a.scoreDiff)); + return { category, tools: items, pairs: pairs.slice(0, 5) }; + }); + + res.json(result); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index e05d50c..c22bb04 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -52,6 +52,46 @@ async function getClient(): Promise { } } +async function upsertUserFromOidc(userinfo: Record): Promise<{ id: number; tier: string; role: string }> { + const sub = String(userinfo.sub ?? ""); + if (!sub) throw new Error("Missing sub claim"); + + const email = typeof userinfo.email === "string" ? userinfo.email : undefined; + const preferredUsername = typeof userinfo.preferred_username === "string" ? userinfo.preferred_username : undefined; + const name = typeof userinfo.name === "string" ? userinfo.name : preferredUsername; + const username = preferredUsername ?? email ?? `oidc-${sub.slice(0, 8)}`; + + const [existing] = await db + .select({ id: usersTable.id, tier: usersTable.tier, role: usersTable.role }) + .from(usersTable) + .where(eq(usersTable.authProviderId, sub)) + .limit(1); + + if (existing) { + await db + .update(usersTable) + .set({ email, displayName: name }) + .where(eq(usersTable.id, existing.id)); + return existing; + } + + const [created] = await db + .insert(usersTable) + .values({ + username, + email, + displayName: name, + role: "user", + tier: "free", + authProvider: "oidc", + authProviderId: sub, + }) + .returning({ id: usersTable.id, tier: usersTable.tier, role: usersTable.role }); + + logger.info({ username, sub }, "OIDC user created"); + return created; +} + router.get("/auth/mode", (_req, res): void => { res.json({ mode: isOidcConfigured() ? "oidc" : "local" }); }); @@ -74,7 +114,7 @@ router.post("/auth/login", async (req, res): Promise => { .where(eq(usersTable.username, String(username))) .limit(1); - if (!user) { + if (!user || !user.passwordHash) { res.status(401).json({ error: "Invalid username or password" }); return; } @@ -91,6 +131,7 @@ router.post("/auth/login", async (req, res): Promise => { preferred_username: user.username, email: user.email ?? undefined, role: (user.role as "admin" | "user") ?? "user", + tier: (user.tier as "free" | "premium" | "enterprise") ?? "free", isLocal: true, }; @@ -100,6 +141,7 @@ router.post("/auth/login", async (req, res): Promise => { name: user.username, preferredUsername: user.username, role: user.role, + tier: user.tier, isLocal: true, }); }); @@ -152,13 +194,15 @@ router.get("/auth/callback", async (req, res): Promise => { }); const userinfo = await client.userinfo(tokenSet.access_token!); + const dbUser = await upsertUserFromOidc(userinfo); req.session.user = { - sub: userinfo.sub, + sub: dbUser.id.toString(), email: typeof userinfo.email === "string" ? userinfo.email : undefined, name: typeof userinfo.name === "string" ? userinfo.name : undefined, preferred_username: typeof userinfo.preferred_username === "string" ? userinfo.preferred_username : undefined, - role: "user", + role: (dbUser.role as "admin" | "user") ?? "user", + tier: (dbUser.tier as "free" | "premium" | "enterprise") ?? "free", isLocal: false, }; delete req.session.codeVerifier; @@ -198,6 +242,7 @@ router.get("/auth/me", async (req, res): Promise => { name: u.name ?? null, preferredUsername: u.preferred_username ?? null, role: u.role ?? "user", + tier: u.tier ?? "free", isLocal: u.isLocal ?? false, }); }); diff --git a/artifacts/api-server/src/routes/costs.ts b/artifacts/api-server/src/routes/costs.ts new file mode 100644 index 0000000..0f99de7 --- /dev/null +++ b/artifacts/api-server/src/routes/costs.ts @@ -0,0 +1,81 @@ +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 { requireFeature } from "../middleware/feature"; +import { writeAuditLog } from "../lib/audit"; + +const router: IRouter = Router(); + +router.get("/tools/:id/costs", async (req, res): Promise => { + const toolId = Number(req.params.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 costs = await db + .select() + .from(toolCostsTable) + .where(eq(toolCostsTable.toolId, toolId)) + .orderBy(toolCostsTable.createdAt); + + res.json(costs); +}); + +router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req, res): Promise => { + const toolId = Number(req.params.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 { licenseType, cost, currency, renewalDate, notes } = req.body; + + const [entry] = await db.insert(toolCostsTable).values({ + toolId, + licenseType: licenseType ?? "free", + cost: cost ?? null, + currency: currency ?? "EUR", + renewalDate: renewalDate ? new Date(renewalDate) : null, + notes: notes ?? null, + createdBy: Number(req.session.user!.sub), + }).returning(); + + await writeAuditLog(req, "tool_cost", entry.id, "create", { toolId, licenseType, cost }); + res.status(201).json(entry); +}); + +router.patch("/costs/:id", requireAuth, async (req, res): Promise => { + const id = Number(req.params.id); + if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; } + + 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, cost, currency, renewalDate, notes } = req.body; + const updateData: Record = {}; + if (licenseType !== undefined) updateData.licenseType = licenseType; + if (cost !== undefined) updateData.cost = cost; + if (currency !== undefined) updateData.currency = currency; + if (renewalDate !== undefined) updateData.renewalDate = renewalDate ? new Date(renewalDate) : null; + if (notes !== undefined) updateData.notes = notes; + + const [updated] = await db.update(toolCostsTable).set(updateData).where(eq(toolCostsTable.id, id)).returning(); + await writeAuditLog(req, "tool_cost", id, "update", { toolId: existing.toolId, ...updateData }); + res.json(updated); +}); + +router.delete("/costs/:id", requireAuth, async (req, res): Promise => { + const id = Number(req.params.id); + if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; } + + const [existing] = await db.select().from(toolCostsTable).where(eq(toolCostsTable.id, id)); + if (!existing) { res.status(404).json({ error: "Cost entry not found" }); return; } + + await writeAuditLog(req, "tool_cost", id, "delete", { toolId: existing.toolId }); + await db.delete(toolCostsTable).where(eq(toolCostsTable.id, id)); + res.sendStatus(204); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 0a43be0..12df8c4 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -6,6 +6,8 @@ import analyticsRouter from "./analytics"; import authRouter from "./auth"; import usersRouter from "./users"; import auditRouter from "./audit"; +import costsRouter from "./costs"; +import adminRouter from "./admin"; const router: IRouter = Router(); @@ -13,6 +15,8 @@ router.use(authRouter); router.use(healthRouter); router.use(toolsRouter); router.use(ratingsRouter); +router.use(costsRouter); +router.use(adminRouter); router.use(analyticsRouter); router.use(usersRouter); router.use(auditRouter); diff --git a/artifacts/api-server/src/routes/ratings.ts b/artifacts/api-server/src/routes/ratings.ts index 6ef348a..c9731c4 100644 --- a/artifacts/api-server/src/routes/ratings.ts +++ b/artifacts/api-server/src/routes/ratings.ts @@ -1,5 +1,5 @@ import { Router, type IRouter } from "express"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { db, toolsTable, ratingsTable } from "@workspace/db"; import { ListToolRatingsParams, @@ -7,9 +7,16 @@ import { 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 => { const params = ListToolRatingsParams.safeParse(req.params); if (!params.success) { @@ -51,12 +58,26 @@ router.post("/tools/:id/ratings", requireAuth, async (req, res): Promise = 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); diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts index bc45b2d..6c1c74b 100644 --- a/artifacts/api-server/src/routes/tools.ts +++ b/artifacts/api-server/src/routes/tools.ts @@ -1,6 +1,6 @@ import { Router, type IRouter } from "express"; -import { eq, ilike, desc, sql } from "drizzle-orm"; -import { db, toolsTable, ratingsTable } from "@workspace/db"; +import { eq, ilike, desc, sql, and, not } from "drizzle-orm"; +import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db"; import { ListToolsQueryParams, CreateToolBody, @@ -10,6 +10,7 @@ import { DeleteToolParams, } from "@workspace/api-zod"; import { requireAuth } from "../middleware/auth"; +import { requireFeature } from "../middleware/feature"; import { writeAuditLog } from "../lib/audit"; const router: IRouter = Router(); @@ -242,4 +243,122 @@ router.get("/features/all", async (_req, res): Promise => { res.json([...featureSet].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 => { + 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(not(eq(toolsTable.id, toolId))); + + 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(); + 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 => { + 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 [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: relationType ?? "similar", + 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", requireAuth, async (req, res): Promise => { + 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; diff --git a/artifacts/api-server/src/types/session.d.ts b/artifacts/api-server/src/types/session.d.ts index b3954b9..c0ef2f7 100644 --- a/artifacts/api-server/src/types/session.d.ts +++ b/artifacts/api-server/src/types/session.d.ts @@ -8,6 +8,7 @@ declare module "express-session" { name?: string; preferred_username?: string; role?: "admin" | "user"; + tier?: "free" | "premium" | "enterprise"; isLocal?: boolean; }; codeVerifier?: string; diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx index a5b1259..5bcbe44 100644 --- a/artifacts/toolrate/src/App.tsx +++ b/artifacts/toolrate/src/App.tsx @@ -11,6 +11,7 @@ import ToolNew from "@/pages/tool-new"; import ToolEdit from "@/pages/tool-edit"; import Analytics from "@/pages/analytics"; import Admin from "@/pages/admin"; +import Redundancy from "@/pages/redundancy"; import Login from "@/pages/login"; import NotFound from "@/pages/not-found"; @@ -34,6 +35,7 @@ function Router() { + ); diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index cb23a50..6e4904d 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from "wouter"; -import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck } from "lucide-react"; +import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -14,6 +14,7 @@ export function Layout({ children }: { children: React.ReactNode }) { { href: "/tools/new", label: "Add Tool", icon: PlusCircle }, { href: "/analytics", label: "Analytics", icon: BarChart3 }, ...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []), + ...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []), ]; return ( diff --git a/artifacts/toolrate/src/hooks/use-auth.ts b/artifacts/toolrate/src/hooks/use-auth.ts index cdf9f76..63cbb9d 100644 --- a/artifacts/toolrate/src/hooks/use-auth.ts +++ b/artifacts/toolrate/src/hooks/use-auth.ts @@ -20,6 +20,7 @@ export function useAuth() { const isAuthenticated = !!user && !error; const isAdmin = isAuthenticated && user?.role === "admin"; const isLocalMode = authMode?.mode === "local"; + const tier = isAuthenticated ? user?.tier ?? "free" : "free"; function login(returnTo?: string) { if (isLocalMode) { @@ -45,6 +46,7 @@ export function useAuth() { isAuthenticated, isAdmin, isLocalMode, + tier, login, logout, }; diff --git a/artifacts/toolrate/src/pages/admin.tsx b/artifacts/toolrate/src/pages/admin.tsx index 07f2a20..c03255d 100644 --- a/artifacts/toolrate/src/pages/admin.tsx +++ b/artifacts/toolrate/src/pages/admin.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useLocation } from "wouter"; +import { useLocation, Link } from "wouter"; import { useListUsers, useCreateUser, @@ -22,7 +22,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Skeleton } from "@/components/ui/skeleton"; import { useToast } from "@/hooks/use-toast"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock } from "lucide-react"; +import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle } from "lucide-react"; import { format } from "date-fns"; export default function Admin() { @@ -130,8 +130,15 @@ export default function Admin() {
-

Admin Panel

-

Manage users and review system changes.

+
+
+

Admin Panel

+

Manage users and review system changes.

+
+ +
diff --git a/artifacts/toolrate/src/pages/redundancy.tsx b/artifacts/toolrate/src/pages/redundancy.tsx new file mode 100644 index 0000000..0633829 --- /dev/null +++ b/artifacts/toolrate/src/pages/redundancy.tsx @@ -0,0 +1,107 @@ +import { useState, useEffect } from "react"; +import { Layout } from "@/components/layout"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Progress } from "@/components/ui/progress"; +import { Star, AlertTriangle } from "lucide-react"; +import { Link } from "wouter"; +import { customFetch } from "@workspace/api-client-react"; + +export default function RedundancyPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + customFetch("/api/admin/redundancy") + .then(setData) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + return ( + +
+
+ +

Redundancy Dashboard

+
+

+ Tools grouped by category with feature overlap analysis. +

+ + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => )} +
+ ) : data && data.length > 0 ? ( +
+ {data.map((group) => ( +
+

{group.category}

+
+ {group.tools.map((tool: any) => ( + + + +
+
+ {tool.name} +
+ {tool.ratingCount} reviews + {tool.avgCombined != null && ( + <> + · + + + {tool.avgCombined.toFixed(1)} + + + )} +
+
+ {tool.features.length} features +
+
+
+ + ))} +
+ + {group.pairs.length > 0 && ( +
+

Overlap Analysis

+ {group.pairs.map((pair: any, i: number) => ( + + +
+ {pair.a.name} + vs + {pair.b.name} +
+
+
+ + {pair.overlap}% +
+ {pair.scoreDiff !== 0 && ( + 0 ? "default" : "secondary"} className="text-[10px]"> + {pair.scoreDiff > 0 ? `${pair.b.name} +${pair.scoreDiff.toFixed(1)}` : `${pair.a.name} +${Math.abs(pair.scoreDiff).toFixed(1)}`} + + )} +
+
+
+ ))} +
+ )} +
+ ))} +
+ ) : ( +

No tools found.

+ )} +
+
+ ); +} diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index dcfbbb0..fa64067 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRoute, useLocation } from "wouter"; import { useGetTool, @@ -25,9 +25,17 @@ import { Progress } from "@/components/ui/progress"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; -import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2 } from "lucide-react"; +import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon } from "lucide-react"; import { Link } from "wouter"; import { useAuth } from "@/hooks/use-auth"; import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react"; @@ -41,6 +49,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { customFetch } from "@workspace/api-client-react"; const ratingSchema = z.object({ usefulness: z.number().min(1).max(5), @@ -64,6 +73,55 @@ export default function ToolDetail() { const { user, isAdmin } = useAuth(); const deleteTool = useDeleteTool(); + const [similarData, setSimilarData] = useState<{ manual: any[]; auto: any[] } | null>(null); + const [similarLoading, setSimilarLoading] = useState(false); + const [linkDialogOpen, setLinkDialogOpen] = useState(false); + const [linkToolId, setLinkToolId] = useState(""); + const [linkType, setLinkType] = useState("similar"); + const [linkNotes, setLinkNotes] = useState(""); + + useEffect(() => { + if (!id) return; + setSimilarLoading(true); + customFetch(`/api/tools/${id}/similar`) + .then((r) => r.json()) + .then((data) => setSimilarData(data)) + .catch(() => {}) + .finally(() => setSimilarLoading(false)); + }, [id]); + + async function handleCreateRelation() { + if (!linkToolId) return; + try { + const res = await customFetch(`/api/tools/${id}/relations`, { + method: "POST", + body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }), + }); + if (!res.ok) { const e = await res.json(); throw new Error(e.error); } + toast({ title: "Relation created" }); + setLinkDialogOpen(false); + setLinkToolId(""); + setLinkNotes(""); + setLinkType("similar"); + const r = await customFetch(`/api/tools/${id}/similar`).then((r2) => r2.json()); + setSimilarData(r); + } catch (err: any) { + toast({ title: "Failed to create relation", description: err.message, variant: "destructive" }); + } + } + + async function handleDeleteRelation(relationId: number) { + try { + const res = await customFetch(`/api/tools/relations/${relationId}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete"); + toast({ title: "Relation deleted" }); + const r = await customFetch(`/api/tools/${id}/similar`).then((r2) => r2.json()); + setSimilarData(r); + } catch { + toast({ title: "Failed to delete relation", variant: "destructive" }); + } + } + const { data: tool, isLoading: loadingTool } = useGetTool(id, { query: { enabled: !!id, queryKey: getGetToolQueryKey(id) } }); @@ -273,6 +331,141 @@ export default function ToolDetail() {
Tool not found.
)} + {/* Similar Tools Section */} + {tool && ( +
+
+

Similar Tools

+ {isAdmin && ( + + )} +
+ + {similarLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => )} +
+ ) : similarData && (similarData.manual.length > 0 || similarData.auto.length > 0) ? ( +
+ {similarData.manual.map((item: any) => ( + + + +
+
+ {item.name} + + {item.relationType === "superseded_by" ? "replaces" : item.relationType} + +
+
+ {item.category} + {item.avgCombined != null && ( + <> + · + + + {item.avgCombined.toFixed(1)} + + + )} +
+ {item.notes &&

{item.notes}

} +
+ {isAdmin && ( + + )} +
+
+ + ))} + {similarData.auto.map((item: any) => ( + + + +
+ {item.name} +
+ {item.category} + {item.avgCombined != null && ( + <> + · + + + {item.avgCombined.toFixed(1)} + + + )} + · + Score: {item.score} +
+
+
+
+ + ))} +
+ ) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? ( +

No similar tools found.

+ ) : null} +
+ )} + + {/* Link Tool Dialog */} + + + + Link Similar Tool + Manually link this tool to another tool. + +
+
+ + setLinkToolId(e.target.value)} + /> +
+
+ + +
+
+ +