Source: Router/orgaSettings/controller.js

// @ts-check
/**
 * @import {ExpressRequestAuthorized, ExpressResponse} from './../../types.js'
 */
/**
 * OrgaSettings Controller
 *
 * HTTP layer for domain and mail/SMTP settings endpoints.
 * All endpoints require the user to be an organisation admin (checkAdmin middleware).
 */

import Busboy from 'busboy';
import { errorLoggerRead, errorLoggerUpdate } from '../../utils/requestLogger.js';
import * as service from './service.js';
import * as registryService from './registryService.js';
import { challengeNamesForZone, delegationRecords } from './acmeDns.js';

/**
 * Apps und Domains laufen über `registryService`; `service.js` bleibt als
 * **Vault-Adapter** im Spiel (Lesefallback während der Umstellung) und für
 * **Mail**.
 *
 * Die Validierung liegt bewusst auch im `registryService`: sie muss gegen
 * denselben Bestand prüfen, in den sie schreibt. Eine Fassung, die noch den
 * Vault-Bestand las, sah in der Datenbank längst aufgelöste Domains nicht — eine
 * Domain ließ sich damit zweimal vergeben.
 *
 * `registryService` liest und schreibt ausschließlich die Datenbank (`ObjectBase`/
 * `Links`). Die HTTP-Oberfläche ist unverändert — genau das macht `admin` zum
 * Abnahmewerkzeug.
 */

const ALLOWED_ICON_MIME = new Set(['image/png', 'image/jpeg', 'image/svg+xml', 'image/gif', 'image/webp']);

const isHttpsUrl = (value) => {
    try {
        const url = new URL(value);
        return url.protocol === 'https:';
    } catch {
        return false;
    }
};

const EXTERNAL_KEY_RE = /^ext-[a-z0-9]+(?:-[a-z0-9]+)*$/;

const validateAppConfig = (appId, appConfig) => {
    if (!appConfig || typeof appConfig !== 'object' || Array.isArray(appConfig))
        return `App "${appId}" must be an object.`;

    if (appConfig.roles !== undefined && !Array.isArray(appConfig.roles))
        return `App "${appId}" field "roles" must be an array.`;

    const isExternal = appConfig.external === true;
    const hasDomain = typeof appConfig.domain === 'string' && appConfig.domain.trim() !== '';
    const hasUrl = typeof appConfig.url === 'string' && appConfig.url.trim() !== '';

    if (isExternal) {
        // Strict key validation only for org-created external links
        if (!EXTERNAL_KEY_RE.test(appId))
            return `External app key "${appId}" must start with "ext-" and contain only lowercase letters, digits and hyphens.`;
        if (!appConfig.title || typeof appConfig.title !== 'string' || !appConfig.title.trim())
            return `External app "${appId}" requires a non-empty title.`;
        if (hasDomain)
            return `External app "${appId}" must not define "domain".`;
        if (!hasUrl)
            return `External app "${appId}" requires a "url".`;
        if (!isHttpsUrl(appConfig.url.trim()))
            return `External app "${appId}" must use an https:// URL.`;
        return null;
    }

    // CommTool-managed apps: only reject if both url and domain are set (ambiguous)
    if (hasUrl && hasDomain)
        return `App "${appId}" must not define both "url" and "domain".`;

    return null;
};

// ── Domains ───────────────────────────────────────────────────────────────────

