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:
+1
-1
@@ -45,4 +45,4 @@ ENV STATIC_DIR=/app/artifacts/toolrate/dist/public
|
|||||||
|
|
||||||
EXPOSE 8080
|
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"]
|
||||||
|
|||||||
@@ -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"],
|
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "sso", "audit-export", "api-access"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function hasFeature(tier: string | undefined, feature: string): boolean {
|
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
||||||
const features = TIER_FEATURES[tier ?? "free"] ?? TIER_FEATURES.free;
|
if (role === "admin") {
|
||||||
return features.includes(feature);
|
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) {
|
export function requireFeature(feature: string) {
|
||||||
@@ -17,7 +27,7 @@ export function requireFeature(feature: string) {
|
|||||||
res.status(401).json({ error: "Authentication required" });
|
res.status(401).json({ error: "Authentication required" });
|
||||||
return;
|
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` });
|
res.status(403).json({ error: `Feature "${feature}" requires a higher tier` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db, usersTable } from "@workspace/db";
|
import { db, usersTable } from "@workspace/db";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
|
import { getEntitlements } from "../middleware/feature";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -155,6 +156,7 @@ router.post("/auth/login", async (req, res): Promise<void> => {
|
|||||||
preferredUsername: user.username,
|
preferredUsername: user.username,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
tier: user.tier,
|
tier: user.tier,
|
||||||
|
entitlements: getEntitlements(user.tier, user.role),
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -271,6 +273,7 @@ router.get("/auth/me", async (req, res): Promise<void> => {
|
|||||||
preferredUsername: u.preferred_username ?? null,
|
preferredUsername: u.preferred_username ?? null,
|
||||||
role: u.role ?? "user",
|
role: u.role ?? "user",
|
||||||
tier: u.tier ?? "free",
|
tier: u.tier ?? "free",
|
||||||
|
entitlements: getEntitlements(u.tier, u.role),
|
||||||
isLocal: u.isLocal ?? false,
|
isLocal: u.isLocal ?? false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ const CostCreateBody = z.object({
|
|||||||
billingPeriod: BillingPeriod.nullable().optional(),
|
billingPeriod: BillingPeriod.nullable().optional(),
|
||||||
cost: z.coerce.number().finite().nonnegative().nullable().optional(),
|
cost: z.coerce.number().finite().nonnegative().nullable().optional(),
|
||||||
currency: z.string().min(1).max(10).optional(),
|
currency: z.string().min(1).max(10).optional(),
|
||||||
renewalDate: z.coerce.date().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
notes: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const CostUpdateBody = CostCreateBody.partial().extend({
|
const CostUpdateBody = CostCreateBody.partial().extend({
|
||||||
@@ -25,7 +24,6 @@ const CostUpdateBody = CostCreateBody.partial().extend({
|
|||||||
billingPeriod: BillingPeriod.nullable().optional(),
|
billingPeriod: BillingPeriod.nullable().optional(),
|
||||||
cost: z.coerce.number().finite().nonnegative().nullable().optional(),
|
cost: z.coerce.number().finite().nonnegative().nullable().optional(),
|
||||||
currency: z.string().min(1).max(10).optional(),
|
currency: z.string().min(1).max(10).optional(),
|
||||||
renewalDate: z.coerce.date().nullable().optional(),
|
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +43,7 @@ router.get("/tools/:id/costs", async (req, res): Promise<void> => {
|
|||||||
res.json(costs);
|
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);
|
const toolId = Number(req.params.id);
|
||||||
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
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 });
|
res.status(400).json({ error: parsed.error.message });
|
||||||
return;
|
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({
|
const [entry] = await db.insert(toolCostsTable).values({
|
||||||
toolId,
|
toolId,
|
||||||
@@ -65,7 +63,6 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
|||||||
billingPeriod: billingPeriod ?? null,
|
billingPeriod: billingPeriod ?? null,
|
||||||
cost: cost != null ? String(cost) : null,
|
cost: cost != null ? String(cost) : null,
|
||||||
currency: currency ?? "EUR",
|
currency: currency ?? "EUR",
|
||||||
renewalDate: renewalDate ?? null,
|
|
||||||
notes: notes ?? null,
|
notes: notes ?? null,
|
||||||
createdBy: Number(req.session.user!.sub),
|
createdBy: Number(req.session.user!.sub),
|
||||||
}).returning();
|
}).returning();
|
||||||
@@ -74,7 +71,7 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
|||||||
res.status(201).json(entry);
|
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);
|
const id = Number(req.params.id);
|
||||||
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
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 });
|
res.status(400).json({ error: parsed.error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = parsed.data;
|
const { licenseType, billingPeriod, cost, currency, notes } = parsed.data;
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (licenseType !== undefined) updateData.licenseType = licenseType;
|
if (licenseType !== undefined) updateData.licenseType = licenseType;
|
||||||
if (billingPeriod !== undefined) updateData.billingPeriod = billingPeriod;
|
if (billingPeriod !== undefined) updateData.billingPeriod = billingPeriod;
|
||||||
if (cost !== undefined) updateData.cost = cost != null ? String(cost) : null;
|
if (cost !== undefined) updateData.cost = cost != null ? String(cost) : null;
|
||||||
if (currency !== undefined) updateData.currency = currency;
|
if (currency !== undefined) updateData.currency = currency;
|
||||||
if (renewalDate !== undefined) updateData.renewalDate = renewalDate;
|
|
||||||
if (notes !== undefined) updateData.notes = notes;
|
if (notes !== undefined) updateData.notes = notes;
|
||||||
|
|
||||||
const [updated] = await db.update(toolCostsTable).set(updateData).where(eq(toolCostsTable.id, id)).returning();
|
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);
|
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);
|
const id = Number(req.params.id);
|
||||||
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
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());
|
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 ──────────────────────────────────────────
|
// ── Similar Tools ──────────────────────────────────────────
|
||||||
|
|
||||||
function computeSimilarityScore(
|
function computeSimilarityScore(
|
||||||
|
|||||||
@@ -8,15 +8,19 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
const Tier = z.enum(["free", "premium", "enterprise"]);
|
||||||
|
|
||||||
const UserCreateSchema = z.object({
|
const UserCreateSchema = z.object({
|
||||||
username: z.string().min(2),
|
username: z.string().min(2),
|
||||||
password: z.string().min(6),
|
password: z.string().min(6),
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
role: z.enum(["admin", "user"]).optional().default("user"),
|
role: z.enum(["admin", "user"]).optional().default("user"),
|
||||||
|
tier: Tier.optional().default("free"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const UserRoleUpdateSchema = z.object({
|
const UserUpdateSchema = z.object({
|
||||||
role: z.enum(["admin", "user"]),
|
role: z.enum(["admin", "user"]).optional(),
|
||||||
|
tier: Tier.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
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,
|
username: usersTable.username,
|
||||||
email: usersTable.email,
|
email: usersTable.email,
|
||||||
role: usersTable.role,
|
role: usersTable.role,
|
||||||
|
tier: usersTable.tier,
|
||||||
createdAt: usersTable.createdAt,
|
createdAt: usersTable.createdAt,
|
||||||
})
|
})
|
||||||
.from(usersTable)
|
.from(usersTable)
|
||||||
@@ -59,16 +64,18 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
email: parsed.data.email ?? null,
|
email: parsed.data.email ?? null,
|
||||||
role: parsed.data.role ?? "user",
|
role: parsed.data.role ?? "user",
|
||||||
|
tier: parsed.data.tier ?? "free",
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
id: usersTable.id,
|
id: usersTable.id,
|
||||||
username: usersTable.username,
|
username: usersTable.username,
|
||||||
email: usersTable.email,
|
email: usersTable.email,
|
||||||
role: usersTable.role,
|
role: usersTable.role,
|
||||||
|
tier: usersTable.tier,
|
||||||
createdAt: usersTable.createdAt,
|
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);
|
res.status(201).json(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +86,7 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = UserRoleUpdateSchema.safeParse(req.body);
|
const parsed = UserUpdateSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: parsed.error.message });
|
res.status(400).json({ error: parsed.error.message });
|
||||||
return;
|
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
|
const [user] = await db
|
||||||
.update(usersTable)
|
.update(usersTable)
|
||||||
.set({ role: parsed.data.role })
|
.set(updateData)
|
||||||
.where(eq(usersTable.id, id))
|
.where(eq(usersTable.id, id))
|
||||||
.returning({
|
.returning({
|
||||||
id: usersTable.id,
|
id: usersTable.id,
|
||||||
username: usersTable.username,
|
username: usersTable.username,
|
||||||
email: usersTable.email,
|
email: usersTable.email,
|
||||||
role: usersTable.role,
|
role: usersTable.role,
|
||||||
|
tier: usersTable.tier,
|
||||||
createdAt: usersTable.createdAt,
|
createdAt: usersTable.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeAuditLog(req, "user", id, "update", { role: parsed.data.role });
|
await writeAuditLog(req, "user", id, "update", updateData);
|
||||||
res.json(user);
|
res.json(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ export function FeatureInput({ value, onChange, placeholder, "data-testid": test
|
|||||||
|
|
||||||
const known: string[] = allFeatures.data ?? [];
|
const known: string[] = allFeatures.data ?? [];
|
||||||
|
|
||||||
const suggestions = value.trim().length >= 1
|
const q = value.trim();
|
||||||
? known.filter(
|
const suggestions = known
|
||||||
|
.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.toLowerCase().includes(value.toLowerCase()) &&
|
q.length === 0 ||
|
||||||
f.toLowerCase() !== value.toLowerCase()
|
(f.toLowerCase().includes(q.toLowerCase()) && f.toLowerCase() !== q.toLowerCase()),
|
||||||
).slice(0, 6)
|
)
|
||||||
: [];
|
.slice(0, 6);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div ref={containerRef} className="relative w-full">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={className}
|
||||||
|
data-testid={testId}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{open && suggestions.length > 0 && (
|
||||||
|
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover shadow-md text-sm overflow-hidden">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 hover:bg-muted transition-colors text-foreground",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange(s);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
data-testid={`suggestion-tag-${s}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,13 +32,14 @@ export default function Admin() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
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 [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
||||||
|
|
||||||
const [newUsername, setNewUsername] = useState("");
|
const [newUsername, setNewUsername] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
const [newRole, setNewRole] = useState<"admin" | "user">("user");
|
const [newRole, setNewRole] = useState<"admin" | "user">("user");
|
||||||
|
const [newTier, setNewTier] = useState<"free" | "premium" | "enterprise">("free");
|
||||||
|
|
||||||
const { data: users, isLoading: loadingUsers } = useListUsers({
|
const { data: users, isLoading: loadingUsers } = useListUsers({
|
||||||
query: { queryKey: getListUsersQueryKey(), enabled: isAdmin },
|
query: { queryKey: getListUsersQueryKey(), enabled: isAdmin },
|
||||||
@@ -68,7 +69,7 @@ export default function Admin() {
|
|||||||
const handleCreateUser = () => {
|
const handleCreateUser = () => {
|
||||||
if (!newUsername || !newPassword) return;
|
if (!newUsername || !newPassword) return;
|
||||||
createUser.mutate(
|
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: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "User created", description: `${newUsername} has been created.` });
|
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;
|
if (!editUser) return;
|
||||||
updateUser.mutate(
|
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: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "Role updated" });
|
toast({ title: "User updated" });
|
||||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
setEditUser(null);
|
setEditUser(null);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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() {
|
|||||||
<Badge variant={u.role === "admin" ? "default" : "secondary"}>
|
<Badge variant={u.role === "admin" ? "default" : "secondary"}>
|
||||||
{u.role}
|
{u.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{u.tier ?? "free"}
|
||||||
|
</Badge>
|
||||||
{u.username !== user?.preferredUsername && (
|
{u.username !== user?.preferredUsername && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role })}
|
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free" })}
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -295,6 +299,19 @@ export default function Admin() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Plan</Label>
|
||||||
|
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="free">Free</SelectItem>
|
||||||
|
<SelectItem value="premium">Premium</SelectItem>
|
||||||
|
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
@@ -308,9 +325,11 @@ export default function Admin() {
|
|||||||
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
|
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Change Role — {editUser?.username}</DialogTitle>
|
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-2">
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Role</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editUser?.role || "user"}
|
value={editUser?.role || "user"}
|
||||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
||||||
@@ -324,10 +343,27 @@ export default function Admin() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Plan</Label>
|
||||||
|
<Select
|
||||||
|
value={editUser?.tier || "free"}
|
||||||
|
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="free">Free</SelectItem>
|
||||||
|
<SelectItem value="premium">Premium</SelectItem>
|
||||||
|
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleUpdateRole(editUser?.role as "admin" | "user")}
|
onClick={handleUpdateUser}
|
||||||
disabled={updateUser.isPending}
|
disabled={updateUser.isPending}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ export default function ToolDetail() {
|
|||||||
const [costBillingPeriod, setCostBillingPeriod] = useState("monthly");
|
const [costBillingPeriod, setCostBillingPeriod] = useState("monthly");
|
||||||
const [costAmount, setCostAmount] = useState("");
|
const [costAmount, setCostAmount] = useState("");
|
||||||
const [costCurrency, setCostCurrency] = useState("EUR");
|
const [costCurrency, setCostCurrency] = useState("EUR");
|
||||||
const [costRenewal, setCostRenewal] = useState("");
|
|
||||||
const [costNotes, setCostNotes] = useState("");
|
const [costNotes, setCostNotes] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -150,7 +149,6 @@ export default function ToolDetail() {
|
|||||||
notes: costNotes || null,
|
notes: costNotes || null,
|
||||||
};
|
};
|
||||||
if (costAmount) body.cost = costAmount;
|
if (costAmount) body.cost = costAmount;
|
||||||
if (costRenewal) body.renewalDate = costRenewal;
|
|
||||||
try {
|
try {
|
||||||
await customFetch(url, { method, body: JSON.stringify(body) });
|
await customFetch(url, { method, body: JSON.stringify(body) });
|
||||||
toast({ title: editCost ? "Cost updated" : "Cost added" });
|
toast({ title: editCost ? "Cost updated" : "Cost added" });
|
||||||
@@ -168,7 +166,6 @@ export default function ToolDetail() {
|
|||||||
setCostBillingPeriod("monthly");
|
setCostBillingPeriod("monthly");
|
||||||
setCostAmount("");
|
setCostAmount("");
|
||||||
setCostCurrency("EUR");
|
setCostCurrency("EUR");
|
||||||
setCostRenewal("");
|
|
||||||
setCostNotes("");
|
setCostNotes("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +175,6 @@ export default function ToolDetail() {
|
|||||||
setCostBillingPeriod(c.billingPeriod ?? "monthly");
|
setCostBillingPeriod(c.billingPeriod ?? "monthly");
|
||||||
setCostAmount(c.cost ?? "");
|
setCostAmount(c.cost ?? "");
|
||||||
setCostCurrency(c.currency ?? "EUR");
|
setCostCurrency(c.currency ?? "EUR");
|
||||||
setCostRenewal(c.renewalDate ?? "");
|
|
||||||
setCostNotes(c.notes ?? "");
|
setCostNotes(c.notes ?? "");
|
||||||
setCostDialogOpen(true);
|
setCostDialogOpen(true);
|
||||||
}
|
}
|
||||||
@@ -562,11 +558,6 @@ export default function ToolDetail() {
|
|||||||
<div className="text-lg font-bold">
|
<div className="text-lg font-bold">
|
||||||
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
|
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
|
||||||
</div>
|
</div>
|
||||||
{c.renewalDate && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Renews: {new Date(c.renewalDate).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -641,10 +632,6 @@ export default function ToolDetail() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">Renewal Date</label>
|
|
||||||
<Input type="date" value={costRenewal} onChange={(e) => setCostRenewal(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Notes</label>
|
<label className="text-sm font-medium">Notes</label>
|
||||||
<Textarea placeholder="Billing details, contract info..." value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
|
<Textarea placeholder="Billing details, contract info..." value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getListToolsQueryKey,
|
getListToolsQueryKey,
|
||||||
getListCategoriesQueryKey,
|
getListCategoriesQueryKey,
|
||||||
getListAllFeaturesQueryKey,
|
getListAllFeaturesQueryKey,
|
||||||
|
getListAllTagsQueryKey,
|
||||||
getGetTopToolsQueryKey,
|
getGetTopToolsQueryKey,
|
||||||
getGetAnalyticsSummaryQueryKey,
|
getGetAnalyticsSummaryQueryKey,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
@@ -27,6 +28,7 @@ import { Pencil, Plus, X, ArrowLeft } from "lucide-react";
|
|||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { CategoryCombobox } from "@/components/category-combobox";
|
import { CategoryCombobox } from "@/components/category-combobox";
|
||||||
import { FeatureInput } from "@/components/feature-input";
|
import { FeatureInput } from "@/components/feature-input";
|
||||||
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
const toolSchema = z.object({
|
const toolSchema = z.object({
|
||||||
@@ -109,6 +111,7 @@ export default function ToolEdit() {
|
|||||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListAllTagsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
||||||
setLocation(`/tools/${id}`);
|
setLocation(`/tools/${id}`);
|
||||||
@@ -260,7 +263,7 @@ export default function ToolEdit() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Features</h3>
|
<h3 className="text-lg font-medium">Features</h3>
|
||||||
<p className="text-sm text-muted-foreground">Key capabilities of this tool.</p>
|
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
||||||
@@ -304,7 +307,7 @@ export default function ToolEdit() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Tags</h3>
|
<h3 className="text-lg font-medium">Tags</h3>
|
||||||
<p className="text-sm text-muted-foreground">Keywords for this tool.</p>
|
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
||||||
@@ -317,9 +320,14 @@ export default function ToolEdit() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name={`tags.${index}.value`}
|
name={`tags.${index}.value`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center space-y-0 relative w-[150px]">
|
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Tag" className="pr-8 h-9 text-sm" {...field} />
|
<TagInput
|
||||||
|
placeholder="Tag"
|
||||||
|
className="pr-8 h-9 text-sm"
|
||||||
|
onChange={field.onChange}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useLocation } from "wouter";
|
|||||||
import { useForm, useFieldArray } from "react-hook-form";
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useCreateTool, getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey, getGetTopToolsQueryKey, getGetAnalyticsSummaryQueryKey } from "@workspace/api-client-react";
|
import { useCreateTool, getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey, getListAllTagsQueryKey, getGetTopToolsQueryKey, getGetAnalyticsSummaryQueryKey } from "@workspace/api-client-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
@@ -16,6 +16,7 @@ import { Wrench, Plus, X, ArrowLeft, LogIn } from "lucide-react";
|
|||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { CategoryCombobox } from "@/components/category-combobox";
|
import { CategoryCombobox } from "@/components/category-combobox";
|
||||||
import { FeatureInput } from "@/components/feature-input";
|
import { FeatureInput } from "@/components/feature-input";
|
||||||
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
const toolSchema = z.object({
|
const toolSchema = z.object({
|
||||||
@@ -77,6 +78,7 @@ export default function ToolNew() {
|
|||||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListAllTagsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
|
||||||
setLocation(`/tools/${newTool.id}`);
|
setLocation(`/tools/${newTool.id}`);
|
||||||
@@ -230,7 +232,7 @@ export default function ToolNew() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Features</h3>
|
<h3 className="text-lg font-medium">Features</h3>
|
||||||
<p className="text-sm text-muted-foreground">List key capabilities. Start typing to see suggestions from existing tools.</p>
|
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -283,7 +285,7 @@ export default function ToolNew() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Tags</h3>
|
<h3 className="text-lg font-medium">Tags</h3>
|
||||||
<p className="text-sm text-muted-foreground">Keywords to help find this tool.</p>
|
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -303,12 +305,13 @@ export default function ToolNew() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name={`tags.${index}.value`}
|
name={`tags.${index}.value`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center space-y-0 relative w-[150px]">
|
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<TagInput
|
||||||
placeholder="Tag"
|
placeholder="Tag"
|
||||||
className="pr-8 h-9 text-sm"
|
className="pr-8 h-9 text-sm"
|
||||||
{...field}
|
onChange={field.onChange}
|
||||||
|
value={field.value ?? ""}
|
||||||
data-testid={`input-tag-${index}`}
|
data-testid={`input-tag-${index}`}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -34,12 +34,22 @@ export const UserRole = {
|
|||||||
user: 'user',
|
user: 'user',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type UserTier = typeof UserTier[keyof typeof UserTier];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserTier = {
|
||||||
|
free: 'free',
|
||||||
|
premium: 'premium',
|
||||||
|
enterprise: 'enterprise',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
tier?: UserTier;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +61,15 @@ export const UserCreateInputRole = {
|
|||||||
user: 'user',
|
user: 'user',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type UserCreateInputTier = typeof UserCreateInputTier[keyof typeof UserCreateInputTier];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserCreateInputTier = {
|
||||||
|
free: 'free',
|
||||||
|
premium: 'premium',
|
||||||
|
enterprise: 'enterprise',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface UserCreateInput {
|
export interface UserCreateInput {
|
||||||
/** @minLength 2 */
|
/** @minLength 2 */
|
||||||
username: string;
|
username: string;
|
||||||
@@ -58,6 +77,7 @@ export interface UserCreateInput {
|
|||||||
password: string;
|
password: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
role?: UserCreateInputRole;
|
role?: UserCreateInputRole;
|
||||||
|
tier?: UserCreateInputTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRoleUpdateRole = typeof UserRoleUpdateRole[keyof typeof UserRoleUpdateRole];
|
export type UserRoleUpdateRole = typeof UserRoleUpdateRole[keyof typeof UserRoleUpdateRole];
|
||||||
@@ -68,8 +88,18 @@ export const UserRoleUpdateRole = {
|
|||||||
user: 'user',
|
user: 'user',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type UserRoleUpdateTier = typeof UserRoleUpdateTier[keyof typeof UserRoleUpdateTier];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserRoleUpdateTier = {
|
||||||
|
free: 'free',
|
||||||
|
premium: 'premium',
|
||||||
|
enterprise: 'enterprise',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface UserRoleUpdate {
|
export interface UserRoleUpdate {
|
||||||
role: UserRoleUpdateRole;
|
role?: UserRoleUpdateRole;
|
||||||
|
tier?: UserRoleUpdateTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
@@ -144,7 +174,9 @@ export interface ToolUpdate {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
/** @nullable */
|
||||||
websiteUrl?: string | null;
|
websiteUrl?: string | null;
|
||||||
|
/** @nullable */
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
features?: string[];
|
features?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
@@ -232,6 +264,15 @@ export const AuthUserRole = {
|
|||||||
user: 'user',
|
user: 'user',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type AuthUserTier = typeof AuthUserTier[keyof typeof AuthUserTier];
|
||||||
|
|
||||||
|
|
||||||
|
export const AuthUserTier = {
|
||||||
|
free: 'free',
|
||||||
|
premium: 'premium',
|
||||||
|
enterprise: 'enterprise',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
sub: string;
|
sub: string;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
@@ -241,7 +282,8 @@ export interface AuthUser {
|
|||||||
/** @nullable */
|
/** @nullable */
|
||||||
preferredUsername?: string | null;
|
preferredUsername?: string | null;
|
||||||
role?: AuthUserRole;
|
role?: AuthUserRole;
|
||||||
tier?: "free" | "premium" | "enterprise";
|
tier?: AuthUserTier;
|
||||||
|
entitlements?: string[];
|
||||||
isLocal?: boolean;
|
isLocal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1134,6 +1134,83 @@ export function useListAllFeatures<TData = Awaited<ReturnType<typeof listAllFeat
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListAllTagsUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/tags/all`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List all distinct tag strings across all tools
|
||||||
|
*/
|
||||||
|
export const listAllTags = async ( options?: RequestInit): Promise<string[]> => {
|
||||||
|
|
||||||
|
return customFetch<string[]>(getListAllTagsUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListAllTagsQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/api/tags/all`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListAllTagsQueryOptions = <TData = Awaited<ReturnType<typeof listAllTags>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listAllTags>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListAllTagsQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listAllTags>>> = ({ signal }) => listAllTags({ signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listAllTags>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListAllTagsQueryResult = NonNullable<Awaited<ReturnType<typeof listAllTags>>>
|
||||||
|
export type ListAllTagsQueryError = ErrorType<unknown>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List all distinct tag strings across all tools
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListAllTags<TData = Awaited<ReturnType<typeof listAllTags>>, TError = ErrorType<unknown>>(
|
||||||
|
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listAllTags>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getListAllTagsQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getGetAuthModeUrl = () => {
|
export const getGetAuthModeUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,21 @@ paths:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
/tags/all:
|
||||||
|
get:
|
||||||
|
operationId: listAllTags
|
||||||
|
tags: [tools]
|
||||||
|
summary: List all distinct tag strings across all tools
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: All known tags
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
|
||||||
/auth/mode:
|
/auth/mode:
|
||||||
get:
|
get:
|
||||||
operationId: getAuthMode
|
operationId: getAuthMode
|
||||||
@@ -553,6 +568,9 @@ components:
|
|||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
enum: [admin, user]
|
enum: [admin, user]
|
||||||
|
tier:
|
||||||
|
type: string
|
||||||
|
enum: [free, premium, enterprise]
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -572,14 +590,19 @@ components:
|
|||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
enum: [admin, user]
|
enum: [admin, user]
|
||||||
|
tier:
|
||||||
|
type: string
|
||||||
|
enum: [free, premium, enterprise]
|
||||||
|
|
||||||
UserRoleUpdate:
|
UserRoleUpdate:
|
||||||
type: object
|
type: object
|
||||||
required: [role]
|
|
||||||
properties:
|
properties:
|
||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
enum: [admin, user]
|
enum: [admin, user]
|
||||||
|
tier:
|
||||||
|
type: string
|
||||||
|
enum: [free, premium, enterprise]
|
||||||
|
|
||||||
AuditLog:
|
AuditLog:
|
||||||
type: object
|
type: object
|
||||||
@@ -714,9 +737,9 @@ components:
|
|||||||
category:
|
category:
|
||||||
type: string
|
type: string
|
||||||
websiteUrl:
|
websiteUrl:
|
||||||
type: string
|
type: ["string", "null"]
|
||||||
iconUrl:
|
iconUrl:
|
||||||
type: string
|
type: ["string", "null"]
|
||||||
features:
|
features:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -849,6 +872,13 @@ components:
|
|||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
enum: [admin, user]
|
enum: [admin, user]
|
||||||
|
tier:
|
||||||
|
type: string
|
||||||
|
enum: [free, premium, enterprise]
|
||||||
|
entitlements:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
isLocal:
|
isLocal:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ export const UpdateToolBody = zod.object({
|
|||||||
"name": zod.string().min(1).optional(),
|
"name": zod.string().min(1).optional(),
|
||||||
"description": zod.string().optional(),
|
"description": zod.string().optional(),
|
||||||
"category": zod.string().optional(),
|
"category": zod.string().optional(),
|
||||||
"websiteUrl": zod.string().nullable().optional(),
|
"websiteUrl": zod.string().nullish(),
|
||||||
"iconUrl": zod.string().nullable().optional(),
|
"iconUrl": zod.string().nullish(),
|
||||||
"features": zod.array(zod.string()).optional(),
|
"features": zod.array(zod.string()).optional(),
|
||||||
"tags": zod.array(zod.string()).optional()
|
"tags": zod.array(zod.string()).optional()
|
||||||
})
|
})
|
||||||
@@ -288,6 +288,13 @@ export const ListAllFeaturesResponseItem = zod.string()
|
|||||||
export const ListAllFeaturesResponse = zod.array(ListAllFeaturesResponseItem)
|
export const ListAllFeaturesResponse = zod.array(ListAllFeaturesResponseItem)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List all distinct tag strings across all tools
|
||||||
|
*/
|
||||||
|
export const ListAllTagsResponseItem = zod.string()
|
||||||
|
export const ListAllTagsResponse = zod.array(ListAllTagsResponseItem)
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get authentication mode (oidc or local)
|
* @summary Get authentication mode (oidc or local)
|
||||||
*/
|
*/
|
||||||
@@ -310,6 +317,8 @@ export const LocalLoginResponse = zod.object({
|
|||||||
"name": zod.string().nullish(),
|
"name": zod.string().nullish(),
|
||||||
"preferredUsername": zod.string().nullish(),
|
"preferredUsername": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']).optional(),
|
"role": zod.enum(['admin', 'user']).optional(),
|
||||||
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"entitlements": zod.array(zod.string()).optional(),
|
||||||
"isLocal": zod.boolean().optional()
|
"isLocal": zod.boolean().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -323,6 +332,8 @@ export const GetMeResponse = zod.object({
|
|||||||
"name": zod.string().nullish(),
|
"name": zod.string().nullish(),
|
||||||
"preferredUsername": zod.string().nullish(),
|
"preferredUsername": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']).optional(),
|
"role": zod.enum(['admin', 'user']).optional(),
|
||||||
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"entitlements": zod.array(zod.string()).optional(),
|
||||||
"isLocal": zod.boolean().optional()
|
"isLocal": zod.boolean().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -335,6 +346,7 @@ export const ListUsersResponseItem = zod.object({
|
|||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
"email": zod.string().nullish(),
|
"email": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']),
|
"role": zod.enum(['admin', 'user']),
|
||||||
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
})
|
})
|
||||||
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
||||||
@@ -353,7 +365,8 @@ export const CreateUserBody = zod.object({
|
|||||||
"username": zod.string().min(createUserBodyUsernameMin),
|
"username": zod.string().min(createUserBodyUsernameMin),
|
||||||
"password": zod.string().min(createUserBodyPasswordMin),
|
"password": zod.string().min(createUserBodyPasswordMin),
|
||||||
"email": zod.string().optional(),
|
"email": zod.string().optional(),
|
||||||
"role": zod.enum(['admin', 'user']).optional()
|
"role": zod.enum(['admin', 'user']).optional(),
|
||||||
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -365,7 +378,8 @@ export const UpdateUserParams = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const UpdateUserBody = zod.object({
|
export const UpdateUserBody = zod.object({
|
||||||
"role": zod.enum(['admin', 'user'])
|
"role": zod.enum(['admin', 'user']).optional(),
|
||||||
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const UpdateUserResponse = zod.object({
|
export const UpdateUserResponse = zod.object({
|
||||||
@@ -373,6 +387,7 @@ export const UpdateUserResponse = zod.object({
|
|||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
"email": zod.string().nullish(),
|
"email": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']),
|
"role": zod.enum(['admin', 'user']),
|
||||||
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { AuthUserRole } from './authUserRole';
|
import type { AuthUserRole } from './authUserRole';
|
||||||
|
import type { AuthUserTier } from './authUserTier';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -16,5 +17,7 @@ export interface AuthUser {
|
|||||||
/** @nullable */
|
/** @nullable */
|
||||||
preferredUsername?: string | null;
|
preferredUsername?: string | null;
|
||||||
role?: AuthUserRole;
|
role?: AuthUserRole;
|
||||||
|
tier?: AuthUserTier;
|
||||||
|
entitlements?: string[];
|
||||||
isLocal?: boolean;
|
isLocal?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AuthUserTier = typeof AuthUserTier[keyof typeof AuthUserTier];
|
||||||
|
|
||||||
|
|
||||||
|
export const AuthUserTier = {
|
||||||
|
free: 'free',
|
||||||
|
premium: 'premium',
|
||||||
|
enterprise: 'enterprise',
|
||||||
|
} as const;
|
||||||
@@ -12,6 +12,7 @@ export * from './authMode';
|
|||||||
export * from './authModeMode';
|
export * from './authModeMode';
|
||||||
export * from './authUser';
|
export * from './authUser';
|
||||||
export * from './authUserRole';
|
export * from './authUserRole';
|
||||||
|
export * from './authUserTier';
|
||||||
export * from './categoryStats';
|
export * from './categoryStats';
|
||||||
export * from './errorResponse';
|
export * from './errorResponse';
|
||||||
export * from './getRatingDistributionParams';
|
export * from './getRatingDistributionParams';
|
||||||
@@ -34,6 +35,9 @@ export * from './topToolEntry';
|
|||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './userCreateInput';
|
export * from './userCreateInput';
|
||||||
export * from './userCreateInputRole';
|
export * from './userCreateInputRole';
|
||||||
|
export * from './userCreateInputTier';
|
||||||
export * from './userRole';
|
export * from './userRole';
|
||||||
export * from './userRoleUpdate';
|
export * from './userRoleUpdate';
|
||||||
export * from './userRoleUpdateRole';
|
export * from './userRoleUpdateRole';
|
||||||
|
export * from './userRoleUpdateTier';
|
||||||
|
export * from './userTier';
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ export interface ToolUpdate {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
websiteUrl?: string;
|
/** @nullable */
|
||||||
iconUrl?: string;
|
websiteUrl?: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
iconUrl?: string | null;
|
||||||
features?: string[];
|
features?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { UserRole } from './userRole';
|
import type { UserRole } from './userRole';
|
||||||
|
import type { UserTier } from './userTier';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,5 +14,6 @@ export interface User {
|
|||||||
/** @nullable */
|
/** @nullable */
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
tier?: UserTier;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { UserCreateInputRole } from './userCreateInputRole';
|
import type { UserCreateInputRole } from './userCreateInputRole';
|
||||||
|
import type { UserCreateInputTier } from './userCreateInputTier';
|
||||||
|
|
||||||
export interface UserCreateInput {
|
export interface UserCreateInput {
|
||||||
/** @minLength 2 */
|
/** @minLength 2 */
|
||||||
@@ -14,4 +15,5 @@ export interface UserCreateInput {
|
|||||||
password: string;
|
password: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
role?: UserCreateInputRole;
|
role?: UserCreateInputRole;
|
||||||
|
tier?: UserCreateInputTier;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UserCreateInputTier = typeof UserCreateInputTier[keyof typeof UserCreateInputTier];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserCreateInputTier = {
|
||||||
|
free: 'free',
|
||||||
|
premium: 'premium',
|
||||||
|
enterprise: 'enterprise',
|
||||||
|
} as const;
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { UserRoleUpdateRole } from './userRoleUpdateRole';
|
import type { UserRoleUpdateRole } from './userRoleUpdateRole';
|
||||||
|
import type { UserRoleUpdateTier } from './userRoleUpdateTier';
|
||||||
|
|
||||||
export interface UserRoleUpdate {
|
export interface UserRoleUpdate {
|
||||||
role: UserRoleUpdateRole;
|
role?: UserRoleUpdateRole;
|
||||||
|
tier?: UserRoleUpdateTier;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UserRoleUpdateTier = typeof UserRoleUpdateTier[keyof typeof UserRoleUpdateTier];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserRoleUpdateTier = {
|
||||||
|
free: 'free',
|
||||||
|
premium: 'premium',
|
||||||
|
enterprise: 'enterprise',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UserTier = typeof UserTier[keyof typeof UserTier];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserTier = {
|
||||||
|
free: 'free',
|
||||||
|
premium: 'premium',
|
||||||
|
enterprise: 'enterprise',
|
||||||
|
} as const;
|
||||||
@@ -9,7 +9,6 @@ export const toolCostsTable = pgTable("tool_costs", {
|
|||||||
billingPeriod: text("billing_period", { enum: ["monthly", "quarterly", "yearly"] }),
|
billingPeriod: text("billing_period", { enum: ["monthly", "quarterly", "yearly"] }),
|
||||||
cost: numeric("cost", { precision: 10, scale: 2 }),
|
cost: numeric("cost", { precision: 10, scale: 2 }),
|
||||||
currency: text("currency").default("EUR"),
|
currency: text("currency").default("EUR"),
|
||||||
renewalDate: timestamp("renewal_date", { withTimezone: true }),
|
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
createdBy: integer("created_by").references(() => usersTable.id, { onDelete: "set null" }),
|
createdBy: integer("created_by").references(() => usersTable.id, { onDelete: "set null" }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
Reference in New Issue
Block a user