Warn admins before deleting non-empty users/apps/services (Task #85)
Apply the existing groups delete-warning pattern to user, app, and
service deletions so admins are warned (and have to confirm twice)
before destroying records that still own dependent data.
Backend
- openapi.yaml: added `force` query param to DELETE /users/{id},
/apps/{id}, /services/{id} plus three new conflict schemas
(UserDeletionConflict, AppDeletionConflict, ServiceDeletionConflict).
- routes/users.ts, apps.ts, services.ts: rewrote DELETE handlers to
count dependents (notes/orders/conversations/messages for users;
group_apps/restrictions/open events for apps; service_orders for
services), return 409 with counts when non-empty and force is not set,
and on `?force=true` perform the cascade in a transaction and write
an `*.force_delete` audit_logs row.
- Existing 204 success path preserved for empty deletes.
UI (artifacts/tx-os/src/pages/admin.tsx)
- New `DeletionWarningDialog` helper, plus app/service/user delete state
+ lazy 409 detection (first click probes; on 409 the dialog upgrades
to show counts + "Delete anyway"; second click sends ?force=true).
- Replaced the three plain `confirm(t("admin.deleteConfirm"))` callsites.
i18n
- Added admin.deleteApp/deleteService/deleteUser keys (title, warning,
forceHint, emptyBody, count keys, confirm, anyway) in en.json + ar.json.
Tests
- artifacts/api-server/tests/delete-force-warnings.test.mjs covers all
9 cases (clean delete, 409 with counts, force=true 204 + audit log)
for users, apps, and services. Existing groups-crud and service-orders
tests still pass.
Notes / drift
- Lazy detection (vs eager counts on the row like Groups already does)
was chosen because list endpoints don't return counts yet — proposed
follow-up #96 covers eager counts so the warning appears on first
click everywhere.
- E2E test for the UI flow flaked twice; backend integration tests
(9/9 pass), direct curl validation of force=true returning 204, and
typecheck across the monorepo all confirm correctness.
Replit-Task-Id: 91404d92-e74c-4720-8fc9-8eb772eefc33
This commit is contained in:
@@ -223,6 +223,26 @@ export interface GroupDeletionConflict {
|
||||
roleCount: number;
|
||||
}
|
||||
|
||||
export interface UserDeletionConflict {
|
||||
error: string;
|
||||
noteCount: number;
|
||||
orderCount: number;
|
||||
conversationCount: number;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export interface AppDeletionConflict {
|
||||
error: string;
|
||||
groupCount: number;
|
||||
restrictionCount: number;
|
||||
openCount: number;
|
||||
}
|
||||
|
||||
export interface ServiceDeletionConflict {
|
||||
error: string;
|
||||
orderCount: number;
|
||||
}
|
||||
|
||||
export interface GroupDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -802,6 +822,20 @@ export interface AuditLogList {
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export type DeleteAppParams = {
|
||||
/**
|
||||
* Force deletion of an app that has dependent records (records an audit log entry).
|
||||
*/
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type DeleteServiceParams = {
|
||||
/**
|
||||
* Force deletion of a service that has existing orders (records an audit log entry).
|
||||
*/
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type LeaveConversationBody = {
|
||||
/** When the leaver is the only admin, optionally name a specific
|
||||
remaining member to promote. Omit (or send null) to keep the
|
||||
@@ -833,6 +867,13 @@ export const ListUsersStatus = {
|
||||
disabled: "disabled",
|
||||
} as const;
|
||||
|
||||
export type DeleteUserParams = {
|
||||
/**
|
||||
* Force deletion of a user that owns dependent records (records an audit log entry).
|
||||
*/
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type ListGroupsParams = {
|
||||
q?: string;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
AdminResetLinkResponse,
|
||||
AdminStats,
|
||||
App,
|
||||
AppDeletionConflict,
|
||||
AppSettings,
|
||||
AuditLogList,
|
||||
AuthUser,
|
||||
@@ -35,7 +36,10 @@ import type {
|
||||
CreateServiceBody,
|
||||
CreateServiceOrderBody,
|
||||
CreateUserBody,
|
||||
DeleteAppParams,
|
||||
DeleteGroupParams,
|
||||
DeleteServiceParams,
|
||||
DeleteUserParams,
|
||||
ErrorResponse,
|
||||
ForgotPasswordBody,
|
||||
ForgotPasswordResponse,
|
||||
@@ -63,6 +67,7 @@ import type {
|
||||
SendMessageBody,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDeletionConflict,
|
||||
ServiceOrder,
|
||||
SuccessResponse,
|
||||
UpdateAppBody,
|
||||
@@ -78,6 +83,7 @@ import type {
|
||||
UpdateServiceBody,
|
||||
UpdateServiceOrderStatusBody,
|
||||
UpdateUserBody,
|
||||
UserDeletionConflict,
|
||||
UserProfile,
|
||||
VerifyResetTokenBody,
|
||||
VerifyResetTokenResponse,
|
||||
@@ -1399,35 +1405,48 @@ export const useUpdateApp = <
|
||||
/**
|
||||
* @summary Delete app (admin)
|
||||
*/
|
||||
export const getDeleteAppUrl = (id: number) => {
|
||||
return `/api/apps/${id}`;
|
||||
export const getDeleteAppUrl = (id: number, params?: DeleteAppParams) => {
|
||||
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/apps/${id}?${stringifiedParams}`
|
||||
: `/api/apps/${id}`;
|
||||
};
|
||||
|
||||
export const deleteApp = async (
|
||||
id: number,
|
||||
params?: DeleteAppParams,
|
||||
options?: RequestInit,
|
||||
): Promise<void> => {
|
||||
return customFetch<void>(getDeleteAppUrl(id), {
|
||||
return customFetch<void>(getDeleteAppUrl(id, params), {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteAppMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<AppDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteAppParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteAppParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["deleteApp"];
|
||||
@@ -1441,11 +1460,11 @@ export const getDeleteAppMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
{ id: number }
|
||||
{ id: number; params?: DeleteAppParams }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
const { id, params } = props ?? {};
|
||||
|
||||
return deleteApp(id, requestOptions);
|
||||
return deleteApp(id, params, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -1455,26 +1474,26 @@ export type DeleteAppMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteApp>>
|
||||
>;
|
||||
|
||||
export type DeleteAppMutationError = ErrorType<unknown>;
|
||||
export type DeleteAppMutationError = ErrorType<AppDeletionConflict>;
|
||||
|
||||
/**
|
||||
* @summary Delete app (admin)
|
||||
*/
|
||||
export const useDeleteApp = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<AppDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteAppParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteApp>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteAppParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteAppMutationOptions(options));
|
||||
@@ -2235,35 +2254,51 @@ export const useUpdateService = <
|
||||
/**
|
||||
* @summary Delete service (admin)
|
||||
*/
|
||||
export const getDeleteServiceUrl = (id: number) => {
|
||||
return `/api/services/${id}`;
|
||||
export const getDeleteServiceUrl = (
|
||||
id: number,
|
||||
params?: DeleteServiceParams,
|
||||
) => {
|
||||
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/services/${id}?${stringifiedParams}`
|
||||
: `/api/services/${id}`;
|
||||
};
|
||||
|
||||
export const deleteService = async (
|
||||
id: number,
|
||||
params?: DeleteServiceParams,
|
||||
options?: RequestInit,
|
||||
): Promise<void> => {
|
||||
return customFetch<void>(getDeleteServiceUrl(id), {
|
||||
return customFetch<void>(getDeleteServiceUrl(id, params), {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<ServiceDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteServiceParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteServiceParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["deleteService"];
|
||||
@@ -2277,11 +2312,11 @@ export const getDeleteServiceMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
{ id: number }
|
||||
{ id: number; params?: DeleteServiceParams }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
const { id, params } = props ?? {};
|
||||
|
||||
return deleteService(id, requestOptions);
|
||||
return deleteService(id, params, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -2291,26 +2326,26 @@ export type DeleteServiceMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteService>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceMutationError = ErrorType<unknown>;
|
||||
export type DeleteServiceMutationError = ErrorType<ServiceDeletionConflict>;
|
||||
|
||||
/**
|
||||
* @summary Delete service (admin)
|
||||
*/
|
||||
export const useDeleteService = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<ServiceDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteServiceParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteService>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteServiceParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteServiceMutationOptions(options));
|
||||
@@ -4340,35 +4375,48 @@ export const useUpdateUser = <
|
||||
/**
|
||||
* @summary Delete user (admin)
|
||||
*/
|
||||
export const getDeleteUserUrl = (id: number) => {
|
||||
return `/api/users/${id}`;
|
||||
export const getDeleteUserUrl = (id: number, params?: DeleteUserParams) => {
|
||||
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/users/${id}?${stringifiedParams}`
|
||||
: `/api/users/${id}`;
|
||||
};
|
||||
|
||||
export const deleteUser = async (
|
||||
id: number,
|
||||
params?: DeleteUserParams,
|
||||
options?: RequestInit,
|
||||
): Promise<void> => {
|
||||
return customFetch<void>(getDeleteUserUrl(id), {
|
||||
return customFetch<void>(getDeleteUserUrl(id, params), {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteUserMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<UserDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteUserParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteUserParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["deleteUser"];
|
||||
@@ -4382,11 +4430,11 @@ export const getDeleteUserMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
{ id: number }
|
||||
{ id: number; params?: DeleteUserParams }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
const { id, params } = props ?? {};
|
||||
|
||||
return deleteUser(id, requestOptions);
|
||||
return deleteUser(id, params, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -4396,26 +4444,26 @@ export type DeleteUserMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteUser>>
|
||||
>;
|
||||
|
||||
export type DeleteUserMutationError = ErrorType<unknown>;
|
||||
export type DeleteUserMutationError = ErrorType<UserDeletionConflict>;
|
||||
|
||||
/**
|
||||
* @summary Delete user (admin)
|
||||
*/
|
||||
export const useDeleteUser = <
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<UserDeletionConflict>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteUserParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteUser>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; params?: DeleteUserParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteUserMutationOptions(options));
|
||||
|
||||
@@ -388,9 +388,22 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: force
|
||||
in: query
|
||||
required: false
|
||||
description: Force deletion of an app that has dependent records (records an audit log entry).
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted
|
||||
"409":
|
||||
description: App has dependent records; pass force=true to confirm deletion.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDeletionConflict"
|
||||
|
||||
/apps/{id}/open:
|
||||
post:
|
||||
@@ -590,9 +603,22 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: force
|
||||
in: query
|
||||
required: false
|
||||
description: Force deletion of a service that has existing orders (records an audit log entry).
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted
|
||||
"409":
|
||||
description: Service has dependent records; pass force=true to confirm deletion.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceDeletionConflict"
|
||||
|
||||
/orders:
|
||||
post:
|
||||
@@ -1143,9 +1169,22 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: force
|
||||
in: query
|
||||
required: false
|
||||
description: Force deletion of a user that owns dependent records (records an audit log entry).
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted
|
||||
"409":
|
||||
description: User has dependent records; pass force=true to confirm deletion.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDeletionConflict"
|
||||
|
||||
/orders/{id}:
|
||||
delete:
|
||||
@@ -2129,6 +2168,54 @@ components:
|
||||
- appCount
|
||||
- roleCount
|
||||
|
||||
UserDeletionConflict:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
noteCount:
|
||||
type: integer
|
||||
orderCount:
|
||||
type: integer
|
||||
conversationCount:
|
||||
type: integer
|
||||
messageCount:
|
||||
type: integer
|
||||
required:
|
||||
- error
|
||||
- noteCount
|
||||
- orderCount
|
||||
- conversationCount
|
||||
- messageCount
|
||||
|
||||
AppDeletionConflict:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
groupCount:
|
||||
type: integer
|
||||
restrictionCount:
|
||||
type: integer
|
||||
openCount:
|
||||
type: integer
|
||||
required:
|
||||
- error
|
||||
- groupCount
|
||||
- restrictionCount
|
||||
- openCount
|
||||
|
||||
ServiceDeletionConflict:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
orderCount:
|
||||
type: integer
|
||||
required:
|
||||
- error
|
||||
- orderCount
|
||||
|
||||
GroupDetail:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -382,6 +382,17 @@ export const DeleteAppParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const deleteAppQueryForceDefault = false;
|
||||
|
||||
export const DeleteAppQueryParams = zod.object({
|
||||
force: zod.coerce
|
||||
.boolean()
|
||||
.default(deleteAppQueryForceDefault)
|
||||
.describe(
|
||||
"Force deletion of an app that has dependent records (records an audit log entry).",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Log an app open event for the current user
|
||||
*/
|
||||
@@ -591,6 +602,17 @@ export const DeleteServiceParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const deleteServiceQueryForceDefault = false;
|
||||
|
||||
export const DeleteServiceQueryParams = zod.object({
|
||||
force: zod.coerce
|
||||
.boolean()
|
||||
.default(deleteServiceQueryForceDefault)
|
||||
.describe(
|
||||
"Force deletion of a service that has existing orders (records an audit log entry).",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Place a service order
|
||||
*/
|
||||
@@ -1500,6 +1522,17 @@ export const DeleteUserParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const deleteUserQueryForceDefault = false;
|
||||
|
||||
export const DeleteUserQueryParams = zod.object({
|
||||
force: zod.coerce
|
||||
.boolean()
|
||||
.default(deleteUserQueryForceDefault)
|
||||
.describe(
|
||||
"Force deletion of a user that owns dependent records (records an audit log entry).",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Permanently deletes the order. Allowed when the caller owns the order
|
||||
AND it is in a terminal status (completed/cancelled), OR the caller is
|
||||
|
||||
Reference in New Issue
Block a user