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 = () => {