Source: scripts/migrateVaultToRegistry.js

// @ts-check
/**
 * Vault → Registry-Import (`ObjectBase`/`Links`)
 *
 * Überführt `orgas/data/{orgId}/apps` und `.../domains` aus Vault in das
 * App-Registry. Mit `--dry-run` wird **nichts geschrieben**, sondern der
 * Unterschied zwischen beiden Quellen berichtet — das ist der Nachweis D1
 * („Registry liefert gleiche Zahlen wie Vault") und die Voraussetzung dafür,
 * `REGISTRY_READ_MODE` von `dual` auf `db` zu stellen.
 *
 * ## Ablauf
 *
 *   node src/scripts/migrateVaultToRegistry.js --dry-run     # nur berichten
 *   node src/scripts/migrateVaultToRegistry.js               # schreiben
 *   node src/scripts/migrateVaultToRegistry.js --org=UUID-…  # eine Organisation
 *
 * ## Warum der Modus erzwungen wird
 *
 * Das Skript setzt `REGISTRY_READ_MODE=db`, **bevor** der Service importiert
 * wird. Sonst würde `saveOrgApps` im Modus `dual` den Vault zurückschreiben —
 * also genau die Quelle, aus der gerade gelesen wird. Der Import ist eine
 * Einbahnstraße.
 *
 * ## Icons
 *
 * Der Plan sah vor, base64-Icons aus `orgData.pwa.icons` nach S3 zu kopieren.
 * Das entfällt: `apps[appId].icon` ist bereits eine öffentliche S3-URL, und die
 * PWA-Varianten (`{orgId}/manifests/{appId}/…`) schreibt ausschließlich
 * `uploadOrgAppIcon`. In `members-back` gibt es keine PWA-Lesestelle — geprüft.
 *
 * ## Schreiben geht über den Service, nicht über eigenes SQL
 *
 * Damit die Upsert-Logik **genau** die ist, die später auch das Frontend
 * auslöst. Ein zweiter Schreibpfad wäre eine zweite Wahrheit. Insbesondere die
 * UID-Behandlung (bestehende Objekte behalten ihre UID) kommt damit aus
 * derselben Stelle wie im Betrieb.
 *
 * ## Zugangsdaten und Ziel-Datenbank
 *
 * Die DB-Zugangsdaten stehen **nicht** im Container-Environment, sondern in
 * Vault unter `GIT_SECRET_PATH`. Das Skript lädt sie deshalb selbst über
 * `loadSecretsFromVault()` — dieselbe Reihenfolge wie beim App-Start.
 *
 * **Vor dem Schreiben prüfen, wohin geschrieben wird.** In dieser Umgebung
 * zeigt `GIT_SECRET_PATH=…/devProd` auf `member@10.1.1.22` mit dem Benutzer
 * `commtool-prod` — also auf die Produktions-Datenbank. `--dry-run` ist dort
 * die einzige gefahrlose Betriebsart.
 */

process.env.REGISTRY_READ_MODE = 'db';

import { pathToFileURL } from 'node:url';

import { recreateMainConnection, recreatePool } from '@commtool/sql-query';
import { getSecretsFromVault, loadSecretsFromVault } from '@commtool/vault-secrets';
import * as registryService from '../Router/orgaSettings/registryService.js';
import { isValidUid, normalizeDomainMap } from '../Router/orgaSettings/registryTypes.js';

// ── CLI ───────────────────────────────────────────────────────────────────────

const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry-run');
const VERBOSE = args.includes('--verbose');
const onlyOrg = args.find((a) => a.startsWith('--org='))?.slice('--org='.length) ?? null;

// ── Vergleich (D1) ────────────────────────────────────────────────────────────

