diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index 86f89bb..422f074 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -149,6 +149,20 @@ async function ensureVoterTokenColumn(): Promise { } } +async function ensureBillingPeriodColumn(): Promise { + try { + const exists = await db.execute( + sql`SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name = 'tool_costs' AND column_name = 'billing_period')`, + ); + const rows = exists.rows as [{ exists: boolean }]; + if (rows[0]?.exists) return; + await db.execute(sql`ALTER TABLE "tool_costs" ADD COLUMN "billing_period" text`); + logger.info("billing_period column added to tool_costs"); + } catch (err) { + logger.error({ err }, "Failed to add billing_period column"); + } +} + async function ensureAdminTier(): Promise { try { const result = await db @@ -169,6 +183,7 @@ async function start(): Promise { await ensureToolRelationsTable(); await ensureToolCostsTable(); await ensureVoterTokenColumn(); + await ensureBillingPeriodColumn(); await ensureUserColumns(); await seedAdminUser(); await ensureAdminTier(); diff --git a/artifacts/api-server/src/routes/admin.ts b/artifacts/api-server/src/routes/admin.ts index 9c6877b..c9fcf25 100644 --- a/artifacts/api-server/src/routes/admin.ts +++ b/artifacts/api-server/src/routes/admin.ts @@ -1,6 +1,6 @@ import { Router, type IRouter } from "express"; import { sql } from "drizzle-orm"; -import { db, toolsTable, ratingsTable } from "@workspace/db"; +import { db, toolsTable, ratingsTable, toolCostsTable } from "@workspace/db"; import { requireAuth } from "../middleware/auth"; const router: IRouter = Router(); @@ -12,6 +12,10 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise => .select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) .from(ratingsTable); + const allCosts = await db + .select() + .from(toolCostsTable); + const ratingsByTool = new Map(); for (const r of allRatings) { const arr = ratingsByTool.get(r.toolId) ?? []; @@ -19,16 +23,34 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise => ratingsByTool.set(r.toolId, arr); } + const costsByTool = new Map(); + for (const c of allCosts) { + const arr = costsByTool.get(c.toolId) ?? []; + arr.push(c); + costsByTool.set(c.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; + const costs = costsByTool.get(t.id) ?? []; + const totalMonthly = costs.reduce((sum, c) => { + if (c.licenseType === "free" || c.cost == null) return sum; + const amount = Number(c.cost); + if (c.licenseType === "one_time") return sum; + if (c.billingPeriod === "yearly") return sum + amount / 12; + if (c.billingPeriod === "quarterly") return sum + amount / 3; + return sum + amount; // monthly or subscription default + }, 0); 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, + costs, + totalMonthly, }; } @@ -40,7 +62,7 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise => } const result = Object.entries(grouped).map(([category, items]) => { - const pairs: { a: any; b: any; overlap: number; scoreDiff: number }[] = []; + const pairs: { a: any; b: any; overlap: number; scoreDiff: number; costDiff: 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); @@ -49,11 +71,13 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise => 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 }); + const costDiff = (items[j].totalMonthly ?? 0) - (items[i].totalMonthly ?? 0); + pairs.push({ a: items[i], b: items[j], overlap, scoreDiff, costDiff }); } } 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) }; + const totalMonthlyCost = items.reduce((s, t) => s + (t.totalMonthly ?? 0), 0); + return { category, tools: items, pairs: pairs.slice(0, 5), totalMonthlyCost }; }); res.json(result); diff --git a/artifacts/api-server/src/routes/costs.ts b/artifacts/api-server/src/routes/costs.ts index 0f99de7..a26ee4c 100644 --- a/artifacts/api-server/src/routes/costs.ts +++ b/artifacts/api-server/src/routes/costs.ts @@ -30,11 +30,12 @@ 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, cost, currency, renewalDate, notes } = req.body; + const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = req.body; const [entry] = await db.insert(toolCostsTable).values({ toolId, licenseType: licenseType ?? "free", + billingPeriod: billingPeriod ?? null, cost: cost ?? null, currency: currency ?? "EUR", renewalDate: renewalDate ? new Date(renewalDate) : null, @@ -53,9 +54,10 @@ router.patch("/costs/:id", requireAuth, async (req, res): Promise => { 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 { licenseType, billingPeriod, cost, currency, renewalDate, notes } = req.body; const updateData: Record = {}; if (licenseType !== undefined) updateData.licenseType = licenseType; + if (billingPeriod !== undefined) updateData.billingPeriod = billingPeriod; if (cost !== undefined) updateData.cost = cost; if (currency !== undefined) updateData.currency = currency; if (renewalDate !== undefined) updateData.renewalDate = renewalDate ? new Date(renewalDate) : null; diff --git a/artifacts/toolrate/src/pages/redundancy.tsx b/artifacts/toolrate/src/pages/redundancy.tsx index 0633829..53f2922 100644 --- a/artifacts/toolrate/src/pages/redundancy.tsx +++ b/artifacts/toolrate/src/pages/redundancy.tsx @@ -4,7 +4,7 @@ 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 { Star, AlertTriangle, DollarSign } from "lucide-react"; import { Link } from "wouter"; import { customFetch } from "@workspace/api-client-react"; @@ -38,16 +38,24 @@ export default function RedundancyPage() {
{data.map((group) => (
-

{group.category}

+
+

{group.category}

+ {group.totalMonthlyCost > 0 && ( + + + {group.totalMonthlyCost.toFixed(2)}/mo total + + )} +
{group.tools.map((tool: any) => ( -
-
+
+
{tool.name} -
+
{tool.ratingCount} reviews {tool.avgCombined != null && ( <> @@ -58,9 +66,27 @@ export default function RedundancyPage() { )} + {tool.totalMonthly > 0 && ( + <> + ยท + + + {tool.totalMonthly.toFixed(2)}/mo + + + )}
+ {tool.costs?.length > 0 && ( +
+ {tool.costs.map((c: any, i: number) => ( + + {c.licenseType}{c.billingPeriod ? ` (${c.billingPeriod})` : ""} + + ))} +
+ )}
- {tool.features.length} features + {tool.features.length} features
@@ -79,7 +105,7 @@ export default function RedundancyPage() { vs {pair.b.name}
-
+
{pair.overlap}% @@ -89,6 +115,13 @@ export default function RedundancyPage() { {pair.scoreDiff > 0 ? `${pair.b.name} +${pair.scoreDiff.toFixed(1)}` : `${pair.a.name} +${Math.abs(pair.scoreDiff).toFixed(1)}`} )} + {pair.costDiff !== 0 && ( + + {pair.costDiff > 0 + ? `${pair.b.name} +${pair.costDiff.toFixed(2)}/mo` + : `${pair.a.name} +${Math.abs(pair.costDiff).toFixed(2)}/mo`} + + )}
diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index 925f604..4a7eb5a 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -84,6 +84,7 @@ export default function ToolDetail() { const [costDialogOpen, setCostDialogOpen] = useState(false); const [editCost, setEditCost] = useState(null); const [costLicenseType, setCostLicenseType] = useState("subscription"); + const [costBillingPeriod, setCostBillingPeriod] = useState("monthly"); const [costAmount, setCostAmount] = useState(""); const [costCurrency, setCostCurrency] = useState("EUR"); const [costRenewal, setCostRenewal] = useState(""); @@ -140,6 +141,7 @@ export default function ToolDetail() { const method = editCost ? "PATCH" : "POST"; const body: Record = { licenseType: costLicenseType, + billingPeriod: costLicenseType === "subscription" ? costBillingPeriod : null, currency: costCurrency, notes: costNotes || null, }; @@ -159,6 +161,7 @@ export default function ToolDetail() { function resetCostForm() { setEditCost(null); setCostLicenseType("subscription"); + setCostBillingPeriod("monthly"); setCostAmount(""); setCostCurrency("EUR"); setCostRenewal(""); @@ -168,6 +171,7 @@ export default function ToolDetail() { function openEditCost(c: any) { setEditCost(c); setCostLicenseType(c.licenseType); + setCostBillingPeriod(c.billingPeriod ?? "monthly"); setCostAmount(c.cost ?? ""); setCostCurrency(c.currency ?? "EUR"); setCostRenewal(c.renewalDate ?? ""); @@ -550,7 +554,10 @@ export default function ToolDetail() {
- {c.licenseType} +
+ {c.licenseType} + {c.billingPeriod && {c.billingPeriod}} +
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
@@ -602,6 +609,19 @@ export default function ToolDetail() {
+ {costLicenseType === "subscription" && ( +
+ + +
+ )}
diff --git a/lib/db/src/schema/tool-costs.ts b/lib/db/src/schema/tool-costs.ts index 252b850..2a8cde4 100644 --- a/lib/db/src/schema/tool-costs.ts +++ b/lib/db/src/schema/tool-costs.ts @@ -6,6 +6,7 @@ export const toolCostsTable = pgTable("tool_costs", { id: serial("id").primaryKey(), toolId: integer("tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }), licenseType: text("license_type", { enum: ["free", "subscription", "one_time", "usage_based"] }).notNull().default("free"), + billingPeriod: text("billing_period", { enum: ["monthly", "quarterly", "yearly"] }), cost: numeric("cost", { precision: 10, scale: 2 }), currency: text("currency").default("EUR"), renewalDate: timestamp("renewal_date", { withTimezone: true }),