/**
 * GET /kpe20/orgaSettings/domains
 * Returns the domain settings for the current organisation.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const getDomainsController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const domains = await registryService.getOrgDomains(orgId);

        // Die CNAME-Records werden **hier** abgeleitet, nicht in der Oberfläche:
        // die Zuordnung SAN → Challenge-Name ist ein Vertrag mit dem Zonen-
        // Controller. Zwei Implementierungen davon wären zwei Gelegenheiten,
        // dem Kunden einen Namen zu zeigen, den der Bot nicht ausstellt.
        //
        // `challengeNames` steht **immer** dabei, auch ohne acme-dns-Konten:
        // die Namen ergeben sich allein aus Zone und Zonenlabels und sind genau
        // das, was im DNS angelegt werden muss. Ohne sie zeigte die Oberfläche
        // bei fehlender Delegation gar nichts — und der Admin könnte dem Kunden
        // nicht sagen, welche Einträge entstehen werden. `records` bleibt auf
        // die Fälle mit Ziel beschränkt: ein Record ohne Ziel ist eine
        // Anweisung ins Leere.
        const labels = registryService.appZoneLabels();
        const result = Object.fromEntries(Object.entries(domains).map(([zone, entry]) => {
            if (entry.type !== 'external') return [zone, entry];
            const challengeNames = challengeNamesForZone(zone, labels);
            const accounts = /** @type {Record<string, object>|undefined} */ (entry.acmeDns);
            if (!accounts || Object.keys(accounts).length === 0)
                return [zone, { ...entry, challengeNames }];
            return [zone, {
                ...entry,
                challengeNames,
                records: delegationRecords(challengeNames, accounts),
            }];
        }));

        res.json({ success: true, result });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load domain settings.' });
    }
};

