feat: tiered costs feature + admin tier management + tag selection
Build & Push Docker Image / build (push) Successful in 6m52s
Build & Push Docker Image / build (push) Successful in 6m52s
- costs: nullable notes (fix create without notes), drop renewalDate (schema + API + UI), gate POST/PATCH/DELETE to admin + costs feature - feature middleware: admin-aware hasFeature + getEntitlements union; /auth/me and login return resolved entitlements - users: tier enum (free/premium/enterprise) in create/update/list, admin UI tier select + tier badge - tags: GET /tags/all, TagInput autocomplete in new/edit tool forms, feature suggestions on focus, query invalidation on create/update - openapi: nullable ToolUpdate urls, ToolUpdate tier fields, listAllTags - Dockerfile: push-force to drop renewal_date column
This commit is contained in:
@@ -6,9 +6,19 @@ const TIER_FEATURES: Record<string, string[]> = {
|
||||
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 getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
||||
if (role === "admin") {
|
||||
const all = new Set<string>();
|
||||
for (const features of Object.values(TIER_FEATURES)) {
|
||||
for (const f of features) all.add(f);
|
||||
}
|
||||
return [...all];
|
||||
}
|
||||
return TIER_FEATURES[tier ?? "free"] ?? TIER_FEATURES.free;
|
||||
}
|
||||
|
||||
export function hasFeature(tier: string | undefined, feature: string, role?: string): boolean {
|
||||
return getEntitlements(tier, role).includes(feature);
|
||||
}
|
||||
|
||||
export function requireFeature(feature: string) {
|
||||
@@ -17,7 +27,7 @@ export function requireFeature(feature: string) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
if (!hasFeature(req.session.user.tier, feature)) {
|
||||
if (!hasFeature(req.session.user.tier, feature, req.session.user.role)) {
|
||||
res.status(403).json({ error: `Feature "${feature}" requires a higher tier` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, usersTable } from "@workspace/db";
|
||||
import { logger } from "../lib/logger";
|
||||
import { getEntitlements } from "../middleware/feature";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -155,6 +156,7 @@ router.post("/auth/login", async (req, res): Promise<void> => {
|
||||
preferredUsername: user.username,
|
||||
role: user.role,
|
||||
tier: user.tier,
|
||||
entitlements: getEntitlements(user.tier, user.role),
|
||||
isLocal: true,
|
||||
});
|
||||
});
|
||||
@@ -271,6 +273,7 @@ router.get("/auth/me", async (req, res): Promise<void> => {
|
||||
preferredUsername: u.preferred_username ?? null,
|
||||
role: u.role ?? "user",
|
||||
tier: u.tier ?? "free",
|
||||
entitlements: getEntitlements(u.tier, u.role),
|
||||
isLocal: u.isLocal ?? false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,7 @@ const CostCreateBody = z.object({
|
||||
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(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const CostUpdateBody = CostCreateBody.partial().extend({
|
||||
@@ -25,7 +24,6 @@ const CostUpdateBody = CostCreateBody.partial().extend({
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -45,7 +43,7 @@ router.get("/tools/:id/costs", async (req, res): Promise<void> => {
|
||||
res.json(costs);
|
||||
});
|
||||
|
||||
router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req, res): Promise<void> => {
|
||||
router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), requireAdmin, async (req, res): Promise<void> => {
|
||||
const toolId = Number(req.params.id);
|
||||
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||
|
||||
@@ -57,7 +55,7 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = parsed.data;
|
||||
const { licenseType, billingPeriod, cost, currency, notes } = parsed.data;
|
||||
|
||||
const [entry] = await db.insert(toolCostsTable).values({
|
||||
toolId,
|
||||
@@ -65,7 +63,6 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
||||
billingPeriod: billingPeriod ?? null,
|
||||
cost: cost != null ? String(cost) : null,
|
||||
currency: currency ?? "EUR",
|
||||
renewalDate: renewalDate ?? null,
|
||||
notes: notes ?? null,
|
||||
createdBy: Number(req.session.user!.sub),
|
||||
}).returning();
|
||||
@@ -74,7 +71,7 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
||||
res.status(201).json(entry);
|
||||
});
|
||||
|
||||
router.patch("/costs/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
router.patch("/costs/:id", requireAuth, requireFeature("costs"), requireAdmin, async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||
|
||||
@@ -86,13 +83,12 @@ router.patch("/costs/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = parsed.data;
|
||||
const { licenseType, billingPeriod, cost, currency, 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 != null ? String(cost) : null;
|
||||
if (currency !== undefined) updateData.currency = currency;
|
||||
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();
|
||||
@@ -100,7 +96,7 @@ router.patch("/costs/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete("/costs/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
router.delete("/costs/:id", requireAuth, requireFeature("costs"), requireAdmin, async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||
|
||||
|
||||
@@ -253,6 +253,17 @@ router.get("/features/all", async (_req, res): Promise<void> => {
|
||||
res.json([...featureSet].sort());
|
||||
});
|
||||
|
||||
router.get("/tags/all", async (_req, res): Promise<void> => {
|
||||
const tools = await db.select({ tags: toolsTable.tags }).from(toolsTable);
|
||||
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(
|
||||
|
||||
@@ -8,15 +8,19 @@ import { z } from "zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
const Tier = z.enum(["free", "premium", "enterprise"]);
|
||||
|
||||
const UserCreateSchema = z.object({
|
||||
username: z.string().min(2),
|
||||
password: z.string().min(6),
|
||||
email: z.string().optional(),
|
||||
role: z.enum(["admin", "user"]).optional().default("user"),
|
||||
tier: Tier.optional().default("free"),
|
||||
});
|
||||
|
||||
const UserRoleUpdateSchema = z.object({
|
||||
role: z.enum(["admin", "user"]),
|
||||
const UserUpdateSchema = z.object({
|
||||
role: z.enum(["admin", "user"]).optional(),
|
||||
tier: Tier.optional(),
|
||||
});
|
||||
|
||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
@@ -26,6 +30,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
username: usersTable.username,
|
||||
email: usersTable.email,
|
||||
role: usersTable.role,
|
||||
tier: usersTable.tier,
|
||||
createdAt: usersTable.createdAt,
|
||||
})
|
||||
.from(usersTable)
|
||||
@@ -59,16 +64,18 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
passwordHash,
|
||||
email: parsed.data.email ?? null,
|
||||
role: parsed.data.role ?? "user",
|
||||
tier: parsed.data.tier ?? "free",
|
||||
})
|
||||
.returning({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
email: usersTable.email,
|
||||
role: usersTable.role,
|
||||
tier: usersTable.tier,
|
||||
createdAt: usersTable.createdAt,
|
||||
});
|
||||
|
||||
await writeAuditLog(req, "user", user.id, "create", { username: user.username, role: user.role });
|
||||
await writeAuditLog(req, "user", user.id, "create", { username: user.username, role: user.role, tier: user.tier });
|
||||
res.status(201).json(user);
|
||||
});
|
||||
|
||||
@@ -79,7 +86,7 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = UserRoleUpdateSchema.safeParse(req.body);
|
||||
const parsed = UserUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
@@ -111,19 +118,24 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (parsed.data.role !== undefined) updateData.role = parsed.data.role;
|
||||
if (parsed.data.tier !== undefined) updateData.tier = parsed.data.tier;
|
||||
|
||||
const [user] = await db
|
||||
.update(usersTable)
|
||||
.set({ role: parsed.data.role })
|
||||
.set(updateData)
|
||||
.where(eq(usersTable.id, id))
|
||||
.returning({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
email: usersTable.email,
|
||||
role: usersTable.role,
|
||||
tier: usersTable.tier,
|
||||
createdAt: usersTable.createdAt,
|
||||
});
|
||||
|
||||
await writeAuditLog(req, "user", id, "update", { role: parsed.data.role });
|
||||
await writeAuditLog(req, "user", id, "update", updateData);
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user