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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user