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
@@ -34,12 +34,22 @@ export const UserRole = {
user: 'user',
} as const;
export type UserTier = typeof UserTier[keyof typeof UserTier];
export const UserTier = {
free: 'free',
premium: 'premium',
enterprise: 'enterprise',
} as const;
export interface User {
id: number;
username: string;
/** @nullable */
email?: string | null;
role: UserRole;
tier?: UserTier;
createdAt: string;
}
@@ -51,6 +61,15 @@ export const UserCreateInputRole = {
user: 'user',
} as const;
export type UserCreateInputTier = typeof UserCreateInputTier[keyof typeof UserCreateInputTier];
export const UserCreateInputTier = {
free: 'free',
premium: 'premium',
enterprise: 'enterprise',
} as const;
export interface UserCreateInput {
/** @minLength 2 */
username: string;
@@ -58,6 +77,7 @@ export interface UserCreateInput {
password: string;
email?: string;
role?: UserCreateInputRole;
tier?: UserCreateInputTier;
}
export type UserRoleUpdateRole = typeof UserRoleUpdateRole[keyof typeof UserRoleUpdateRole];
@@ -68,8 +88,18 @@ export const UserRoleUpdateRole = {
user: 'user',
} as const;
export type UserRoleUpdateTier = typeof UserRoleUpdateTier[keyof typeof UserRoleUpdateTier];
export const UserRoleUpdateTier = {
free: 'free',
premium: 'premium',
enterprise: 'enterprise',
} as const;
export interface UserRoleUpdate {
role: UserRoleUpdateRole;
role?: UserRoleUpdateRole;
tier?: UserRoleUpdateTier;
}
export interface AuditLog {
@@ -144,7 +174,9 @@ export interface ToolUpdate {
name?: string;
description?: string;
category?: string;
/** @nullable */
websiteUrl?: string | null;
/** @nullable */
iconUrl?: string | null;
features?: string[];
tags?: string[];
@@ -232,6 +264,15 @@ export const AuthUserRole = {
user: 'user',
} as const;
export type AuthUserTier = typeof AuthUserTier[keyof typeof AuthUserTier];
export const AuthUserTier = {
free: 'free',
premium: 'premium',
enterprise: 'enterprise',
} as const;
export interface AuthUser {
sub: string;
/** @nullable */
@@ -241,7 +282,8 @@ export interface AuthUser {
/** @nullable */
preferredUsername?: string | null;
role?: AuthUserRole;
tier?: "free" | "premium" | "enterprise";
tier?: AuthUserTier;
entitlements?: string[];
isLocal?: boolean;
}
+77
View File
@@ -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 = () => {
+33 -3
View File
@@ -325,6 +325,21 @@ paths:
items:
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:
get:
operationId: getAuthMode
@@ -553,6 +568,9 @@ components:
role:
type: string
enum: [admin, user]
tier:
type: string
enum: [free, premium, enterprise]
createdAt:
type: string
format: date-time
@@ -572,14 +590,19 @@ components:
role:
type: string
enum: [admin, user]
tier:
type: string
enum: [free, premium, enterprise]
UserRoleUpdate:
type: object
required: [role]
properties:
role:
type: string
enum: [admin, user]
tier:
type: string
enum: [free, premium, enterprise]
AuditLog:
type: object
@@ -714,9 +737,9 @@ components:
category:
type: string
websiteUrl:
type: string
type: ["string", "null"]
iconUrl:
type: string
type: ["string", "null"]
features:
type: array
items:
@@ -849,6 +872,13 @@ components:
role:
type: string
enum: [admin, user]
tier:
type: string
enum: [free, premium, enterprise]
entitlements:
type: array
items:
type: string
isLocal:
type: boolean
+19 -4
View File
@@ -105,8 +105,8 @@ export const UpdateToolBody = zod.object({
"name": zod.string().min(1).optional(),
"description": zod.string().optional(),
"category": zod.string().optional(),
"websiteUrl": zod.string().nullable().optional(),
"iconUrl": zod.string().nullable().optional(),
"websiteUrl": zod.string().nullish(),
"iconUrl": zod.string().nullish(),
"features": 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)
/**
* @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)
*/
@@ -310,6 +317,8 @@ export const LocalLoginResponse = zod.object({
"name": zod.string().nullish(),
"preferredUsername": zod.string().nullish(),
"role": zod.enum(['admin', 'user']).optional(),
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
"entitlements": zod.array(zod.string()).optional(),
"isLocal": zod.boolean().optional()
})
@@ -323,6 +332,8 @@ export const GetMeResponse = zod.object({
"name": zod.string().nullish(),
"preferredUsername": zod.string().nullish(),
"role": zod.enum(['admin', 'user']).optional(),
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
"entitlements": zod.array(zod.string()).optional(),
"isLocal": zod.boolean().optional()
})
@@ -335,6 +346,7 @@ export const ListUsersResponseItem = zod.object({
"username": zod.string(),
"email": zod.string().nullish(),
"role": zod.enum(['admin', 'user']),
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
"createdAt": zod.coerce.date()
})
export const ListUsersResponse = zod.array(ListUsersResponseItem)
@@ -353,7 +365,8 @@ export const CreateUserBody = zod.object({
"username": zod.string().min(createUserBodyUsernameMin),
"password": zod.string().min(createUserBodyPasswordMin),
"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({
"role": zod.enum(['admin', 'user'])
"role": zod.enum(['admin', 'user']).optional(),
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
})
export const UpdateUserResponse = zod.object({
@@ -373,6 +387,7 @@ export const UpdateUserResponse = zod.object({
"username": zod.string(),
"email": zod.string().nullish(),
"role": zod.enum(['admin', 'user']),
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
"createdAt": zod.coerce.date()
})
@@ -6,6 +6,7 @@
* OpenAPI spec version: 0.1.0
*/
import type { AuthUserRole } from './authUserRole';
import type { AuthUserTier } from './authUserTier';
export interface AuthUser {
sub: string;
@@ -16,5 +17,7 @@ export interface AuthUser {
/** @nullable */
preferredUsername?: string | null;
role?: AuthUserRole;
tier?: AuthUserTier;
entitlements?: string[];
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;
+4
View File
@@ -12,6 +12,7 @@ export * from './authMode';
export * from './authModeMode';
export * from './authUser';
export * from './authUserRole';
export * from './authUserTier';
export * from './categoryStats';
export * from './errorResponse';
export * from './getRatingDistributionParams';
@@ -34,6 +35,9 @@ export * from './topToolEntry';
export * from './user';
export * from './userCreateInput';
export * from './userCreateInputRole';
export * from './userCreateInputTier';
export * from './userRole';
export * from './userRoleUpdate';
export * from './userRoleUpdateRole';
export * from './userRoleUpdateTier';
export * from './userTier';
@@ -11,8 +11,10 @@ export interface ToolUpdate {
name?: string;
description?: string;
category?: string;
websiteUrl?: string;
iconUrl?: string;
/** @nullable */
websiteUrl?: string | null;
/** @nullable */
iconUrl?: string | null;
features?: string[];
tags?: string[];
}
+2
View File
@@ -6,6 +6,7 @@
* OpenAPI spec version: 0.1.0
*/
import type { UserRole } from './userRole';
import type { UserTier } from './userTier';
export interface User {
id: number;
@@ -13,5 +14,6 @@ export interface User {
/** @nullable */
email?: string | null;
role: UserRole;
tier?: UserTier;
createdAt: Date;
}
@@ -6,6 +6,7 @@
* OpenAPI spec version: 0.1.0
*/
import type { UserCreateInputRole } from './userCreateInputRole';
import type { UserCreateInputTier } from './userCreateInputTier';
export interface UserCreateInput {
/** @minLength 2 */
@@ -14,4 +15,5 @@ export interface UserCreateInput {
password: string;
email?: string;
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
*/
import type { UserRoleUpdateRole } from './userRoleUpdateRole';
import type { UserRoleUpdateTier } from './userRoleUpdateTier';
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;
-1
View File
@@ -9,7 +9,6 @@ export const toolCostsTable = pgTable("tool_costs", {
billingPeriod: text("billing_period", { enum: ["monthly", "quarterly", "yearly"] }),
cost: numeric("cost", { precision: 10, scale: 2 }),
currency: text("currency").default("EUR"),
renewalDate: timestamp("renewal_date", { withTimezone: true }),
notes: text("notes"),
createdBy: integer("created_by").references(() => usersTable.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),