/**
 * PUT /kpe20/orgaSettings/domains
 * Validates and saves the domain settings for the current organisation.
 *
 * Request body: { [domain: string]: "internal" | "external" }
 *
 * Die Antwort enthält den **geschriebenen** Bestand, nicht den übergebenen:
 * ein selbst erklärtes `verified` wird auf `pending` zurückgestuft (der
 * Nachweis läuft über `POST /domains/:domain/verify`), und die Oberfläche soll
 * das sehen statt es beim nächsten Laden zu entdecken.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const saveDomainsController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const domains = req.body;
        if (!domains || typeof domains !== 'object' || Array.isArray(domains))
            return res.status(400).json({ success: false, message: 'Request body must be an object.' });

        // Bewusst noch gegen Vault validiert (siehe Dateikopf): der Bestand
        // liegt in der Migrationsphase in beiden Quellen.
        const errors = await registryService.validateDomains(domains, orgId);
        if (errors.length > 0)
            return res.status(400).json({ success: false, errors });

        const result = await registryService.saveOrgDomains(orgId, domains);
        res.json({ success: true, result });
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to save domain settings.' });
    }
};

/**
 * POST /kpe20/orgaSettings/domains/:domain/verify
 * Confirms ownership of an external domain via its DNS TXT record.
 *
 * Der Endpunkt ist der **einzige** Weg zu `verified` für eine externe Domain.
 * Er liest `_commtool-verify.<domain>` und vergleicht den Wert mit dem Token,
 * den die Domain-Zeile trägt; erst bei Gleichheit wird geschrieben und
 * `registry:domain:changed` gemeldet.
 *
 * Antwort im Erfolgsfall: `{ success: true, result: { domain, record, verifiedAt } }`.
 * Bei Nichtübereinstimmung `400` mit `record`, `expected` und `found` — damit
 * die Oberfläche zeigen kann, was **tatsächlich** im DNS steht, statt den
 * Kunden raten zu lassen.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const verifyDomainController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const result = await registryService.verifyDomain(orgId, req.params.domain);
        if (result.ok) return res.json({ success: true, result });

        // Die Codes sind die Unterscheidung, die der Aufrufer braucht:
        //  400 = der Eintrag ist da, passt aber nicht (oder die Eingabe ist falsch)
        //  404 = die Domain gehört dieser Organisation nicht
        //  409 = Zeile ohne Token / ohne Delegation — Vorbereitung fehlt
        //  502 = der Resolver hat nicht geantwortet — kein Urteil über den Eintrag
        const status = result.code === 'unknown' ? 404
            : (result.code === 'no-token' || result.code === 'no-delegation') ? 409
                : result.code === 'dns-error' ? 502
                    : 400;
        res.status(status).json({ success: false, ...result });
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to verify domain.' });
    }
};

/**
 * POST /kpe20/orgaSettings/domains/:domain/delegation
 * Registers the acme-dns accounts for a zone and returns the CNAME records.
 *
 * Der Schritt **vor** dem Nachweis: ohne gespeichertes Konto gibt es kein Ziel,
 * gegen das `verify` prüfen könnte. Wiederholbar — bestehende Konten bleiben,
 * nur fehlende werden registriert. Er setzt **keinen** Status: die Zone bleibt
 * `pending`, bis die Records auflösen.
 *
 * Antwort: `{ success: true, result: { zone, created[], records[] } }` — die
 * `records` sind genau das, was der Kunde in seinem DNS eintragen muss.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const prepareDelegationController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const result = await registryService.prepareDelegation(orgId, req.params.domain);
        if (result.ok) return res.json({ success: true, result });

        const status = result.code === 'unknown' ? 404
            : result.code === 'ambiguous' ? 409
                : result.code === 'not-configured' ? 503
                    : result.code === 'acme-dns-error' ? 502
                        : 400;
        res.status(status).json({ success: false, ...result });
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to prepare the delegation.' });
    }
};

// ── Mail settings ─────────────────────────────────────────────────────────────

/**
 * GET /kpe20/orgaSettings/mail
 * Returns the SMTP settings for the current organisation (password masked).
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const getMailController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const settings = await service.getMailSettings(orgId);
        res.json({ success: true, result: settings });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load mail settings.' });
    }
};

/**
 * PUT /kpe20/orgaSettings/mail
 * Saves the SMTP settings for the current organisation.
 *
 * Request body: { mailHost, mailPort, mailUser, mailPassword, mailUserName?,
 *                 supportName?, supportEmail?, mailBcc?, mailDebounceMs? }
 *
 * Das ist die Sektion, die der Mailversand liest
 * (`orgas/data/{orgId}/mailserver`).
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
// ── Apps ─────────────────────────────────────────────────────────────────────

/**
 * GET /kpe20/orgaSettings/apps
 * Returns the app registry for the current organisation.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const getAppsController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const apps = await registryService.getOrgApps(orgId);
        // `defaultRoutes` ist **Geschwister** von `result`, nicht darin: die
        // Oberfläche speichert `result[appId]` beim Bearbeiten wörtlich zurück,
        // ein Feld darin landete in `Data` (siehe `defaultRoutesFor`).
        res.json({
            success: true,
            result: apps,
            defaultRoutes: registryService.defaultRoutesFor(orgId, apps),
        });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load app settings.' });
    }
};

/**
 * PUT /kpe20/orgaSettings/apps
 * Saves the app registry for the current organisation.
 *
 * Request body: { [appId]: { domain, roles, title, description?, port? } }
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const saveAppsController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const apps = req.body;
        if (!apps || typeof apps !== 'object' || Array.isArray(apps))
            return res.status(400).json({ success: false, message: 'Request body must be an object.' });

        const validationErrors = Object.entries(apps)
            .map(([appId, appConfig]) => validateAppConfig(appId, appConfig))
            .filter(Boolean);

        if (validationErrors.length > 0)
            return res.status(400).json({ success: false, errors: validationErrors });

        await registryService.saveOrgApps(orgId, apps);
        res.json({ success: true });
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to save app settings.' });
    }
};

/**
 * PUT /kpe20/orgaSettings/apps/:appId
 * Speichert **einen** App-Eintrag.
 *
 * Der Edit-Dialog bearbeitet genau eine Zeile — also wird auch nur diese
 * geschrieben. Über `POST /apps` (ganze Map) wären es bei fünf Apps fünf
 * sequenzielle Schreibvorgänge, obwohl sich eine Zeile geändert hat.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const saveAppController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const appId = req.params.appId;
        const config = req.body;
        const error = validateAppConfig(appId, config);
        if (error)
            return res.status(400).json({ success: false, errors: [error] });

        await registryService.saveOrgApp(orgId, appId, config);
        res.json({ success: true });
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to save app settings.' });
    }
};

/**
 * DELETE /kpe20/orgaSettings/apps/:appId
 * Entfernt **einen** App-Eintrag samt seiner Links.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const deleteAppController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        await registryService.deleteOrgApp(orgId, req.params.appId);
        res.json({ success: true });
    } catch (e) {
        // Wie beim Icon-Upload: eine abgelehnte Eingabe ist kein Serverfehler.
        // Die Admin-App zu löschen ist genau so eine Ablehnung (`statusCode`
        // 400) — als 500 protokolliert müllte sie das Fehler-Log zu.
        const status = e?.statusCode === 400 ? 400 : 500;
        if (status === 500) errorLoggerUpdate(e);
        res.status(status).json({ success: false, message: e.message ?? 'Failed to delete app settings.' });
    }
};


/**
 * POST /kpe20/orgaSettings/apps/:appId/icon
 * Upload an icon image for a specific app to the public bucket.
 * Stores the resulting public URL in Vault under apps[appId].icon.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const uploadAppIconController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const { appId } = req.params;

        const iconUrl = await new Promise((resolve, reject) => {
            const busboy = Busboy({ headers: req.headers });
            /** @type {Promise<string>|null} */
            let uploadPromise = null;

            busboy.on('file', (fieldname, stream, info) => {
                const { mimeType } = info;
                if (!ALLOWED_ICON_MIME.has(mimeType)) {
                    stream.resume();
                    reject(new Error(`File type "${mimeType}" is not allowed. Use PNG, JPG, SVG, GIF or WebP.`));
                    return;
                }
                // Store the promise — busboy may emit 'finish' before the upload completes
                uploadPromise = registryService.uploadOrgAppIcon(orgId, appId, stream, mimeType);
            });

            busboy.on('finish', () => {
                if (!uploadPromise) { reject(new Error('No file received.')); return; }
                uploadPromise.then(resolve).catch(reject);
            });
            busboy.on('error', reject);
            // @ts-ignore — req is a Node.js IncomingMessage at runtime; TypeScript typedef is narrower
            req.pipe(busboy);
        });

        res.json({ success: true, iconUrl });
    } catch (e) {
        // Ein Eingabefehler ist kein Serverfehler: 400, und **nicht** ins
        // Fehler-Log. Eine falsch gewählte Datei ist kein Defekt des Dienstes —
        // als 500 protokolliert müllt sie das Log zu (mehrfach geschehen, siehe
        // `IconInputError`).
        const status = e?.statusCode === 400 ? 400 : 500;
        if (status === 500) errorLoggerUpdate(e);
        res.status(status).json({ success: false, message: e.message ?? 'Failed to upload icon.' });
    }
};

