From 01c70085dbfc94e17088d04c7754c339482a36f7 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 2 Aug 2026 01:10:47 +0200 Subject: [PATCH] feat: tiered costs feature + admin tier management + tag selection - 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 --- Dockerfile | 2 +- .../api-server/src/middleware/feature.ts | 18 +++- artifacts/api-server/src/routes/auth.ts | 3 + artifacts/api-server/src/routes/costs.ts | 16 ++-- artifacts/api-server/src/routes/tools.ts | 11 +++ artifacts/api-server/src/routes/users.ts | 24 ++++-- .../toolrate/src/components/feature-input.tsx | 15 ++-- .../toolrate/src/components/tag-input.tsx | 83 +++++++++++++++++++ artifacts/toolrate/src/pages/admin.tsx | 80 +++++++++++++----- artifacts/toolrate/src/pages/tool-detail.tsx | 13 --- artifacts/toolrate/src/pages/tool-edit.tsx | 16 +++- artifacts/toolrate/src/pages/tool-new.tsx | 15 ++-- .../src/generated/api.schemas.ts | 46 +++++++++- lib/api-client-react/src/generated/api.ts | 77 +++++++++++++++++ lib/api-spec/openapi.yaml | 36 +++++++- lib/api-zod/src/generated/api.ts | 23 ++++- lib/api-zod/src/generated/types/authUser.ts | 3 + .../src/generated/types/authUserTier.ts | 16 ++++ lib/api-zod/src/generated/types/index.ts | 4 + lib/api-zod/src/generated/types/toolUpdate.ts | 6 +- lib/api-zod/src/generated/types/user.ts | 2 + .../src/generated/types/userCreateInput.ts | 2 + .../generated/types/userCreateInputTier.ts | 16 ++++ .../src/generated/types/userRoleUpdate.ts | 4 +- .../src/generated/types/userRoleUpdateTier.ts | 16 ++++ lib/api-zod/src/generated/types/userTier.ts | 16 ++++ lib/db/src/schema/tool-costs.ts | 1 - 27 files changed, 478 insertions(+), 86 deletions(-) create mode 100644 artifacts/toolrate/src/components/tag-input.tsx create mode 100644 lib/api-zod/src/generated/types/authUserTier.ts create mode 100644 lib/api-zod/src/generated/types/userCreateInputTier.ts create mode 100644 lib/api-zod/src/generated/types/userRoleUpdateTier.ts create mode 100644 lib/api-zod/src/generated/types/userTier.ts diff --git a/Dockerfile b/Dockerfile index ae1a4ec..fe4ddab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,4 +45,4 @@ ENV STATIC_DIR=/app/artifacts/toolrate/dist/public EXPOSE 8080 -CMD ["sh", "-c", "pnpm --filter @workspace/db run push && node --enable-source-maps artifacts/api-server/dist/index.mjs"] +CMD ["sh", "-c", "pnpm --filter @workspace/db run push-force && node --enable-source-maps artifacts/api-server/dist/index.mjs"] diff --git a/artifacts/api-server/src/middleware/feature.ts b/artifacts/api-server/src/middleware/feature.ts index 5783841..12a15f0 100644 --- a/artifacts/api-server/src/middleware/feature.ts +++ b/artifacts/api-server/src/middleware/feature.ts @@ -6,9 +6,19 @@ const TIER_FEATURES: Record = { 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(); + 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; } diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index 68f9b22..4cc4fa4 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -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 => { 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 => { preferredUsername: u.preferred_username ?? null, role: u.role ?? "user", tier: u.tier ?? "free", + entitlements: getEntitlements(u.tier, u.role), isLocal: u.isLocal ?? false, }); }); diff --git a/artifacts/api-server/src/routes/costs.ts b/artifacts/api-server/src/routes/costs.ts index d1ab441..6eac8dd 100644 --- a/artifacts/api-server/src/routes/costs.ts +++ b/artifacts/api-server/src/routes/costs.ts @@ -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 => { res.json(costs); }); -router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req, res): Promise => { +router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), requireAdmin, async (req, res): Promise => { 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 => { +router.patch("/costs/:id", requireAuth, requireFeature("costs"), requireAdmin, async (req, res): Promise => { 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 => { 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 = {}; 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 => { res.json(updated); }); -router.delete("/costs/:id", requireAdmin, async (req, res): Promise => { +router.delete("/costs/:id", requireAuth, requireFeature("costs"), requireAdmin, async (req, res): Promise => { const id = Number(req.params.id); if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; } diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts index 78fc9de..912412e 100644 --- a/artifacts/api-server/src/routes/tools.ts +++ b/artifacts/api-server/src/routes/tools.ts @@ -253,6 +253,17 @@ router.get("/features/all", async (_req, res): Promise => { res.json([...featureSet].sort()); }); +router.get("/tags/all", async (_req, res): Promise => { + const tools = await db.select({ tags: toolsTable.tags }).from(toolsTable); + const tagSet = new Set(); + 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( diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index 8176c1f..4fb0f12 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -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 => { @@ -26,6 +30,7 @@ router.get("/users", requireAdmin, async (req, res): Promise => { 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 => { 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 => { 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 => { } } + const updateData: Record = {}; + 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); }); diff --git a/artifacts/toolrate/src/components/feature-input.tsx b/artifacts/toolrate/src/components/feature-input.tsx index 575065d..4ebdda8 100644 --- a/artifacts/toolrate/src/components/feature-input.tsx +++ b/artifacts/toolrate/src/components/feature-input.tsx @@ -23,13 +23,14 @@ export function FeatureInput({ value, onChange, placeholder, "data-testid": test const known: string[] = allFeatures.data ?? []; - const suggestions = value.trim().length >= 1 - ? known.filter( - (f) => - f.toLowerCase().includes(value.toLowerCase()) && - f.toLowerCase() !== value.toLowerCase() - ).slice(0, 6) - : []; + const q = value.trim(); + const suggestions = known + .filter( + (f) => + q.length === 0 || + (f.toLowerCase().includes(q.toLowerCase()) && f.toLowerCase() !== q.toLowerCase()), + ) + .slice(0, 6); useEffect(() => { function handleClickOutside(e: MouseEvent) { diff --git a/artifacts/toolrate/src/components/tag-input.tsx b/artifacts/toolrate/src/components/tag-input.tsx new file mode 100644 index 0000000..97b8f6e --- /dev/null +++ b/artifacts/toolrate/src/components/tag-input.tsx @@ -0,0 +1,83 @@ +import { useState, useRef, useEffect } from "react"; +import { Input } from "@/components/ui/input"; +import { useListAllTags, getListAllTagsQueryKey } from "@workspace/api-client-react"; +import { cn } from "@/lib/utils"; + +interface TagInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + "data-testid"?: string; +} + +export function TagInput({ value, onChange, placeholder, className, "data-testid": testId }: TagInputProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const allTags = useListAllTags({ + query: { + queryKey: getListAllTagsQueryKey(), + refetchOnMount: "always", + staleTime: 0, + }, + }); + + const known: string[] = allTags.data ?? []; + + const q = value.trim(); + const suggestions = known + .filter( + (t) => + q.length === 0 || + (t.toLowerCase().includes(q.toLowerCase()) && t.toLowerCase() !== q.toLowerCase()), + ) + .slice(0, 6); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ { + onChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + placeholder={placeholder} + className={className} + data-testid={testId} + autoComplete="off" + /> + {open && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} +
+ ); +} diff --git a/artifacts/toolrate/src/pages/admin.tsx b/artifacts/toolrate/src/pages/admin.tsx index c03255d..3effb14 100644 --- a/artifacts/toolrate/src/pages/admin.tsx +++ b/artifacts/toolrate/src/pages/admin.tsx @@ -32,13 +32,14 @@ export default function Admin() { const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); - const [editUser, setEditUser] = useState<{ id: number; username: string; role: string } | null>(null); + const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string } | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null); const [newUsername, setNewUsername] = useState(""); const [newPassword, setNewPassword] = useState(""); const [newEmail, setNewEmail] = useState(""); const [newRole, setNewRole] = useState<"admin" | "user">("user"); + const [newTier, setNewTier] = useState<"free" | "premium" | "enterprise">("free"); const { data: users, isLoading: loadingUsers } = useListUsers({ query: { queryKey: getListUsersQueryKey(), enabled: isAdmin }, @@ -68,7 +69,7 @@ export default function Admin() { const handleCreateUser = () => { if (!newUsername || !newPassword) return; createUser.mutate( - { data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole } }, + { data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } }, { onSuccess: () => { toast({ title: "User created", description: `${newUsername} has been created.` }); @@ -86,18 +87,18 @@ export default function Admin() { ); }; - const handleUpdateRole = (role: "admin" | "user") => { + const handleUpdateUser = () => { if (!editUser) return; updateUser.mutate( - { id: editUser.id, data: { role } }, + { id: editUser.id, data: { role: editUser.role as "admin" | "user", tier: editUser.tier as "free" | "premium" | "enterprise" } }, { onSuccess: () => { - toast({ title: "Role updated" }); + toast({ title: "User updated" }); queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() }); setEditUser(null); }, onError: (err) => { - toast({ title: "Failed to update role", description: err.data?.error ?? err.message, variant: "destructive" }); + toast({ title: "Failed to update user", description: err.data?.error ?? err.message, variant: "destructive" }); }, }, ); @@ -184,13 +185,16 @@ export default function Admin() { {u.role} + + {u.tier ?? "free"} + {u.username !== user?.preferredUsername && ( <> @@ -295,6 +299,19 @@ export default function Admin() { +
+ + +
@@ -308,26 +325,45 @@ export default function Admin() { !open && setEditUser(null)}> - Change Role — {editUser?.username} + Edit User — {editUser?.username} -
- +
+
+ + +
+
+ + +
{isAdmin && ( @@ -641,10 +632,6 @@ export default function ToolDetail() { -
- - setCostRenewal(e.target.value)} /> -