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:
riyadhafraa
2026-04-27 12:07:07 +00:00
parent 6e9b9b5f3d
commit 7c9edf6cb6
11 changed files with 1146 additions and 72 deletions
@@ -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;
};
+87 -39
View File
@@ -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));
+87
View File
@@ -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:
+33
View File
@@ -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