feat: trash (soft delete) with admin tool management
Build & Push Docker Image / build (push) Successful in 2m15s

- tools: add deletedAt/deletedBy, soft delete via DELETE /tools/:id when
  actor has trash entitlement, else immediate hard delete
- trash endpoints: GET /tools/trash, POST /tools/trash (admin bulk),
  POST /tools/trash/restore, DELETE /tools/trash, POST /tools/trash/empty
- trash feature for premium/enterprise; exclude trashed from all public
  surfaces (browse, categories, features, tags, similar, ratings, costs,
  analytics, redundancy)
- TRASH_RETENTION_DAYS env (0 = keep forever) with hourly purge job
- frontend: /trash page (premium+, restore for all, permanent delete +
  empty for admin), admin Tools tab with multi-select bulk trash,
  sidebar Trash link, tool-detail delete hint
This commit is contained in:
opencode
2026-08-02 02:02:16 +02:00
parent 32df998399
commit 2f20b1e5c2
25 changed files with 1404 additions and 28 deletions
@@ -130,6 +130,10 @@ export interface Tool {
tags?: string[];
createdAt: string;
updatedAt: string;
/** @nullable */
deletedAt?: string | null;
/** @nullable */
deletedBy?: string | null;
}
export interface ToolWithStats {
@@ -182,6 +186,14 @@ export interface ToolUpdate {
tags?: string[];
}
export interface TrashToolsInput {
/**
* @minItems 1
* @maxItems 500
*/
ids: number[];
}
export interface Rating {
id: number;
toolId: number;
@@ -306,6 +318,22 @@ export const ListToolsSort = {
most_reviewed: 'most_reviewed',
} as const;
export type ListTrashedToolsParams = {
search?: string;
};
export type TrashTools200 = {
trashed?: number;
};
export type RestoreTools200 = {
restored?: number;
};
export type EmptyTrash200 = {
deleted?: number;
};
export type GetTopToolsParams = {
limit?: number;
metric?: GetTopToolsMetric;
+372
View File
@@ -25,21 +25,26 @@ import type {
AuthMode,
AuthUser,
CategoryStats,
EmptyTrash200,
ErrorResponse,
GetRatingDistributionParams,
GetTopToolsParams,
HealthStatus,
ListAuditLogsParams,
ListToolsParams,
ListTrashedToolsParams,
LocalLoginInput,
Rating,
RatingDistribution,
RatingInput,
RestoreTools200,
Tool,
ToolInput,
ToolUpdate,
ToolWithStats,
TopToolEntry,
TrashTools200,
TrashToolsInput,
User,
UserCreateInput,
UserRoleUpdate
@@ -509,6 +514,373 @@ export const useDeleteTool = <TError = ErrorType<ErrorResponse>,
return useMutation(getDeleteToolMutationOptions(options));
}
export const getListTrashedToolsUrl = (params?: ListTrashedToolsParams,) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString())
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0 ? `/api/tools/trash?${stringifiedParams}` : `/api/tools/trash`
}
/**
* @summary List trashed (soft-deleted) tools
*/
export const listTrashedTools = async (params?: ListTrashedToolsParams, options?: RequestInit): Promise<Tool[]> => {
return customFetch<Tool[]>(getListTrashedToolsUrl(params),
{
...options,
method: 'GET'
}
);}
export const getListTrashedToolsQueryKey = (params?: ListTrashedToolsParams,) => {
return [
`/api/tools/trash`, ...(params ? [params] : [])
] as const;
}
export const getListTrashedToolsQueryOptions = <TData = Awaited<ReturnType<typeof listTrashedTools>>, TError = ErrorType<ErrorResponse>>(params?: ListTrashedToolsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listTrashedTools>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListTrashedToolsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listTrashedTools>>> = ({ signal }) => listTrashedTools(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listTrashedTools>>, TError, TData> & { queryKey: QueryKey }
}
export type ListTrashedToolsQueryResult = NonNullable<Awaited<ReturnType<typeof listTrashedTools>>>
export type ListTrashedToolsQueryError = ErrorType<ErrorResponse>
/**
* @summary List trashed (soft-deleted) tools
*/
export function useListTrashedTools<TData = Awaited<ReturnType<typeof listTrashedTools>>, TError = ErrorType<ErrorResponse>>(
params?: ListTrashedToolsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listTrashedTools>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListTrashedToolsQueryOptions(params,options)
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey };
}
export const getTrashToolsUrl = () => {
return `/api/tools/trash`
}
/**
* @summary Move tools to trash (admin)
*/
export const trashTools = async (trashToolsInput: TrashToolsInput, options?: RequestInit): Promise<TrashTools200> => {
return customFetch<TrashTools200>(getTrashToolsUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
trashToolsInput,)
}
);}
export const getTrashToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext> => {
const mutationKey = ['trashTools'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof trashTools>>, {data: BodyType<TrashToolsInput>}> = (props) => {
const {data} = props ?? {};
return trashTools(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type TrashToolsMutationResult = NonNullable<Awaited<ReturnType<typeof trashTools>>>
export type TrashToolsMutationBody = BodyType<TrashToolsInput>
export type TrashToolsMutationError = ErrorType<ErrorResponse>
/**
* @summary Move tools to trash (admin)
*/
export const useTrashTools = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationResult<
Awaited<ReturnType<typeof trashTools>>,
TError,
{data: BodyType<TrashToolsInput>},
TContext
> => {
return useMutation(getTrashToolsMutationOptions(options));
}
export const getDeleteTrashedToolsUrl = () => {
return `/api/tools/trash`
}
/**
* @summary Permanently delete trashed tools (admin)
*/
export const deleteTrashedTools = async (trashToolsInput: TrashToolsInput, options?: RequestInit): Promise<void> => {
return customFetch<void>(getDeleteTrashedToolsUrl(),
{
...options,
method: 'DELETE',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
trashToolsInput,)
}
);}
export const getDeleteTrashedToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext> => {
const mutationKey = ['deleteTrashedTools'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteTrashedTools>>, {data: BodyType<TrashToolsInput>}> = (props) => {
const {data} = props ?? {};
return deleteTrashedTools(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type DeleteTrashedToolsMutationResult = NonNullable<Awaited<ReturnType<typeof deleteTrashedTools>>>
export type DeleteTrashedToolsMutationBody = BodyType<TrashToolsInput>
export type DeleteTrashedToolsMutationError = ErrorType<ErrorResponse>
/**
* @summary Permanently delete trashed tools (admin)
*/
export const useDeleteTrashedTools = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationResult<
Awaited<ReturnType<typeof deleteTrashedTools>>,
TError,
{data: BodyType<TrashToolsInput>},
TContext
> => {
return useMutation(getDeleteTrashedToolsMutationOptions(options));
}
export const getRestoreToolsUrl = () => {
return `/api/tools/trash/restore`
}
/**
* @summary Restore trashed tools
*/
export const restoreTools = async (trashToolsInput: TrashToolsInput, options?: RequestInit): Promise<RestoreTools200> => {
return customFetch<RestoreTools200>(getRestoreToolsUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
trashToolsInput,)
}
);}
export const getRestoreToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext> => {
const mutationKey = ['restoreTools'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof restoreTools>>, {data: BodyType<TrashToolsInput>}> = (props) => {
const {data} = props ?? {};
return restoreTools(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type RestoreToolsMutationResult = NonNullable<Awaited<ReturnType<typeof restoreTools>>>
export type RestoreToolsMutationBody = BodyType<TrashToolsInput>
export type RestoreToolsMutationError = ErrorType<ErrorResponse>
/**
* @summary Restore trashed tools
*/
export const useRestoreTools = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationResult<
Awaited<ReturnType<typeof restoreTools>>,
TError,
{data: BodyType<TrashToolsInput>},
TContext
> => {
return useMutation(getRestoreToolsMutationOptions(options));
}
export const getEmptyTrashUrl = () => {
return `/api/tools/trash/empty`
}
/**
* @summary Permanently delete all trashed tools (admin)
*/
export const emptyTrash = async ( options?: RequestInit): Promise<EmptyTrash200> => {
return customFetch<EmptyTrash200>(getEmptyTrashUrl(),
{
...options,
method: 'POST'
}
);}
export const getEmptyTrashMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext> => {
const mutationKey = ['emptyTrash'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof emptyTrash>>, void> = () => {
return emptyTrash(requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type EmptyTrashMutationResult = NonNullable<Awaited<ReturnType<typeof emptyTrash>>>
export type EmptyTrashMutationError = ErrorType<ErrorResponse>
/**
* @summary Permanently delete all trashed tools (admin)
*/
export const useEmptyTrash = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationResult<
Awaited<ReturnType<typeof emptyTrash>>,
TError,
void,
TContext
> => {
return useMutation(getEmptyTrashMutationOptions(options));
}
export const getListToolRatingsUrl = (id: number,) => {