feat: tiered costs feature + admin tier management + tag selection
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:
opencode
2026-08-02 01:10:47 +02:00
parent cd4efd16f6
commit 01c70085db
27 changed files with 478 additions and 86 deletions
+18 -6
View File
@@ -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);
});