From 7c9edf6cb68b10e231c0c9679639e74dcf7ee15b Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 27 Apr 2026 12:07:07 +0000 Subject: [PATCH] Warn admins before deleting non-empty users/apps/services (Task #85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- artifacts/api-server/src/routes/apps.ts | 60 ++- artifacts/api-server/src/routes/services.ts | 62 ++- artifacts/api-server/src/routes/users.ts | 92 ++++- .../tests/delete-force-warnings.test.mjs | 383 ++++++++++++++++++ artifacts/tx-os/src/locales/ar.json | 32 ++ artifacts/tx-os/src/locales/en.json | 32 ++ artifacts/tx-os/src/pages/admin.tsx | 270 +++++++++++- .../src/generated/api.schemas.ts | 41 ++ lib/api-client-react/src/generated/api.ts | 126 ++++-- lib/api-spec/openapi.yaml | 87 ++++ lib/api-zod/src/generated/api.ts | 33 ++ 11 files changed, 1146 insertions(+), 72 deletions(-) create mode 100644 artifacts/api-server/tests/delete-force-warnings.test.mjs diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index d95d6154..96ad1532 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -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 => { 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`count(*)::int` }) + .from(groupAppsTable) + .where(eq(groupAppsTable.appId, appId)); + const [restrictionRow] = await db + .select({ count: sql`count(*)::int` }) + .from(appPermissionsTable) + .where(eq(appPermissionsTable.appId, appId)); + const [openRow] = await db + .select({ count: sql`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); }); diff --git a/artifacts/api-server/src/routes/services.ts b/artifacts/api-server/src/routes/services.ts index 056a4ec2..b82e462f 100644 --- a/artifacts/api-server/src/routes/services.ts +++ b/artifacts/api-server/src/routes/services.ts @@ -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 => 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`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); }); diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index 1296f594..531b8ea7 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -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 => { 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`count(*)::int` }) + .from(notesTable) + .where(eq(notesTable.userId, userId)); + const [orderRow] = await db + .select({ count: sql`count(*)::int` }) + .from(serviceOrdersTable) + .where(eq(serviceOrdersTable.userId, userId)); + const [convRow] = await db + .select({ count: sql`count(*)::int` }) + .from(conversationsTable) + .where(eq(conversationsTable.createdBy, userId)); + const [msgRow] = await db + .select({ count: sql`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); }); diff --git a/artifacts/api-server/tests/delete-force-warnings.test.mjs b/artifacts/api-server/tests/delete-force-warnings.test.mjs new file mode 100644 index 00000000..3ec30458 --- /dev/null +++ b/artifacts/api-server/tests/delete-force-warnings.test.mjs @@ -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); +}); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index f11753d3..d1d61b0d 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -305,6 +305,38 @@ "editService": "تعديل الخدمة", "editUser": "تعديل المستخدم", "deleteConfirm": "هل أنت متأكد من الحذف؟", + "deleteApp": { + "title": "حذف التطبيق \"{{name}}\"؟", + "warning": "هذا التطبيق لا يزال قيد الاستخدام. حذفه سيؤثر على ما يلي:", + "forceHint": "سيؤدي ذلك إلى إلغاء وصول كل من يستخدمه وإزالة سجلّه. لا يمكن التراجع عن هذا الإجراء.", + "emptyBody": "هل أنت متأكد من حذف هذا التطبيق؟", + "groupCount": "{{count}} مجموعة تمنح هذا التطبيق حالياً", + "restrictionCount": "{{count}} قاعدة صلاحيات تقيّده", + "openCount": "{{count}} مرة فتح في السجل", + "confirm": "حذف", + "anyway": "حذف رغم ذلك" + }, + "deleteService": { + "title": "حذف الخدمة \"{{name}}\"؟", + "warning": "هذه الخدمة لا تزال مرتبطة بطلبات. حذفها سيؤثر على ما يلي:", + "forceHint": "سيؤدي ذلك إلى حذف كل الطلبات المرتبطة نهائياً. لا يمكن التراجع عن هذا الإجراء.", + "emptyBody": "هل أنت متأكد من حذف هذه الخدمة؟", + "orderCount": "{{count}} طلب", + "confirm": "حذف", + "anyway": "حذف رغم ذلك" + }, + "deleteUser": { + "title": "حذف المستخدم \"{{name}}\"؟", + "warning": "هذا المستخدم لديه بيانات. حذفه سيؤثر على ما يلي:", + "forceHint": "سيؤدي ذلك إلى حذف ملاحظاته وطلباته والمحادثات التي أنشأها والرسائل التي أرسلها بشكل نهائي. لا يمكن التراجع عن هذا الإجراء.", + "emptyBody": "هل أنت متأكد من حذف هذا المستخدم؟", + "noteCount": "{{count}} ملاحظة", + "orderCount": "{{count}} طلب", + "conversationCount": "{{count}} محادثة أنشأها", + "messageCount": "{{count}} رسالة أرسلها", + "confirm": "حذف", + "anyway": "حذف رغم ذلك" + }, "activate": "تفعيل", "deactivate": "تعطيل", "appName": "اسم التطبيق", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 1d82b28f..1f122694 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -302,6 +302,38 @@ "editService": "Edit Service", "editUser": "Edit User", "deleteConfirm": "Are you sure you want to delete?", + "deleteApp": { + "title": "Delete app \"{{name}}\"?", + "warning": "This app is still in use. Deleting it will affect the following:", + "forceHint": "This will revoke access for everyone using it and remove its history. This cannot be undone.", + "emptyBody": "Are you sure you want to delete this app?", + "groupCount": "{{count}} group(s) currently grant this app", + "restrictionCount": "{{count}} permission rule(s) restricting it", + "openCount": "{{count}} open event(s) in history", + "confirm": "Delete", + "anyway": "Delete anyway" + }, + "deleteService": { + "title": "Delete service \"{{name}}\"?", + "warning": "This service still has orders. Deleting it will affect the following:", + "forceHint": "This will permanently remove every related order. This cannot be undone.", + "emptyBody": "Are you sure you want to delete this service?", + "orderCount": "{{count}} order(s)", + "confirm": "Delete", + "anyway": "Delete anyway" + }, + "deleteUser": { + "title": "Delete user \"{{name}}\"?", + "warning": "This user owns data. Deleting them will affect the following:", + "forceHint": "This will permanently remove their notes, orders, conversations they started, and messages they sent. This cannot be undone.", + "emptyBody": "Are you sure you want to delete this user?", + "noteCount": "{{count}} note(s)", + "orderCount": "{{count}} order(s)", + "conversationCount": "{{count}} conversation(s) created", + "messageCount": "{{count}} message(s) sent", + "confirm": "Delete", + "anyway": "Delete anyway" + }, "activate": "Activate", "deactivate": "Deactivate", "appName": "App Name", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 10c98646..ec4cb79c 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -47,6 +47,9 @@ import { } from "@workspace/api-client-react"; import type { GroupDeletionConflict, + AppDeletionConflict, + ServiceDeletionConflict, + UserDeletionConflict, App, Service, UserProfile, @@ -137,6 +140,82 @@ type ServiceForm = { isAvailable: boolean; }; +function DeletionWarningDialog({ + title, + items, + warning, + forceHint, + emptyBody, + confirmLabel, + anywayLabel, + showWarning, + isPending, + onCancel, + onConfirm, + testId, + confirmTestId, +}: { + title: string; + items: string[]; + warning: string; + forceHint: string; + emptyBody: string; + confirmLabel: string; + anywayLabel: string; + showWarning: boolean; + isPending: boolean; + onCancel: () => void; + onConfirm: () => void; + testId?: string; + confirmTestId?: string; +}) { + const { t } = useTranslation(); + return ( +
+
+

{title}

+ {showWarning ? ( + <> +

{warning}

+ {items.length > 0 && ( +
    + {items.map((item, idx) => ( +
  • {item}
  • + ))} +
+ )} +

{forceHint}

+ + ) : ( +

{emptyBody}

+ )} +
+ + +
+
+
+ ); +} + export default function AdminPage() { const { t, i18n } = useTranslation(); const [, setLocation] = useLocation(); @@ -224,6 +303,59 @@ export default function AdminPage() { const [editingApp, setEditingApp] = useState<{ id?: number; form: AppForm } | null>(null); const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null); + const [appDeleteTarget, setAppDeleteTarget] = useState(null); + const [appDeleteConflict, setAppDeleteConflict] = useState(null); + const [serviceDeleteTarget, setServiceDeleteTarget] = useState(null); + const [serviceDeleteConflict, setServiceDeleteConflict] = useState(null); + + const closeAppDeleteDialog = () => { + setAppDeleteTarget(null); + setAppDeleteConflict(null); + }; + const closeServiceDeleteDialog = () => { + setServiceDeleteTarget(null); + setServiceDeleteConflict(null); + }; + + const performAppDelete = (app: App, force: boolean) => { + deleteApp.mutate( + { id: app.id, params: force ? { force: true } : undefined }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }); + toast({ title: t("common.success") }); + closeAppDeleteDialog(); + }, + onError: (err: unknown) => { + if (err instanceof ApiError && err.status === 409 && err.data) { + setAppDeleteConflict(err.data as AppDeletionConflict); + return; + } + toast({ title: t("common.error"), variant: "destructive" }); + }, + }, + ); + }; + + const performServiceDelete = (service: Service, force: boolean) => { + deleteService.mutate( + { id: service.id, params: force ? { force: true } : undefined }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() }); + toast({ title: t("common.success") }); + closeServiceDeleteDialog(); + }, + onError: (err: unknown) => { + if (err instanceof ApiError && err.status === 409 && err.data) { + setServiceDeleteConflict(err.data as ServiceDeletionConflict); + return; + } + toast({ title: t("common.error"), variant: "destructive" }); + }, + }, + ); + }; const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null); const [section, setSection] = useState("dashboard"); const [navOpenGroups, setNavOpenGroups] = useState>({}); @@ -702,14 +834,11 @@ export default function AdminPage() { @@ -764,14 +893,11 @@ export default function AdminPage() { @@ -818,6 +944,61 @@ export default function AdminPage() { + {appDeleteTarget && (() => { + const target = appDeleteTarget; + const items: string[] = []; + if (appDeleteConflict) { + if (appDeleteConflict.groupCount > 0) + items.push(t("admin.deleteApp.groupCount", { count: appDeleteConflict.groupCount })); + if (appDeleteConflict.restrictionCount > 0) + items.push(t("admin.deleteApp.restrictionCount", { count: appDeleteConflict.restrictionCount })); + if (appDeleteConflict.openCount > 0) + items.push(t("admin.deleteApp.openCount", { count: appDeleteConflict.openCount })); + } + return ( + performAppDelete(target, appDeleteConflict !== null)} + testId="app-delete-dialog" + confirmTestId="app-delete-confirm" + /> + ); + })()} + + {serviceDeleteTarget && (() => { + const target = serviceDeleteTarget; + const items: string[] = []; + if (serviceDeleteConflict && serviceDeleteConflict.orderCount > 0) { + items.push(t("admin.deleteService.orderCount", { count: serviceDeleteConflict.orderCount })); + } + return ( + performServiceDelete(target, serviceDeleteConflict !== null)} + testId="service-delete-dialog" + confirmTestId="service-delete-confirm" + /> + ); + })()} + {resetLinkUser && (
("username"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [editingUser, setEditingUser] = useState(null); + const [userDeleteTarget, setUserDeleteTarget] = useState(null); + const [userDeleteConflict, setUserDeleteConflict] = useState(null); const [newUserOpen, setNewUserOpen] = useState(false); const [newUserForm, setNewUserForm] = useState<{ username: string; @@ -2046,6 +2229,31 @@ function UsersPanel({ const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: [`/api/users`] }); + const closeUserDeleteDialog = () => { + setUserDeleteTarget(null); + setUserDeleteConflict(null); + }; + + const performUserDelete = (target: UserProfile, force: boolean) => { + deleteUser.mutate( + { id: target.id, params: force ? { force: true } : undefined }, + { + onSuccess: () => { + invalidateUsers(); + toast({ title: t("common.success") }); + closeUserDeleteDialog(); + }, + onError: (err: unknown) => { + if (err instanceof ApiError && err.status === 409 && err.data) { + setUserDeleteConflict(err.data as UserDeletionConflict); + return; + } + toast({ title: t("common.error"), variant: "destructive" }); + }, + }, + ); + }; + return (
@@ -2198,11 +2406,11 @@ function UsersPanel({ {u.id !== currentUserId && ( @@ -2303,6 +2511,38 @@ function UsersPanel({ onSaved={() => { invalidateUsers(); setEditingUser(null); }} /> )} + + {userDeleteTarget && (() => { + const target = userDeleteTarget; + const items: string[] = []; + if (userDeleteConflict) { + if (userDeleteConflict.noteCount > 0) + items.push(t("admin.deleteUser.noteCount", { count: userDeleteConflict.noteCount })); + if (userDeleteConflict.orderCount > 0) + items.push(t("admin.deleteUser.orderCount", { count: userDeleteConflict.orderCount })); + if (userDeleteConflict.conversationCount > 0) + items.push(t("admin.deleteUser.conversationCount", { count: userDeleteConflict.conversationCount })); + if (userDeleteConflict.messageCount > 0) + items.push(t("admin.deleteUser.messageCount", { count: userDeleteConflict.messageCount })); + } + return ( + performUserDelete(target, userDeleteConflict !== null)} + testId="user-delete-dialog" + confirmTestId="user-delete-confirm" + /> + ); + })()}
); } diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index ce6c99da..d07aaa71 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; }; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 7a3b9bec..21f62e0b 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { - return customFetch(getDeleteAppUrl(id), { + return customFetch(getDeleteAppUrl(id, params), { ...options, method: "DELETE", }); }; export const getDeleteAppMutationOptions = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteAppParams }, TContext >; request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteAppParams }, TContext > => { const mutationKey = ["deleteApp"]; @@ -1441,11 +1460,11 @@ export const getDeleteAppMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { 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> >; -export type DeleteAppMutationError = ErrorType; +export type DeleteAppMutationError = ErrorType; /** * @summary Delete app (admin) */ export const useDeleteApp = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteAppParams }, TContext >; request?: SecondParameter; }): UseMutationResult< Awaited>, 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 => { - return customFetch(getDeleteServiceUrl(id), { + return customFetch(getDeleteServiceUrl(id, params), { ...options, method: "DELETE", }); }; export const getDeleteServiceMutationOptions = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteServiceParams }, TContext >; request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteServiceParams }, TContext > => { const mutationKey = ["deleteService"]; @@ -2277,11 +2312,11 @@ export const getDeleteServiceMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { 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> >; -export type DeleteServiceMutationError = ErrorType; +export type DeleteServiceMutationError = ErrorType; /** * @summary Delete service (admin) */ export const useDeleteService = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteServiceParams }, TContext >; request?: SecondParameter; }): UseMutationResult< Awaited>, 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 => { - return customFetch(getDeleteUserUrl(id), { + return customFetch(getDeleteUserUrl(id, params), { ...options, method: "DELETE", }); }; export const getDeleteUserMutationOptions = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteUserParams }, TContext >; request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteUserParams }, TContext > => { const mutationKey = ["deleteUser"]; @@ -4382,11 +4430,11 @@ export const getDeleteUserMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { 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> >; -export type DeleteUserMutationError = ErrorType; +export type DeleteUserMutationError = ErrorType; /** * @summary Delete user (admin) */ export const useDeleteUser = < - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteUserParams }, TContext >; request?: SecondParameter; }): UseMutationResult< Awaited>, TError, - { id: number }, + { id: number; params?: DeleteUserParams }, TContext > => { return useMutation(getDeleteUserMutationOptions(options)); diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 3200cd05..98729e6a 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 75e8f17f..3ad570b9 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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