export const saveMailController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const settings = req.body;
        if (!settings || typeof settings !== 'object')
            return res.status(400).json({ success: false, message: 'Request body is required.' });

        // Basic input validation — Feldnamen wie in `orgas/data/{org}/mailserver`
        const portNum = parseInt(settings?.mailPort, 10);
        if (!settings?.mailHost || typeof settings.mailHost !== 'string' || !settings.mailHost.trim())
            return res.status(400).json({ success: false, message: 'Field "mailHost" is required.' });
        if (isNaN(portNum) || portNum < 1 || portNum > 65535)
            return res.status(400).json({ success: false, message: 'Field "mailPort" must be a number between 1 and 65535.' });
        if (!settings?.mailUser || typeof settings.mailUser !== 'string' || !settings.mailUser.trim())
            return res.status(400).json({ success: false, message: 'Field "mailUser" is required.' });

        if (settings.mailDebounceMs === undefined || settings.mailDebounceMs === '' || settings.mailDebounceMs === null) {
            settings.mailDebounceMs = 0;
        } else {
            const debounce = parseInt(settings.mailDebounceMs, 10);
            if (isNaN(debounce) || debounce < 0 || debounce > 60000)
                return res.status(400).json({ success: false, message: 'Field "mailDebounceMs" must be a number between 0 and 60000.' });
            settings.mailDebounceMs = debounce;
        }

        // Sanitise numeric field
        settings.mailPort = portNum;

        await service.saveMailSettings(orgId, settings);
        res.json({ success: true });
    } catch (e) {
        // Ein fehlendes Passwort ist ein Eingabefehler, kein Serverfehler.
        const status = e?.statusCode === 400 ? 400 : 500;
        if (status === 500) errorLoggerUpdate(e);
        res.status(status).json({ success: false, message: status === 400 ? e.message : `Failed to save mail settings: ${e.message}` });
    }
};

