Source: Router/orgaSettings/service.js

/**
 * OrgaSettings Service
 *
 * Manages per-organisation settings stored in Vault:
 *  - Domain mappings  (orgas/data/{orgId}/domains)
 *  - App registry     (orgas/data/{orgId}/apps)
 *  - Mail/SMTP config (orgas/data/{orgId}/mail)
 *
 * Vault path convention (KV v2):
 *   orgas/data/{orgId}/domains  → { "subdomain": "internal", "custom.tld": "external", … }
 *   orgas/data/{orgId}/apps     → { [appId]: { domain, roles, title, description?, port? } }
 *   orgas/data/{orgId}/mail     → { host, port, user, password, userName, from, secure }
 */

import { getSecretsFromVault, saveSecretsToVault } from '@commtool/vault-secrets';
import { errorLoggerRead }      from '../../utils/requestLogger.js';
import { myMinioClient, publicMinioClient, PUBLIC_BUCKET, DATA_BUCKET, publicObjectUrl } from '../../utils/s3Client.js';
import sharp from 'sharp';

// ── Generic Vault section helpers ─────────────────────────────────────────────

/**
 * Load a named section for one organisation from Vault.
 * Returns `null` on missing key or error.
 * @param {string} orgId
 * @param {string} section  e.g. 'domains', 'apps', 'mail'
 * @returns {Promise<object|null>}
 */
async function getOrgSection(orgId, section) {
    try {
        return await getSecretsFromVault(`orgas/data/${orgId}/${section}`) ?? null;
    } catch (e) {
        errorLoggerRead(e);
        return null;
    }
}

/**
 * Persist a named section for one organisation in Vault.
 * @param {string} orgId
 * @param {string} section
 * @param {object} data
 */
async function saveOrgSection(orgId, section, data) {
    await saveSecretsToVault( data, `orgas/data/${orgId}/${section}`);
}

// ── Public API ────────────────────────────────────────────────────────────────

/**
 * Load domain maps for every organisation from Vault.
 *
 * Wird vom `registryService` **nur** im Rückfall gebraucht: bei
 * `REGISTRY_READ_MODE=vault` und im `dual`-Modus, wenn die Datenbank leer ist.
 * Ohne diese Funktion liefe der Rückfall ins Leere (`undefined` aufrufen) — und
 * zwar genau dann, wenn man ihn braucht, nämlich beim Zurückschalten.
 *
 * @returns {Promise<Record<string, Record<string, string>>>}
 */
export async function getAllOrgDomains() {
    const orgsList = await getSecretsFromVault('orgas/data', { list: true }) ?? [];
    /** @type {Record<string, Record<string, string>>} */
    const result = {};

    await Promise.all(
        orgsList.map(async (raw) => {
            const orgId = raw.replace(/\/$/, '');
            try {
                const domains = await getSecretsFromVault(`orgas/data/${orgId}/domains`);
                if (domains && Object.keys(domains).length > 0)
                    result[orgId] = domains;
            } catch {
                // org has no domains configured – skip silently
            }
        })
    );
    return result;
}

/**
 * Load domain settings for one organisation.
 *
 * This is the **Vault side** of the registry: `registryService` uses it as its
 * fallback while `REGISTRY_READ_MODE` is not yet `db`. The validation rules and
 * the cross-organisation conflict check used to live here as well — they moved
 * to `registryTypes.js` / `registryService.js`, because checking a value is not
 * the same job as fetching it: they must run against the store that is being
 * written to, not against the one this module happens to read.
 *
 * @param {string} orgId
 * @returns {Promise<Record<string, string>>}
 */
export async function getDomains(orgId) {
    return /** @type {Record<string, string>} */ (await getOrgSection(orgId, 'domains') ?? {});
}

/**
 * Persist domain settings for one organisation.
 * @param {string} orgId
 * @param {Record<string, string>} domains
 */
export async function saveDomains(orgId, domains) {
    await saveOrgSection(orgId, 'domains', domains);
}

// ── App settings ──────────────────────────────────────────────────────────────

/**
 * @typedef {Object} AppEntry
 * @property {string} domain       - The app's URL or hostname
 * @property {string[]} roles      - Keycloak roles that grant access
 * @property {string} title        - Human-readable app name
 * @property {string} [description]
 * @property {number} [port]
 */

/**
 * Load the app registry for one organisation.
 * @param {string} orgId
 * @returns {Promise<Record<string, AppEntry>>}
 */
export async function getApps(orgId) {
    return /** @type {Record<string, AppEntry>} */ (await getOrgSection(orgId, 'apps') ?? {});
}

/**
 * Der App-Katalog — welche Apps es überhaupt gibt.
 *
 * Er liegt unter einem **reservierten** Pfad, `orgas/data/default/apps`, und ist
 * keine Organisation: keine UID, keine Domains, keine Rollen-Sichtbarkeit —
 * nur die Metadaten, die eine neue Organisation anbieten können soll
 * (`title`, `description`, `icon`, `category`, `roles`).
 *
 * Eine neue Organisation kopiert daraus **einmal**; einen Overlay gibt es
 * bewusst nicht. Ein Vorlagenstand, der sich unter den Mandanten weiterbewegt,
 * macht die Frage „woher stammt dieser Eintrag" unbeantwortbar — und ein
 * Katalog, der beim Lesen gemischt wird, sähe für zwei Aufrufer verschieden aus,
 * je nachdem, wer ihn zuerst gelesen hat.
 *
 * Es gibt hier keine DB-Variante: der Import lässt `default` ausdrücklich aus
 * (`migrateVaultToRegistry.js` überspringt Nicht-UUID-Ordner), damit kein
 * verwaister Mandant ohne `UIDBelongsTo` entsteht.
 *
 * @returns {Promise<Record<string, AppEntry>>}
 */