/**
 * Legt `value` auf die Felder von `shape` um.
 *
 * Ohne diesen Schritt wäre der Bericht **immer** rot, und zwar aus einem Grund,
 * der kein Fehler ist: die Registry hängt dem App-Objekt die `appId` an (sie ist
 * der Map-Schlüssel und liegt deshalb in `Data`), Vault kennt das Feld nicht.
 * Ein `JSON.stringify`-Vergleich sähe darin eine Abweichung und meldete jede App
 * als „~ abweichend" — ein Bericht, der nie grün wird, wird nicht gelesen.
 *
 * Verglichen wird deshalb genau das, was Vault **behauptet**: ein Feld, das in
 * Vault fehlt, kann in der DB hinzukommen. Ein Feld, das Vault hat und die DB
 * verloren hat, bleibt eine Abweichung.
 *
 * @param {unknown} shape - die Vault-Seite (bestimmt die Feldmenge)
 * @param {unknown} value - die DB-Seite
 * @returns {unknown}
 */
const projectOnto = (shape, value) => {
    if (!shape || typeof shape !== 'object' || Array.isArray(shape)) return value;
    const projected = {};
    for (const key of Object.keys(shape)) projected[key] = value?.[key];
    return projected;
};

/**
 * Vergleicht zwei flache Maps (appId bzw. domain → Wert) und beschreibt die
 * Unterschiede. Bewusst auf Schlüssel-Ebene: „Zahl stimmt, Inhalt nicht" ist
 * der Fall, der bei einem reinen Zähler-Vergleich durchrutscht.
 * @param {Record<string, unknown>} vault
 * @param {Record<string, unknown>} db
 * @returns {string[]} Zeilen für den Bericht (leer = gleich)
 */
export const diffMaps = (vault, db) => {
    const lines = [];
    const vaultKeys = Object.keys(vault).sort();
    const dbKeys = Object.keys(db).sort();

    for (const key of vaultKeys.filter((k) => !dbKeys.includes(k))) lines.push(`      + fehlt in DB: ${key}`);
    for (const key of dbKeys.filter((k) => !vaultKeys.includes(k))) lines.push(`      - nur in DB:   ${key}`);
    for (const key of vaultKeys.filter((k) => dbKeys.includes(k))) {
        const expected = JSON.stringify(vault[key]);
        const actual = JSON.stringify(projectOnto(vault[key], db[key]));
        if (expected !== actual) {
            lines.push(`      ~ abweichend:  ${key}`);
            if (VERBOSE) {
                lines.push(`          vault: ${expected}`);
                lines.push(`          db:    ${actual}`);
            }
        }
    }
    return lines;
};

// ── Hauptlauf ─────────────────────────────────────────────────────────────────

