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
+55 -5
View File
@@ -11,6 +11,7 @@ import {
appOpensTable,
userGroupsTable,
groupAppsTable,
auditLogsTable,
} from "@workspace/db";
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
import {
@@ -253,16 +254,65 @@ router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
return;
}
const [app] = await db
.delete(appsTable)
.where(eq(appsTable.id, params.data.id))
.returning();
const appId = params.data.id;
if (!app) {
const [existing] = await db
.select()
.from(appsTable)
.where(eq(appsTable.id, appId));
if (!existing) {
res.status(404).json({ error: "App not found" });
return;
}
const force = req.query.force === "true" || req.query.force === "1";
const [groupRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(groupAppsTable)
.where(eq(groupAppsTable.appId, appId));
const [restrictionRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(appPermissionsTable)
.where(eq(appPermissionsTable.appId, appId));
const [openRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(appOpensTable)
.where(eq(appOpensTable.appId, appId));
const groupCount = groupRow?.count ?? 0;
const restrictionCount = restrictionRow?.count ?? 0;
const openCount = openRow?.count ?? 0;
const hasDeps = groupCount > 0 || restrictionCount > 0 || openCount > 0;
if (hasDeps && !force) {
res.status(409).json({
error: "App has dependent records",
groupCount,
restrictionCount,
openCount,
});
return;
}
await db.delete(appsTable).where(eq(appsTable.id, appId));
if (hasDeps && force) {
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "app.force_delete",
targetType: "app",
targetId: appId,
metadata: {
slug: existing.slug,
nameEn: existing.nameEn,
groupCount,
restrictionCount,
openCount,
},
});
}
res.sendStatus(204);
});
+55 -7
View File
@@ -1,7 +1,12 @@
import { Router, type IRouter } from "express";
import { eq, asc } from "drizzle-orm";
import { eq, asc, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import { servicesTable, serviceCategoriesTable } from "@workspace/db";
import {
servicesTable,
serviceCategoriesTable,
serviceOrdersTable,
auditLogsTable,
} from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
import {
CreateServiceBody,
@@ -94,16 +99,59 @@ router.delete("/services/:id", requireAdmin, async (req, res): Promise<void> =>
return;
}
const [service] = await db
.delete(servicesTable)
.where(eq(servicesTable.id, params.data.id))
.returning();
const serviceId = params.data.id;
if (!service) {
const [existing] = await db
.select()
.from(servicesTable)
.where(eq(servicesTable.id, serviceId));
if (!existing) {
res.status(404).json({ error: "Service not found" });
return;
}
const force = req.query.force === "true" || req.query.force === "1";
const [orderRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(serviceOrdersTable)
.where(eq(serviceOrdersTable.serviceId, serviceId));
const orderCount = orderRow?.count ?? 0;
const hasDeps = orderCount > 0;
if (hasDeps && !force) {
res.status(409).json({
error: "Service has dependent records",
orderCount,
});
return;
}
await db.transaction(async (tx) => {
if (hasDeps) {
// service_orders.service_id has ON DELETE RESTRICT, so we must clear
// dependent orders first before the service itself can be removed.
await tx
.delete(serviceOrdersTable)
.where(eq(serviceOrdersTable.serviceId, serviceId));
}
await tx.delete(servicesTable).where(eq(servicesTable.id, serviceId));
});
if (hasDeps && force) {
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "service.force_delete",
targetType: "service",
targetId: serviceId,
metadata: {
nameEn: existing.nameEn,
orderCount,
},
});
}
res.sendStatus(204);
});
+86 -6
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter } from "express";
import { eq, and, or, ilike, inArray, asc, type SQL } from "drizzle-orm";
import { eq, and, or, ilike, inArray, asc, sql, type SQL } from "drizzle-orm";
import { db } from "@workspace/db";
import {
usersTable,
@@ -7,6 +7,11 @@ import {
rolesTable,
groupsTable,
userGroupsTable,
notesTable,
serviceOrdersTable,
conversationsTable,
messagesTable,
auditLogsTable,
} from "@workspace/db";
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
import { emitAppsChangedToUsers } from "../lib/realtime";
@@ -457,16 +462,91 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
return;
}
const [user] = await db
.delete(usersTable)
.where(eq(usersTable.id, params.data.id))
.returning();
const userId = params.data.id;
if (!user) {
const [existing] = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId));
if (!existing) {
res.status(404).json({ error: "User not found" });
return;
}
const force = req.query.force === "true" || req.query.force === "1";
const [noteRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(notesTable)
.where(eq(notesTable.userId, userId));
const [orderRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(serviceOrdersTable)
.where(eq(serviceOrdersTable.userId, userId));
const [convRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(conversationsTable)
.where(eq(conversationsTable.createdBy, userId));
const [msgRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(messagesTable)
.where(eq(messagesTable.senderId, userId));
const noteCount = noteRow?.count ?? 0;
const orderCount = orderRow?.count ?? 0;
const conversationCount = convRow?.count ?? 0;
const messageCount = msgRow?.count ?? 0;
const hasDeps =
noteCount > 0 ||
orderCount > 0 ||
conversationCount > 0 ||
messageCount > 0;
if (hasDeps && !force) {
res.status(409).json({
error: "User has dependent records",
noteCount,
orderCount,
conversationCount,
messageCount,
});
return;
}
await db.transaction(async (tx) => {
if (force) {
// Hard cleanup of records that don't cascade automatically so the
// user delete can succeed without FK violations.
if (conversationCount > 0) {
await tx
.delete(conversationsTable)
.where(eq(conversationsTable.createdBy, userId));
}
if (messageCount > 0) {
await tx
.delete(messagesTable)
.where(eq(messagesTable.senderId, userId));
}
}
await tx.delete(usersTable).where(eq(usersTable.id, userId));
});
if (hasDeps && force) {
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "user.force_delete",
targetType: "user",
targetId: userId,
metadata: {
username: existing.username,
noteCount,
orderCount,
conversationCount,
messageCount,
},
});
}
res.sendStatus(204);
});
@@ -0,0 +1,383 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
let adminId;
let adminUsername;
let adminCookie;
let createdUserIds = [];
let createdAppIds = [];
let createdServiceIds = [];
let createdGroupIds = [];
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
return setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
}
before(async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
adminUsername = `del_admin_${stamp}`;
const a = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Delete Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
await pool.query(
`INSERT INTO user_groups (user_id, group_id)
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
ON CONFLICT DO NOTHING`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
});
after(async () => {
if (createdServiceIds.length) {
await pool.query(
`DELETE FROM service_orders WHERE service_id = ANY($1::int[])`,
[createdServiceIds],
);
await pool.query(`DELETE FROM services WHERE id = ANY($1::int[])`, [
createdServiceIds,
]);
}
if (createdAppIds.length) {
await pool.query(`DELETE FROM app_opens WHERE app_id = ANY($1::int[])`, [
createdAppIds,
]);
await pool.query(
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
[createdAppIds],
);
await pool.query(`DELETE FROM group_apps WHERE app_id = ANY($1::int[])`, [
createdAppIds,
]);
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
createdAppIds,
]);
}
if (createdGroupIds.length) {
await pool.query(`DELETE FROM group_apps WHERE group_id = ANY($1::int[])`, [
createdGroupIds,
]);
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
createdGroupIds,
]);
}
for (const uid of [adminId, ...createdUserIds]) {
if (uid !== undefined) {
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [uid]);
await pool.query(`DELETE FROM conversations WHERE created_by = $1`, [
uid,
]);
await pool.query(`DELETE FROM notes WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM service_orders WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
}
}
await pool.end();
});
// ---------- Users ----------
test("DELETE /api/users/:id without dependents succeeds (no force needed)", async () => {
const username = `del_clean_${Date.now().toString(36)}`;
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Clean User', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const uid = r.rows[0].id;
const res = await fetch(`${API_BASE}/api/users/${uid}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
const check = await pool.query(`SELECT id FROM users WHERE id = $1`, [uid]);
assert.equal(check.rowCount, 0);
});
test("DELETE /api/users/:id with dependents returns 409 with counts", async () => {
const username = `del_busy_${Date.now().toString(36)}`;
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Busy User', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const uid = r.rows[0].id;
createdUserIds.push(uid);
await pool.query(
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'Note A', 'x'), ($1, 'Note B', 'y')`,
[uid],
);
const conv = await pool.query(
`INSERT INTO conversations (created_by, name_en, is_group) VALUES ($1, 'Test Conv', true) RETURNING id`,
[uid],
);
const convId = conv.rows[0].id;
await pool.query(
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hello')`,
[convId, uid],
);
const res = await fetch(`${API_BASE}/api/users/${uid}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 409);
const body = await res.json();
assert.equal(body.noteCount, 2);
assert.equal(body.conversationCount, 1);
assert.equal(body.messageCount, 1);
const stillThere = await pool.query(`SELECT id FROM users WHERE id = $1`, [
uid,
]);
assert.equal(stillThere.rowCount, 1);
});
test("DELETE /api/users/:id?force=true deletes user and writes audit log", async () => {
const username = `del_force_${Date.now().toString(36)}`;
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Force User', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const uid = r.rows[0].id;
await pool.query(`INSERT INTO notes (user_id, title) VALUES ($1, 'n')`, [
uid,
]);
const conv = await pool.query(
`INSERT INTO conversations (created_by, name_en, is_group) VALUES ($1, 'Conv', true) RETURNING id`,
[uid],
);
await pool.query(
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
[conv.rows[0].id, uid],
);
const res = await fetch(`${API_BASE}/api/users/${uid}?force=true`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
const check = await pool.query(`SELECT id FROM users WHERE id = $1`, [uid]);
assert.equal(check.rowCount, 0);
const audit = await pool.query(
`SELECT * FROM audit_logs WHERE action = 'user.force_delete' AND target_id = $1`,
[uid],
);
assert.equal(audit.rowCount, 1);
assert.equal(audit.rows[0].actor_user_id, adminId);
});
// ---------- Apps ----------
test("DELETE /api/apps/:id without dependents succeeds (no force needed)", async () => {
const slug = `clean_app_${Date.now().toString(36)}`;
const a = await pool.query(
`INSERT INTO apps (slug, name_ar, name_en, route, sort_order)
VALUES ($1, 'تطبيق', 'App Clean', '/x', 999) RETURNING id`,
[slug],
);
const id = a.rows[0].id;
const res = await fetch(`${API_BASE}/api/apps/${id}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
});
test("DELETE /api/apps/:id with dependents returns 409 with counts", async () => {
const slug = `busy_app_${Date.now().toString(36)}`;
const a = await pool.query(
`INSERT INTO apps (slug, name_ar, name_en, route, sort_order)
VALUES ($1, 'تطبيق', 'App Busy', '/y', 999) RETURNING id`,
[slug],
);
const appId = a.rows[0].id;
createdAppIds.push(appId);
const g = await pool.query(
`INSERT INTO groups (name, description_en) VALUES ($1, 'g') RETURNING id`,
[`gApp_${Date.now().toString(36)}`],
);
const groupId = g.rows[0].id;
createdGroupIds.push(groupId);
await pool.query(
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
[groupId, appId],
);
await pool.query(
`INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2), ($1, $2)`,
[adminId, appId],
);
const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 409);
const body = await res.json();
assert.equal(body.groupCount, 1);
assert.equal(body.openCount, 2);
const stillThere = await pool.query(`SELECT id FROM apps WHERE id = $1`, [
appId,
]);
assert.equal(stillThere.rowCount, 1);
});
test("DELETE /api/apps/:id?force=true deletes app and writes audit log", async () => {
const slug = `force_app_${Date.now().toString(36)}`;
const a = await pool.query(
`INSERT INTO apps (slug, name_ar, name_en, route, sort_order)
VALUES ($1, 'تطبيق', 'App Force', '/z', 999) RETURNING id`,
[slug],
);
const appId = a.rows[0].id;
const g = await pool.query(
`INSERT INTO groups (name, description_en) VALUES ($1, 'g') RETURNING id`,
[`gAppF_${Date.now().toString(36)}`],
);
const groupId = g.rows[0].id;
createdGroupIds.push(groupId);
await pool.query(
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
[groupId, appId],
);
await pool.query(
`INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2)`,
[adminId, appId],
);
const res = await fetch(`${API_BASE}/api/apps/${appId}?force=true`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
const check = await pool.query(`SELECT id FROM apps WHERE id = $1`, [appId]);
assert.equal(check.rowCount, 0);
const audit = await pool.query(
`SELECT * FROM audit_logs WHERE action = 'app.force_delete' AND target_id = $1`,
[String(appId)],
);
assert.equal(audit.rowCount, 1);
});
// ---------- Services ----------
test("DELETE /api/services/:id without dependents succeeds (no force needed)", async () => {
const s = await pool.query(
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', 'Clean Svc', 1.00, true) RETURNING id`,
);
const id = s.rows[0].id;
const res = await fetch(`${API_BASE}/api/services/${id}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
});
test("DELETE /api/services/:id with orders returns 409 with counts", async () => {
const s = await pool.query(
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', 'Busy Svc', 2.00, true) RETURNING id`,
);
const sid = s.rows[0].id;
createdServiceIds.push(sid);
await pool.query(
`INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending'), ($1, $2, 'pending'), ($1, $2, 'completed')`,
[adminId, sid],
);
const res = await fetch(`${API_BASE}/api/services/${sid}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 409);
const body = await res.json();
assert.equal(body.orderCount, 3);
const stillThere = await pool.query(`SELECT id FROM services WHERE id = $1`, [
sid,
]);
assert.equal(stillThere.rowCount, 1);
});
test("DELETE /api/services/:id?force=true deletes service + orders and writes audit log", async () => {
const s = await pool.query(
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', 'Force Svc', 3.00, true) RETURNING id`,
);
const sid = s.rows[0].id;
await pool.query(
`INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending')`,
[adminId, sid],
);
const res = await fetch(`${API_BASE}/api/services/${sid}?force=true`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
const check = await pool.query(`SELECT id FROM services WHERE id = $1`, [
sid,
]);
assert.equal(check.rowCount, 0);
const ordersGone = await pool.query(
`SELECT id FROM service_orders WHERE service_id = $1`,
[sid],
);
assert.equal(ordersGone.rowCount, 0);
const audit = await pool.query(
`SELECT * FROM audit_logs WHERE action = 'service.force_delete' AND target_id = $1`,
[String(sid)],
);
assert.equal(audit.rowCount, 1);
});