From 446c65ff378a27e10f661a1f980674a1d64db688 Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Thu, 30 Jul 2026 17:58:10 -0300 Subject: [PATCH] feat: add forced WAP server deletion support to clean up associated organizations and runtime bots --- ...rnosxpress-triptico-interno.afdesign~lock~ | Bin 119 -> 119 bytes .../Models/WapServer/WapServer.Interface.ts | 10 +- server/src/Models/WapServer/WapServer.ts | 64 ++++++++++- sysadmin-cli/src/api/sysadmin.ts | 15 ++- sysadmin-cli/src/pages/WapServersPage.tsx | 104 ++++++++++++++++-- sysadmin/src/controllers/wap.controller.ts | 4 +- sysadmin/src/models/WapServers.Model.ts | 10 ++ sysadmin/src/services/wap.service.ts | 3 +- 8 files changed, 191 insertions(+), 19 deletions(-) diff --git a/resources/triptico/turnosxpress-triptico-interno.afdesign~lock~ b/resources/triptico/turnosxpress-triptico-interno.afdesign~lock~ index 6a5c211019b53011ff6babb436fe627de201d58a..ddab7203d7a9b336968f32c87c22e545f8ce70bc 100644 GIT binary patch delta 20 acmXRf=kv)=&W>1`oy7nIKXy*!%LD*Pxd#yd delta 20 acmXRf=kv)=&W>1`oy7nI8rvuGWdZ<2p$0zy diff --git a/server/src/Models/WapServer/WapServer.Interface.ts b/server/src/Models/WapServer/WapServer.Interface.ts index fb090c2..7621cd1 100644 --- a/server/src/Models/WapServer/WapServer.Interface.ts +++ b/server/src/Models/WapServer/WapServer.Interface.ts @@ -143,11 +143,19 @@ export type SysAdminWapServerAuditParams = SysAdminWapServerByIdParams; export type SysAdminWapServerRecalculateCountParams = SysAdminWapServerByIdParams; -export type SysAdminDeleteWapServerParams = SysAdminWapServerByIdParams; +export type SysAdminDeleteWapServerParams = SysAdminWapServerByIdParams & { + force?: boolean; +}; export type SysAdminDeleteWapServerResult = { serverId: string; deleted: true; + force?: boolean; + detachedOrganizations?: number; + deletedRuntimeBots?: number; + skippedRuntimeBots?: number; + auditFailed?: boolean; + auditMessage?: string; }; export type SysAdminWapServerOrganizationActionParams = SysAdminWapServerByIdParams & { diff --git a/server/src/Models/WapServer/WapServer.ts b/server/src/Models/WapServer/WapServer.ts index 49acaea..43a8cdd 100644 --- a/server/src/Models/WapServer/WapServer.ts +++ b/server/src/Models/WapServer/WapServer.ts @@ -1157,33 +1157,85 @@ class WapServerManager implements IWapServerManager { throw new Error("WAP server not found"); } - if (server.active) { + const force = data.force === true; + + if (!force && server.active) { throw new Error("WAP server must be inactive before deletion"); } const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId }); - if (assignedCompanies.length > 0) { + if (!force && assignedCompanies.length > 0) { throw new Error(`WAP server cannot be deleted because it has ${assignedCompanies.length} associated bot(s) in DB`); } - let detectedBots: SysAdminWapContainerDto[]; + let detectedBots: SysAdminWapContainerDto[] = []; + let auditFailed = false; + let auditMessage: string | undefined; try { detectedBots = await this.getSysAdminRuntimeContainers(server); } catch (error) { const message = error instanceof Error && error.message ? error.message : "unknown error"; - throw new Error(`WAP server cannot be deleted because bot-admin could not be audited. Verify runtime bots manually before deleting. ${message}`); + if (!force) { + throw new Error(`WAP server cannot be deleted because bot-admin could not be audited. Verify runtime bots manually before deleting. ${message}`); + } + + auditFailed = true; + auditMessage = message; } - if (detectedBots.length > 0) { + if (!force && detectedBots.length > 0) { throw new Error(`WAP server cannot be deleted because bot-admin detected ${detectedBots.length} runtime bot(s)`); } + let deletedRuntimeBots = 0; + let skippedRuntimeBots = 0; + + if (force) { + const baseUrl = this.getBotAdminApiUrl(server.ipv4); + + for (const bot of detectedBots) { + if (!bot.organizationId) { + skippedRuntimeBots += 1; + continue; + } + + try { + const result = await this.deleteSysAdminRuntimeBot(baseUrl, encodeURIComponent(bot.organizationId), true); + + if (result.botDeleted) { + deletedRuntimeBots += 1; + } + } catch (_error) { + skippedRuntimeBots += 1; + } + } + + for (const company of assignedCompanies) { + const companyDocument = company as typeof company & { save: () => Promise }; + + companyDocument.wapServerId = undefined; + await companyDocument.save(); + } + } + await this.servers.delete(data.serverId); - return { serverId: data.serverId, deleted: true }; + return { + serverId: data.serverId, + deleted: true, + ...(force + ? { + force: true, + detachedOrganizations: assignedCompanies.length, + deletedRuntimeBots, + skippedRuntimeBots, + ...(auditFailed ? { auditFailed: true, auditMessage } : {}), + } + : {}), + }; } public async sysAdminStartBot(data: SysAdminWapServerOrganizationActionParams): Promise { diff --git a/sysadmin-cli/src/api/sysadmin.ts b/sysadmin-cli/src/api/sysadmin.ts index 8bcad5a..0c857c3 100644 --- a/sysadmin-cli/src/api/sysadmin.ts +++ b/sysadmin-cli/src/api/sysadmin.ts @@ -254,6 +254,17 @@ export type WapServerRecalculateCountResult = { export type WapServerDeleteResult = { serverId: string; deleted: true; + force?: boolean; + detachedOrganizations?: number; + deletedRuntimeBots?: number; + skippedRuntimeBots?: number; + auditFailed?: boolean; + auditMessage?: string; +}; + +export type WapServerDeletePayload = { + serverId: string; + force?: boolean; }; export type WapServerOrganizationActionPayload = { @@ -282,8 +293,8 @@ export const recalculateWapServerCount = async (serverId: string): Promise => { - const res = await sysadminApi.post('/wap/delete', { serverId }); +export const deleteWapServer = async (data: WapServerDeletePayload): Promise => { + const res = await sysadminApi.post('/wap/delete', data); return res.data; }; diff --git a/sysadmin-cli/src/pages/WapServersPage.tsx b/sysadmin-cli/src/pages/WapServersPage.tsx index 167e19f..a37d7cf 100644 --- a/sysadmin-cli/src/pages/WapServersPage.tsx +++ b/sysadmin-cli/src/pages/WapServersPage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { Search, RotateCcw, CheckCircle, XCircle, Server, ServerOff, ClipboardList, Calculator, X, MoreVertical, Play, Pause, Power, Trash2, Unlink, QrCode, RotateCw, Edit3, Plus } from 'lucide-react'; -import { auditWapServer, createWapServer, deleteWapBot, deleteWapServer, detachWapOrganizationServer, fetchWapServers, getWapBotQr, recalculateWapServerCount, restartWapBot, startWapBot, stopWapBot, updateWapServer, type WapContainerDto, type WapServerAuditResult, type WapServerQrResult, type WapServerRecalculateCountResult } from '../api/sysadmin'; +import { auditWapServer, createWapServer, deleteWapBot, deleteWapServer, detachWapOrganizationServer, fetchWapServers, getWapBotQr, recalculateWapServerCount, restartWapBot, startWapBot, stopWapBot, updateWapServer, type WapContainerDto, type WapServerAuditResult, type WapServerDeleteResult, type WapServerQrResult, type WapServerRecalculateCountResult } from '../api/sysadmin'; const getErrorMessage = (error: unknown, fallback: string): string => { const responseData = (error as any)?.response?.data; @@ -30,6 +30,8 @@ export const WapServersPage = () => { const [openActionsServerId, setOpenActionsServerId] = useState(null); const [openAuditActionKey, setOpenAuditActionKey] = useState(null); const [editingServer, setEditingServer] = useState(null); + const [deleteServerTarget, setDeleteServerTarget] = useState(null); + const [forceDeleteServer, setForceDeleteServer] = useState(false); const [serverFormMode, setServerFormMode] = useState<'create' | 'edit'>('edit'); const [editForm, setEditForm] = useState({ name: '', ipv4: '', port: '', maxBots: '', active: true }); @@ -199,18 +201,28 @@ export const WapServersPage = () => { }; const handleDeleteServer = async (server: any) => { + setForceDeleteServer(false); + setDeleteServerTarget(server); + }; + + const confirmDeleteServer = async () => { + if (!deleteServerTarget) return; + + const server = deleteServerTarget; const serverId = getServerId(server); const serverLabel = server.name || serverId; - if (!window.confirm(`Confirmá eliminar el servidor WAP ${serverLabel}. Debe estar inactivo y sin bots asociados.`)) { - return; - } - setActionServerId(serverId); try { - await deleteWapServer(serverId); - setActionMessage(`Servidor WAP eliminado: ${serverLabel}.`); + const result: WapServerDeleteResult = await deleteWapServer({ serverId, force: forceDeleteServer || undefined }); + const forceSummary = result.force + ? ` Modo forzado: organizaciones desasociadas ${result.detachedOrganizations ?? 0}, bots runtime eliminados ${result.deletedRuntimeBots ?? 0}, bots omitidos ${result.skippedRuntimeBots ?? 0}${result.auditFailed ? `. Auditoría no disponible: ${result.auditMessage || 'sin detalle'}` : ''}.` + : ''; + + setActionMessage(`Servidor WAP eliminado: ${serverLabel}.${forceSummary}`); setSelectedAuditServerId(null); + setDeleteServerTarget(null); + setForceDeleteServer(false); await loadServers(); } catch (e) { console.error(e); @@ -630,6 +642,38 @@ export const WapServersPage = () => { )} + {deleteServerTarget && ( +
setDeleteServerTarget(null)}> +
event.stopPropagation()}> +
+
+

Eliminar servidor WAP

+

{deleteServerTarget.name || getServerId(deleteServerTarget)}

+
+ +
+ +
+ Esta acción elimina el servidor WAP. +

Sin forzar, el servidor debe estar inactivo y no tener bots asociados.

+

Si activás borrado forzado, se omiten esas validaciones: las organizaciones asignadas se desasocian y los bots/contenedores runtime detectados pueden eliminarse u omitirse si no se pueden identificar con seguridad.

+
+ + + +
+ + +
+
+
+ )} + {recalculateResult && (
setRecalculateResult(null)}>
event.stopPropagation()}> @@ -798,6 +842,52 @@ export const WapServersPage = () => { width: min(560px, 96vw); padding: 1.25rem; } + .delete-server-modal { + width: min(560px, 96vw); + padding: 1.25rem; + } + .delete-warning { + display: grid; + gap: 0.5rem; + padding: 1rem; + border: 1px solid rgba(239, 68, 68, 0.32); + border-radius: 14px; + background: rgba(239, 68, 68, 0.1); + color: var(--text-main); + } + .delete-warning p { + margin: 0; + color: var(--text-muted); + } + .force-delete-option { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin-top: 1rem; + padding: 0.9rem; + border: 1px solid var(--glass-border); + border-radius: 12px; + color: #fecaca; + font-weight: 700; + line-height: 1.35; + cursor: pointer; + } + .force-delete-option input { + margin-top: 0.2rem; + } + .btn-danger { + background: rgba(239, 68, 68, 0.9); + color: #ffffff; + border: 0; + border-radius: 10px; + padding: 0.75rem 1rem; + font-weight: 700; + cursor: pointer; + } + .btn-danger:disabled { + opacity: 0.6; + cursor: not-allowed; + } .edit-server-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/sysadmin/src/controllers/wap.controller.ts b/sysadmin/src/controllers/wap.controller.ts index b65c08b..d9bb4fb 100644 --- a/sysadmin/src/controllers/wap.controller.ts +++ b/sysadmin/src/controllers/wap.controller.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { Request, Response } from "express"; import { WapService } from "../services/wap.service"; -import { CreateWapServerParams, PaginateWapServerParams, UpdateWapServerParams, WapServerByIdParams, WapServerOrganizationActionParams } from "src/models/WapServers.Model"; +import { CreateWapServerParams, PaginateWapServerParams, UpdateWapServerParams, WapServerByIdParams, WapServerDeleteParams, WapServerOrganizationActionParams } from "src/models/WapServers.Model"; const wapService = new WapService(); @@ -83,7 +83,7 @@ export class WapController { public deleteServer = async (req: Request, res: Response): Promise => { try { - const data: WapServerByIdParams = req.body as WapServerByIdParams; + const data: WapServerDeleteParams = req.body as WapServerDeleteParams; const result = await wapService.deleteWapServer(data); res.json(result); } catch (error) { diff --git a/sysadmin/src/models/WapServers.Model.ts b/sysadmin/src/models/WapServers.Model.ts index dcad16f..f35cf79 100644 --- a/sysadmin/src/models/WapServers.Model.ts +++ b/sysadmin/src/models/WapServers.Model.ts @@ -62,9 +62,19 @@ export type WapServerByIdParams = { serverId: string; }; +export type WapServerDeleteParams = WapServerByIdParams & { + force?: boolean; +}; + export type WapServerDeleteResult = { serverId: string; deleted: true; + force?: boolean; + detachedOrganizations?: number; + deletedRuntimeBots?: number; + skippedRuntimeBots?: number; + auditFailed?: boolean; + auditMessage?: string; }; export type WapServerOrganizationActionParams = WapServerByIdParams & { diff --git a/sysadmin/src/services/wap.service.ts b/sysadmin/src/services/wap.service.ts index 331e533..a862732 100644 --- a/sysadmin/src/services/wap.service.ts +++ b/sysadmin/src/services/wap.service.ts @@ -8,6 +8,7 @@ import { PaginateWapServerResults, WapServerAuditResult, WapServerByIdParams, + WapServerDeleteParams, WapServerDeleteResult, WapServerDetachResult, WapServerOrganizationActionParams, @@ -82,7 +83,7 @@ export class WapService { return response.data as WapServerRecalculateCountResult; } - public async deleteWapServer(data: WapServerByIdParams): Promise { + public async deleteWapServer(data: WapServerDeleteParams): Promise { const nonce = await getSysAdminNonce(); const postData = { ...data, ...{ payload: getPayload(nonce) } };