const main = async () => {
    // DB-Zugangsdaten stehen nicht im Container-Environment, sondern in Vault
    // unter `GIT_SECRET_PATH`. Ohne diesen Schritt wäre `DB_DATABASE` leer und
    // `recreateMainConnection` würde eine Verbindung zu `undefined` aufbauen.
    // Genau dieselbe Reihenfolge wie beim App-Start in `server.js`.
    await loadSecretsFromVault();

    // Der Service liest seine Verbindung aus der Umgebung; ohne diesen Schritt
    // wartet `query()` auf eine `mainConnection`, die nie entsteht.
    await recreateMainConnection(process.env.DB_DATABASE);
    await recreatePool(process.env.DB_DATABASE);

    const listed = await getSecretsFromVault('orgas/data', { list: true }) ?? [];
    // KV-v2-Listen liefern Verzeichnisse mit abschließendem "/" — als Org-ID
    // zählt der Name davor.
    let orgIds = listed.map((raw) => String(raw).replace(/\/$/, '')).filter(Boolean);
    if (onlyOrg) orgIds = orgIds.filter((id) => id === onlyOrg);

    // `orgas/data` ist ein Vault-Verzeichnis, keine Org-Liste: dort liegt z.B.
    // auch `default` (eine Vorlage, keine Organisation). Solche Namen sind keine
    // UID, und `U_UUID2BIN('default')` ergibt **NULL** — die Zeilen wären danach
    // unter keinem `UIDBelongsTo` auffindbar, also für immer verwaist. Deshalb
    // hier abweisen statt später aufräumen.
    const notAnOrg = orgIds.filter((id) => !isValidUid(id));
    orgIds = orgIds.filter((id) => isValidUid(id));

    console.log(`Vault-Import ${DRY_RUN ? '(DRY RUN — es wird nichts geschrieben)' : '(schreibend)'}`);
    console.log(`Organisationen: ${orgIds.length}${onlyOrg ? ` (gefiltert auf ${onlyOrg})` : ''}\n`);
    if (notAnOrg.length > 0) {
        console.log(`Übersprungen (keine Org-UID): ${notAnOrg.join(', ')}\n`);
    }

    const summary = { orgs: 0, withData: 0, written: 0, differences: 0 };

    for (const orgId of orgIds) {
        summary.orgs++;

        const [vaultApps, vaultDomains] = await Promise.all([
            getSecretsFromVault(`orgas/data/${orgId}/apps`).catch(() => null),
            getSecretsFromVault(`orgas/data/${orgId}/domains`).catch(() => null),
        ]);

        const apps = vaultApps ?? {};
        const domains = vaultDomains ?? {};
        const appCount = Object.keys(apps).length;
        const domainCount = Object.keys(domains).length;

        if (appCount === 0 && domainCount === 0) {
            if (VERBOSE) console.log(`  ${orgId}: nichts zu tun`);
            continue;
        }
        summary.withData++;

        // Ist-Zustand der DB — für den Bericht immer, für den Schreiblauf nur
        // informativ (der Service macht selbst Upsert).
        const dbApps = await registryService.getOrgApps(orgId);
        const dbDomains = await registryService.getOrgDomains(orgId);

        const appDiffs = diffMaps(apps, dbApps);
        // Die Vault-Seite trägt hier eine **Zeichenkette** (`"verified"`), die DB
        // seit der Umstellung `{ type, status }`. Ohne diese Übersetzung meldete
        // der Bericht jede Domain als abweichend — obwohl beide dasselbe sagen.
        const domainDiffs = diffMaps(normalizeDomainMap(domains), dbDomains);

        if (appDiffs.length > 0 || domainDiffs.length > 0) summary.differences++;

        console.log(`  ${orgId}`);
        console.log(`    Vault: ${appCount} Apps, ${domainCount} Domains | DB: ${Object.keys(dbApps).length} Apps, ${Object.keys(dbDomains).length} Domains`);
        if (appDiffs.length > 0) { console.log('    Apps:'); appDiffs.forEach((l) => console.log(l)); }
        if (domainDiffs.length > 0) { console.log('    Domains:'); domainDiffs.forEach((l) => console.log(l)); }
        if (appDiffs.length === 0 && domainDiffs.length === 0) console.log('    identisch');

        if (!DRY_RUN) {
            // Domains zuerst, dann Apps: das ist die Reihenfolge der
            // Vault-Quelle und hält den Bericht lesbar. Abhängig sind die
            // beiden nicht mehr — der App-Host steht im App-Objekt selbst,
            // es gibt kein Domain-Objekt mehr, das vorher da sein müsste.
            await registryService.saveOrgDomains(orgId, domains);
            await registryService.saveOrgApps(orgId, apps);
            summary.written++;
        }
    }

    console.log(`\nZusammenfassung: ${summary.withData} von ${summary.orgs} Organisationen mit Daten, ${summary.differences} mit Abweichungen`);
    if (DRY_RUN) {
        console.log('DRY RUN — nichts geschrieben. Ohne --dry-run wird importiert.');
    } else {
        console.log(`${summary.written} Organisationen geschrieben.`);
        console.log('Hinweis: erst nach diesem Lauf REGISTRY_READ_MODE auf "db" stellen — vorher "dual" lassen.');
    }
};

// Nur beim direkten Aufruf starten. Ohne diese Sperre würde schon ein Import —
// etwa aus einem Test — die Datenbank verbinden und Vault abfragen.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
    main()
        .then(() => process.exit(0))
        .catch((e) => {
            console.error('Import fehlgeschlagen:', e);
            process.exit(1);
        });
}