feat: billing period for subscriptions, costs in redundancy analysis
This commit is contained in:
@@ -149,6 +149,20 @@ async function ensureVoterTokenColumn(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBillingPeriodColumn(): Promise<void> {
|
||||
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<void> {
|
||||
try {
|
||||
const result = await db
|
||||
@@ -169,6 +183,7 @@ async function start(): Promise<void> {
|
||||
await ensureToolRelationsTable();
|
||||
await ensureToolCostsTable();
|
||||
await ensureVoterTokenColumn();
|
||||
await ensureBillingPeriodColumn();
|
||||
await ensureUserColumns();
|
||||
await seedAdminUser();
|
||||
await ensureAdminTier();
|
||||
|
||||
@@ -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<void> =>
|
||||
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||
.from(ratingsTable);
|
||||
|
||||
const allCosts = await db
|
||||
.select()
|
||||
.from(toolCostsTable);
|
||||
|
||||
const ratingsByTool = new Map<number, { usefulness: number; usability: number }[]>();
|
||||
for (const r of allRatings) {
|
||||
const arr = ratingsByTool.get(r.toolId) ?? [];
|
||||
@@ -19,16 +23,34 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
||||
ratingsByTool.set(r.toolId, arr);
|
||||
}
|
||||
|
||||
const costsByTool = new Map<number, typeof allCosts>();
|
||||
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<void> =>
|
||||
}
|
||||
|
||||
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<void> =>
|
||||
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);
|
||||
|
||||
@@ -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<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, cost, currency, renewalDate, notes } = req.body;
|
||||
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = req.body;
|
||||
const updateData: Record<string, unknown> = {};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user