// ── Deployments (die eigene Wahl der Organisation) ────────────────────────────
//
// Die Organisation bestimmt hier **ihr eigenes** Release und ihren
// Backend-Zweig — nicht den Katalog. Was wählbar ist, gibt der Vertrag vor
// (`AppBackendBranch`, Importpfad `saveBackendBranches`); was ausgeliefert wird,
// entscheidet der Zeiger (`AppRelease.Current`, CommTool). Diese drei Endpunkte
// können deshalb keinen Katalog und keinen Zeiger verändern — es gibt keinen
// gemeinsamen Schreibweg, und das ist die Absicht (§6.9).

/**
 * GET /kpe20/orgaSettings/deployments
 * Katalog und aktuelle Wahl für alle Apps dieser Organisation.
 *
 * Die Antwort liefert die **Namen** der wählbaren Zweige und Versionen, keine
 * Backend-URLs — ein Kunden-Admin soll die Infrastruktur-Hosts nicht auszählen.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const getDeploymentsController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const catalog = await registryService.getOrgCatalog(orgId);
        res.json({ success: true, result: catalog });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load deployments.' });
    }
};

/**
 * GET /kpe20/orgaSettings/app-offers
 * Das Angebot für den „Add app"-Dialog: je Produkt die Anzeige-Vorgaben und
 * seine Umgebungen (Zweig-Namen) — **ohne** die Ziel-URLs der Zweige.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const getAppOffersController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const offers = await registryService.getAppOffers(orgId);
        // Nur die Anzeige-Felder durchreichen. Die Backend-Ziele (`api`,
        // `baseUrl`, …) sind in `getAppOffers` bereits entfernt — der Kunde wählt
        // den Namen der Umgebung, nicht den Host.
        const products = {};
        for (const [product, offer] of Object.entries(offers.products ?? {})) {
            products[product] = {
                title: offer.title ?? null,
                description: offer.description ?? null,
                icon: offer.icon ?? null,
                roles: Array.isArray(offer.roles) ? offer.roles : [],
                domainPrefix: offer.domain ?? null,
                branches: offer.branches ?? [],
                environments: offer.environments ?? {},
            };
        }
        res.json({
            success: true,
            result: {
                products,
                domains: offers.domains ?? [],
                platformDomain: offers.platformDomain ?? '',
            },
        });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load app offers.' });
    }
};

/**
 * PUT /kpe20/orgaSettings/deployments/:appKey
 * Setzt Version und Backend-Zweig für die eigene Organisation.
 *
 * Body: `{ branch: "prod", version?: "5.9.0" | null }`
 *
 * Die Prüfung gegen Katalog und Releases passiert im Service
 * (`saveOrgDeployment`) und **nicht** hier: beide Regeln brauchen einen
 * Datenbank-Blick, den ein Controller nicht haben soll, und dort ist auch die
 * Meldung formuliert, die der Admin zu sehen bekommt.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const saveDeploymentController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const { appKey } = req.params;
        const { branch, version = null } = req.body ?? {};

        if (typeof branch !== 'string' || !branch.trim())
            return res.status(400).json({ success: false, message: 'Field "branch" is required.' });
        if (version !== null && typeof version !== 'string')
            return res.status(400).json({ success: false, message: 'Field "version" must be a string or null.' });

        const deployment = await registryService.saveOrgDeployment({ appKey, orgId, version, branch });
        res.json({ success: true, result: deployment });
    } catch (e) {
        // Ein unbekannter Zweig oder ein unbekanntes Release ist ein Fehler des
        // Aufrufers, kein Serverfehler — die Meldung nennt die gültigen Werte.
        const isInputError = /^Unknown |^Invalid /.test(e?.message ?? '');
        if (isInputError) {
            return res.status(400).json({ success: false, message: e.message });
        }
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to save deployment.' });
    }
};

/**
 * DELETE /kpe20/orgaSettings/deployments/:appKey
 * Nimmt die Organisation aus der Zuordnung — ab hier folgt sie wieder dem
 * Zeiger und den Backends des Releases.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {ExpressResponse} res
 */
export const clearDeploymentController = async (req, res) => {
    try {
        const orgId = req.session.root;
        if (!orgId)
            return res.status(400).json({ success: false, message: 'No organisation in session.' });

        const removed = await registryService.clearOrgDeployment(req.params.appKey, orgId);
        res.json({ success: true, removed });
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to clear deployment.' });
    }
};