export async function getAppCatalog() {
    return /** @type {Record<string, AppEntry>} */ (await getOrgSection('default', 'apps') ?? {});
}

/**
 * Persist the app registry for one organisation.
 * @param {string} orgId
 * @param {Record<string, AppEntry>} apps
 */
export async function saveApps(orgId, apps) {
    await saveOrgSection(orgId, 'apps', apps);
}

/** MIME type → file extension map for accepted icon formats */
const ICON_EXT = {
    'image/png':     'png',
    'image/jpeg':    'jpg',
    'image/svg+xml': 'svg',
    'image/gif':     'gif',
    'image/webp':    'webp',
};

/** Standard PWA icon variants written to the data bucket under manifests path */
const PWA_ICON_SIZES = [
    { name: 'icon-192.png',          size: 192 },
    { name: 'icon-512.png',          size: 512 },
    { name: 'icon-192-maskable.png', size: 192 },
    { name: 'icon-512-maskable.png', size: 512 },
    { name: 'favicon.png',           size: 32  },
];

/** Put a Buffer into MinIO — uses publicMinioClient for PUBLIC_BUCKET, myMinioClient otherwise */
function minioput(bucket, key, buffer, mimeType) {
    const client = bucket === PUBLIC_BUCKET ? (publicMinioClient ?? myMinioClient) : myMinioClient;
    return new Promise((resolve, reject) => {
        client.putObject(bucket, key, buffer, buffer.length, { 'Content-Type': mimeType },
            (err) => { if (err) reject(err); else resolve(); }
        );
    });
}

/**
 * Upload an app icon:
 *  1. Stores the original in the public bucket (URL saved in Vault apps[appId].icon).
 *  2. Uses sharp to generate all standard PWA sizes and writes them to the data
 *     bucket at `{orgId}/manifests/{appId}/{iconName}` — the exact paths the
 *     static server looks up, so no redirect is needed after this.
 *
 * @param {string} orgId
 * @param {string} appId
 * @param {import('stream').Readable} fileStream
 * @param {string} mimeType
 * @returns {Promise<string>} The public icon URL
 */
export async function uploadAppIcon(orgId, appId, fileStream, mimeType) {
    const ext = ICON_EXT[mimeType];
    if (!ext) throw new Error(`Unsupported image type: ${mimeType}`);

    // Buffer the stream once — needed for both original upload and sharp resizing
    const chunks = [];
    for await (const chunk of fileStream) chunks.push(chunk);
    const inputBuffer = Buffer.concat(chunks);

    // ── 1. Store original in public bucket ────────────────────────────────────
    const originalKey = `${orgId}/icons/${appId}-${Date.now()}.${ext}`;
    await minioput(PUBLIC_BUCKET, originalKey, inputBuffer, mimeType);
    const iconUrl = publicObjectUrl(originalKey);

    // ── 2. Generate and store all PWA icon sizes in data bucket ───────────────
    const baseImage = sharp(inputBuffer);
    await Promise.all(
        PWA_ICON_SIZES.map(async ({ name, size }) => {
            const resized = await baseImage.clone().resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
            const key = `${orgId}/manifests/${appId}/${name}`;
            await minioput(DATA_BUCKET, key, resized, 'image/png');
        })
    );

    // ── 3. Persist URL in Vault ───────────────────────────────────────────────
    const apps = await getOrgSection(orgId, 'apps') ?? {};
    if (!apps[appId]) throw new Error(`App "${appId}" not found for this organisation.`);

    // Delete old original from public bucket (best-effort)
    const oldIconUrl = apps[appId].icon;
    if (oldIconUrl) {
        try {
            const oldKey = oldIconUrl.replace(
                `${process.env.S3publicBaseUrl ?? `https://${process.env.publicS3endPoint ?? process.env.S3endPoint}:${process.env.publicS3port ?? process.env.S3port}`}/${PUBLIC_BUCKET}/`, ''
            );
            const publicClient = publicMinioClient ?? myMinioClient;
            await publicClient.removeObject(PUBLIC_BUCKET, oldKey);
        } catch { /* non-fatal */ }
    }

    apps[appId] = { ...apps[appId], icon: iconUrl };
    await saveOrgSection(orgId, 'apps', apps);

    return iconUrl;
}

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

const MASKED = '••••••••';

/**
 * Load mail/SMTP settings for one organisation.
 * The password is masked before being returned.
 * @param {string} orgId
 * @returns {Promise<object|null>}
 */
export async function getMailSettings(orgId) {
    const settings = await getOrgSection(orgId, 'mail');
    if (!settings) return null;
    return {
        ...settings,
        password: settings.password ? MASKED : '',
    };
}

/**
 * Persist mail/SMTP settings for one organisation.
 * If the password is the mask sentinel, the existing password is preserved.
 * @param {string} orgId
 * @param {object} settings
 */
export async function saveMailSettings(orgId, settings) {
    let dataToSave = { ...settings };

    if (dataToSave.password === MASKED) {
        // Password not changed – keep existing value from Vault
        const existing = await getOrgSection(orgId, 'mail') ?? {};
        dataToSave.password = existing.password ?? '';
    }

    await saveOrgSection(orgId, 'mail', dataToSave);
}