Source: Router/registry/registryRouter.js

// @ts-check
/**
 * @import {ExpressRequestAuthorized, ExpressResponse} from '../../types.js'
 */

/**
 * Registry Router — Laufzeit-Sicht auf das App-Registry
 *
 * Für **Maschinen**, nicht für Menschen: Broker, Backends und Bots lesen hier die
 * Host→Org→App-Zuordnung. Bedient wird das Registry (Schreiben) weiterhin über
 * `/api/kpe20/orgaSettings/*` — dieselbe Datenbasis, andere Zielgruppe.
 *
 * Mounted at: /api/registry  (siehe http-server.js)
 *
 * ## Die Grenze: `bot` und `employee` — aber kein Mandanten-Nutzer
 *
 * Der Router verlangt `makeAuthCheck(['bot', 'employee'])`. Das ist bewusst
 * **nicht** `['user']` und auch nicht die `checkAdmin`-Prüfung der
 * `orgaSettings`-Endpunkte, die einen Mandanten-`db-admin` durchlässt.
 *
 * Der Grund ist `/domains`: der Endpunkt liefert die Host-Zuordnung **aller**
 * Organisationen — `/cors/origins` entsprechend. Diese Sicht hat bisher nur im
 * Backend existiert (Vault-Scan über alle Orgs) und ist nie adressierbar
 * gewesen. Mit diesem Router wird sie es. Ohne eigenes Gate könnte ein
 * `db-admin` des Kunden A die Domain-Liste **aller** anderen Kunden lesen, also
 * Mandanten auszählen. Die Trennung verläuft damit nicht zwischen „angemeldet"
 * und „nicht angemeldet", sondern zwischen **Mandant** und **Plattform**:
 *
 * | Prinzipal | Zugang | Warum |
 * |---|---|---|
 * | `bot` (Service-Account) | ja | Broker und Backends lesen die Zuordnung pro Request |
 * | `employee` (CommTool-Personal) | ja | das interne Frontend (§2.7 der Planung) |
 * | `user` mit `db-admin` | **nein** | Mandanten-Admin — genau das Leck |
 * | nicht angemeldet | nein | — |
 *
 * **Warum `bot` mit drin ist, obwohl „nur employees" naheliegend wäre:** die
 * Konsumenten dieses Endpunkts sind Maschinen. `shared-auth`
 * (`organizationDomains.js`) und der Static-Server holen die Domain-Liste mit
 * einem **Service-Token** — nach „nur employees" hätten sie keinen Zugang mehr
 * und die Host-Auflösung wäre tot. Das Leck, das geschlossen werden soll, ist
 * der Mandanten-Admin, nicht der Service-Account.
 *
 * Die org-scoped Endpunkte (`/:orgId/apps*`) vertragen denselben Kreis: die
 * `orgId` steht im Pfad, ein Mandanten-Nutzer ist ausgesperrt, und Personal darf
 * jede Organisation einsehen — das ist seine Aufgabe.
 *
 * @swagger
 * tags:
 *   - name: Registry
 *     description: |
 *       Runtime view of the app registry for brokers, backends and bots.
 *       Schemas are defined in ./registry/registry.swagger.yaml.
 *       Use $ref: './registry/registry.swagger.yaml#/components/schemas/{SchemaName}' in route blocks.
 */

import { Router } from 'express';
import { makeAuthCheck } from '@commtool/shared-auth';
import { errorLoggerRead, errorLoggerUpdate } from '../../utils/requestLogger.js';
import * as registryService from '../orgaSettings/registryService.js';
import { importContract } from './contractImport.js';

const router = Router();

/**
 * Das Gate für den gesamten Router — `bot` und `employee`, kein Mandanten-Nutzer.
 * Begründung und Abgrenzung: Dateikopf.
 *
 * Statischer Import ist hier in Ordnung: `addons.js` macht es genauso, und die
 * Middleware wird erst beim ersten Request ausgeführt — zu diesem Zeitpunkt sind
 * die Secrets konfiguriert.
 */
const registryAuth = makeAuthCheck(['bot', 'employee']);

/**
 * @swagger
 * /api/registry/resolve:
 *   get:
 *     summary: Resolve a host to organisation and app
 *     description: >
 *       Returns which organisation and which app serve the given host. Used by the
 *       broker on every request and by backends that need their domain context.
 *       Returns 404 when the host has no app — a host may exist without one.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: query
 *         name: host
 *         required: true
 *         schema:
 *           type: string
 *         example: myclub.app.kpe.de
 *     responses:
 *       200:
 *         description: Mapping found
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/DomainResolution'
 *       400:
 *         description: Missing host query parameter
 *       404:
 *         description: No mapping for this host
 */
router.get('/resolve', registryAuth, async (req, res) => {
    try {
        const host = typeof req.query.host === 'string' ? req.query.host : '';
        if (!host) return res.status(400).json({ success: false, message: 'host query param required' });

        const result = await registryService.resolveDomain(host);
        if (!result) return res.status(404).json({ success: false, message: 'No mapping found' });
        res.json({ success: true, result });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to resolve host.' });
    }
});

/**
 * @swagger
 * /api/registry/domains:
 *   get:
 *     summary: All domain → organisation → app mappings (cross-organisation)
 *     description: >
 *       Returns the mapping for every organisation. This is a cross-tenant view
 *       and is therefore restricted to CommTool staff (`employees`); an
 *       organisation admin must not be able to enumerate other tenants.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: All mappings
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   type: array
 *                   items:
 *                     $ref: './registry/registry.swagger.yaml#/components/schemas/DomainMapping'
 *       403:
 *         description: Caller is a tenant user or unauthenticated
 */
router.get('/domains', registryAuth, async (_req, res) => {
    try {
        const mappings = await registryService.getAllDomainMappings();
        res.json({ success: true, result: mappings });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load domain mappings.' });
    }
});

/**
 * @swagger
 * /api/registry/cors/origins:
 *   get:
 *     summary: CORS origins for all external domains (cross-organisation)
 *     description: >
 *       Cross-tenant view, restricted to CommTool staff (`employees`) — see
 *       /api/registry/domains for the reasoning.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: Origin list
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   type: array
 *                   items:
 *                     type: string
 *                   example: ['https://myclub.de']
 *       403:
 *         description: Caller is a tenant user or unauthenticated
 */
router.get('/cors/origins', registryAuth, async (_req, res) => {
    try {
        const origins = await registryService.getAllCorsOrigins();
        res.json({ success: true, result: origins });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load CORS origins.' });
    }
});

/**
 * @swagger
 * /api/registry/app-catalog:
 *   get:
 *     summary: The app catalogue — which apps exist at all
 *     description: >
 *       The template a new organisation is seeded from
 *       (`orgas/data/default/apps`). It carries only presentation metadata
 *       (`title`, `description`, `icon`, `category`, `roles`) — no domain, no
 *       organisation, no UID. This is deliberately **not** an overlay: an
 *       organisation's own apps are never merged with it, so an entry that
 *       came from the catalogue stays readable as such.
 *
 *       It is a platform-level view across all tenants, hence the same gate as
 *       `/domains`.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: Catalogue map, keyed by appId
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/AppsMap'
 *       403:
 *         description: Caller is a tenant user or unauthenticated
 */
router.get('/app-catalog', registryAuth, async (_req, res) => {
    try {
        const catalog = await registryService.getAppCatalog();
        res.json({ success: true, result: catalog });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load app catalog.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/apps:
 *   get:
 *     summary: App registry of one organisation
 *     description: >
 *       Returns the app map exactly as the admin frontend maintains it
 *       (`{ [appId]: { domain, roles, title, … } }`), so portal and bots can
 *       consume the same shape they used from Vault.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *         example: UUID-4efce539-bd70-11f1-b929-5e3866f0f490
 *     responses:
 *       200:
 *         description: App map
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/AppsMap'
 */
router.get('/:orgId/apps', registryAuth, async (req, res) => {
    try {
        const apps = await registryService.getOrgApps(req.params.orgId);
        res.json({ success: true, result: apps });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load apps.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/apps/{appId}:
 *   get:
 *     summary: One app of one organisation
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *       - in: path
 *         name: appId
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: App entry
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/AppEntry'
 *       404:
 *         description: App not found
 */
router.get('/:orgId/apps/:appId', registryAuth, async (req, res) => {
    try {
        const app = await registryService.getOrgApp(req.params.orgId, req.params.appId);
        if (!app) return res.status(404).json({ success: false, message: 'App not found' });
        res.json({ success: true, result: app });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load app.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/apps/{appId}/manifest:
 *   get:
 *     summary: PWA manifest / branding for one app
 *     description: >
 *       Replaces the Vault-backed PWA cache in protected-static-server: name,
 *       short_name and icons come from the registry.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *       - in: path
 *         name: appId
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Web app manifest
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                 result:
 *                   type: object
 *       404:
 *         description: App or manifest not found
 */
router.get('/:orgId/apps/:appId/manifest', registryAuth, async (req, res) => {
    try {
        const manifest = await registryService.getAppManifest(req.params.orgId, req.params.appId);
        if (!manifest) return res.status(404).json({ success: false, message: 'Manifest not found' });
        res.json({ success: true, result: manifest });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load manifest.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/releases/{appKey}:
 *   get:
 *     summary: Resolve which release an organisation receives
 *     description: >
 *       Applies the release resolution: one lookup by the name the organisation
 *       chose (`OrgAppDeployment.Version`), or `stable` when it chose none. The
 *       broker uses this to pick the S3 artefact; `env.js` uses the `backends`
 *       field of the same answer, which is why frontend and backend choice
 *       cannot drift apart. Returns 404 when the app has no release at all —
 *       that is a real 503 upstream, not an empty artefact.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *         example: UUID-4efce539-bd70-11f1-b929-5e3866f0f490
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Resolved release
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/ResolvedRelease'
 *       404:
 *         description: No release for this app
 */
router.get('/:orgId/releases/:appKey', registryAuth, async (req, res) => {
    try {
        const { orgId, appKey } = req.params;
        const release = await registryService.resolveRelease(appKey, orgId);
        if (!release) return res.status(404).json({ success: false, message: 'No release for this app' });
        res.json({ success: true, result: release });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to resolve release.' });
    }
});

/**
 * @swagger
 * /api/registry/releases/{appKey}:
 *   get:
 *     summary: All releases of one app
 *     description: >
 *       Every stored `(appKey, version)` with its prefix. `version` is a
 *       **name** the organisation can select (`latest`, `stable`, `5.9.0`).
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Release list, newest name first
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   type: array
 *                   items:
 *                     $ref: './registry/registry.swagger.yaml#/components/schemas/ResolvedRelease'
 */
router.get('/releases/:appKey', registryAuth, async (req, res) => {
    try {
        const releases = await registryService.listAppReleases(req.params.appKey);
        res.json({ success: true, result: releases });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load releases.' });
    }
});

/**
 * @swagger
 * /api/registry/{orgId}/env/{appKey}:
 *   get:
 *     summary: Per-organisation env.js overlay for an app
 *     description: >
 *       Returns the backends of the **branch** this organisation selected
 *       (`OrgAppDeployment.Branch` → `AppBackendBranch.Backends`), under exactly
 *       the keys the frontend reads (`api`, `apiPortal`, …) — no renaming, so a
 *       new backend name needs no code change here.
 *
 *       `env: null` is valid and means "no branch selected — keep the deployment
 *       values". Only an unknown app yields 404, which is what separates "no
 *       choice" from "wrong app key".
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: orgId
 *         required: true
 *         schema:
 *           type: string
 *         example: UUID-4efce539-bd70-11f1-b929-5e3866f0f490
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Env overlay (may be null)
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/ReleaseEnv'
 *       404:
 *         description: The app has no release at all
 */
router.get('/:orgId/env/:appKey', registryAuth, async (req, res) => {
    try {
        const { orgId, appKey } = req.params;
        // Eine Auflösung, beide Antworten: `env` kommt aus demselben Release,
        // aus dem auch das Artefakt kommt — genau die Kopplung aus §6.2.
        const resolved = await registryService.resolveReleaseEnv(appKey, orgId);
        if (!resolved) return res.status(404).json({ success: false, message: 'No release for this app' });
        res.json({ success: true, result: { orgId, appKey, ...resolved } });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to resolve env overlay.' });
    }
});

// ── Schreiben: Plattform und Vertrag ──────────────────────────────────────────
//
// Der Router war bis hier ausschließlich lesend — und die Schreibfunktionen des
// Service waren repoweit **ohne Aufrufer**. Das war die Lücke aus §6.9: „wo legt
// der Admin fest, welches Backend die App benutzt?" hatte keine Antwort, weil es
// keinen Weg in die Tabellen gab.
//
// Zwei Gates, zwei Zuständigkeiten — und die Trennung ist der Punkt, nicht die
// Bequemlichkeit:
//
// | Weg | Gate | Wer |
// |---|---|---|
// | `/backend-branches/:appKey` | `bot` + `employee` | der Importer (Service-Token) |
// | `/releases` | `employee` + `release` | CommTool-Personal **und** der Release-Job |
// | `/…/deployments/…` (orgaSettings) | `checkAdmin` | der Kunde, **eigene** Org |
//
// Der Kunde schreibt **nicht** hier. Das ist dieselbe Grenze wie beim Lesen
// (Dateikopf): `admin.app` bedient alle Kunden, und ein `db-admin` erreicht über
// diesen Router sonst fremde Mandanten. Sein Weg liegt in `orgaSettings`, wo die
// Organisation aus der Session kommt und nicht aus dem Pfad.

/**
 * Der Importer schreibt mit einem **Service-Token** (`bot`) — deshalb steht
 * `bot` hier mit drin, obwohl ein Vertragsimport eine Plattform-Sache ist.
 * Es ist derselbe Kompromiss wie beim Lesen (Dateikopf): der Konsument ist eine
 * Maschine, und das Leck, das geschlossen werden soll, ist der Mandanten-Admin,
 * nicht der Service-Account.
 */
const contractAuth = makeAuthCheck(['bot', 'employee']);

/** Plattform-Schreibvorgänge: ausschließlich CommTool-Personal. */
const platformAuth = makeAuthCheck(['employee']);

/**
 * Der Release-Job: er legt die Release-Zeilen an — die konkrete Version
 * (`5.9.0`) und die Namen `latest`/`stable` — PLAN.md §6.2.
 *
 * Sein Kennzeichen ist eine **Gruppe** (`release-ci`), kein Mensch-Konto, und
 * bewusst **nicht** `app-bot`: sonst dürfte jedes Bot-Token Releases schreiben.
 * `employee` bleibt daneben, weil dieselben Routen auch das CommTool-Frontend
 * bedient (§2.7).
 */
const releaseAuth = makeAuthCheck(['employee', 'release']);

/**
 * @swagger
 * /api/registry/backend-branches/{appKey}:
 *   put:
 *     summary: Import the backend branch contract for an app
 *     description: >
 *       Replaces the branch catalogue of one app — the **offer**, not the
 *       choices. Called by the importer process that watches the mounted YAML
 *       file in the broker repository; idempotent, so the same contract may be
 *       pushed repeatedly.
 *
 *       A branch that disappears from the contract is removed only when no
 *       organisation still selects it. Such a branch is reported as `kept`
 *       instead: the import stays clean, but the contract is wrong, and that has
 *       to be visible rather than silently pull a backend out from under a
 *       tenant. Backend values are delivered to the browser, so keys that look
 *       like secrets are rejected here.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: './registry/registry.swagger.yaml#/components/schemas/BackendBranchContract'
 *           example:
 *             prod:
 *               api: member
 *               baseUrl: api.commtool.org/api
 *             test:
 *               api: member
 *               baseUrl: test.api.commtool.org/api
 *     responses:
 *       200:
 *         description: Import result
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './registry/registry.swagger.yaml#/components/schemas/BackendBranchImportResult'
 *       400:
 *         description: Invalid branch name or backend values
 *       500:
 *         description: Import failed
 */
router.put('/backend-branches/:appKey', contractAuth, async (req, res) => {
    try {
        const result = await registryService.saveBackendBranches(req.params.appKey, req.body);
        res.json({ success: true, result });
    } catch (e) {
        // Ein ungültiger Vertrag ist ein Fehler des Absenders, kein Serverfehler.
        // Der Importer soll die Meldung sehen und nicht auf einen Retry hoffen.
        if (typeof e?.message === 'string' && /^Invalid |: /.test(e.message)) {
            return res.status(400).json({ success: false, message: e.message });
        }
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to import backend branches.' });
    }
});

/**
 * @swagger
 * /api/registry/app-environment/{appKey}:
 *   put:
 *     summary: Import the app environment of the contract
 *     description: >
 *       Writes the `AppCatalog` row for one app — the flat env.js object that is
 *       this app's app-level base (`api`, `baseUrl`, `NODE_ENV`, and the display
 *       defaults `title`, `icon`, `roles`, `domain`). A branch
 *       (`/backend-branches`) overrides it key by key. This is CommTool's write
 *       (the contract import), never the org admin's: an organisation's override
 *       lives on its `ObjectBase` app and is untouched.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             example:
 *               NODE_ENV: production
 *               title: Mitgliederdatenbank
 *               roles: [member]
 *               domain: db
 *               api: member
 *               baseUrl: api.commtool.org/api
 *     responses:
 *       200:
 *         description: App environment saved
 *       400:
 *         description: Invalid app key or environment
 *       500:
 *         description: Import failed
 */
router.put('/app-environment/:appKey', contractAuth, async (req, res) => {
    try {
        const result = await registryService.saveAppCatalog(req.params.appKey, req.body ?? {});
        res.json({ success: true, result });
    } catch (e) {
        // Wie beim Zweig-Import: ein ungültiger Vertrag ist ein Fehler des
        // Absenders. Der Importer soll die Meldung sehen, nicht auf einen Retry
        // hoffen.
        if (typeof e?.message === 'string' && /^Invalid |must /.test(e.message)) {
            return res.status(400).json({ success: false, message: e.message });
        }
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to import app environment.' });
    }
});

/**
 * @swagger
 * /api/registry/contract/reload:
 *   post:
 *     summary: Re-import the backend contract from the config bucket
 *     description: >
 *       Reads `config/contract/backends.yaml` from the config bucket and writes
 *       `AppCatalog` (app level) and `AppBackendBranch` (branches) — the same
 *       import that runs at startup. CommTool's write (contract import), never
 *       the org admin's: an organisation's override on its `ObjectBase` app is
 *       untouched, and a branch a tenant still selects is kept, not removed.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: Import result
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   type: object
 *       400:
 *         description: Invalid contract (shape)
 *       404:
 *         description: No contract found in the bucket
 *       500:
 *         description: Import failed
 */
router.post('/contract/reload', contractAuth, async (_req, res) => {
    try {
        const result = await importContract();
        if (!result.ok) {
            // Kein Vertrag ist kein Serverfehler — die Auslieferung läuft mit dem
            // letzten Katalog weiter. Der Aufrufer soll die Ursache sehen.
            return res.status(404).json({ success: false, message: `Contract not imported: ${result.reason}` });
        }
        res.json({ success: true, result });
    } catch (e) {
        // Ein formfehlerhafter Vertrag ist ein Fehler des Absenders (400), kein
        // Serverfehler — dieselbe Trennung wie beim Zweig- und App-Import.
        if (typeof e?.message === 'string'
            && (/^"/.test(e.message) || /not an object|is missing/.test(e.message))) {
            return res.status(400).json({ success: false, message: e.message });
        }
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to import contract.' });
    }
});

/**
 * @swagger
 * /api/registry/releases/reload:
 *   post:
 *     summary: Derive the release list from the artefact bucket
 *     description: >
 *       Lists `<produkt>/<version>/…` in the artefact bucket
 *       (`commtool-apps`) and writes one `AppRelease` row per version — keyed
 *       by **product**, because an artefact serves every environment of that
 *       product. Removes version rows whose folder is gone; channel rows
 *       (`latest`, `stable`) stay, they are resolution rules. The bucket is
 *       enumerated, so a new upload needs no second place to maintain.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: Import summary (products, upserted, removed)
 *       404:
 *         description: Artefact bucket not readable
 *       500:
 *         description: Import failed
 */
router.post('/releases/reload', contractAuth, async (_req, res) => {
    try {
        const { importReleases } = await import('./releaseImport.js');
        const result = await importReleases();
        if (!result.ok) {
            return res.status(404).json({ success: false, message: `Releases not imported: ${result.reason}` });
        }
        res.json({ success: true, result });
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to import releases.' });
    }
});

/**
 * @swagger
 * /api/registry/backend-branches/{appKey}:
 *   get:
 *     summary: The backend branch catalogue of an app
 *     description: >
 *       The offer including the backend URLs. Restricted to CommTool staff
 *       (`employee`) — unlike the customer-facing projection in
 *       `orgaSettings/deployments`, which lists names only.
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Branch list
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                 result:
 *                   type: array
 *                   items:
 *                     $ref: './registry/registry.swagger.yaml#/components/schemas/BackendBranch'
 *       500:
 *         description: Failed to load branches
 */
router.get('/backend-branches/:appKey', platformAuth, async (req, res) => {
    try {
        const branches = await registryService.listBackendBranches(req.params.appKey);
        res.json({ success: true, result: branches });
    } catch (e) {
        errorLoggerRead(e);
        res.status(500).json({ success: false, message: 'Failed to load backend branches.' });
    }
});

/**
 * @swagger
 * /api/registry/releases:
 *   post:
 *     summary: Create or update a release
 *     description: >
 *       Writes one `(appKey, version)` row with its S3 prefix. `version` is a
 *       **name**: `latest`, `stable` or a concrete version (`5.9.0`). There is
 *       no pointer — an organisation without its own choice follows `stable`.
 *
 *       This is CommTool's write — the org admin only ever chooses from what
 *       exists here. It is also the release job's write (`release-ci`, §6.2):
 *       the job uploads the artefact, then writes the concrete version and the
 *       name `latest` (or `stable` on promotion).
 *     tags: [Registry]
 *     security:
 *       - bearerAuth: []
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: './registry/registry.swagger.yaml#/components/schemas/ReleaseInput'
 *           example:
 *             appKey: member.app
 *             version: 5.9.0
 *             prefix: member/5.9.0/
 *     responses:
 *       200:
 *         description: Release saved
 *       400:
 *         description: Missing appKey, version or prefix
 *       500:
 *         description: Failed to save release
 */
router.post('/releases', releaseAuth, async (req, res) => {
    try {
        const { appKey, version, prefix } = req.body ?? {};
        if (!appKey || !version || !prefix) {
            return res.status(400).json({ success: false, message: 'appKey, version and prefix are required.' });
        }
        await registryService.saveAppRelease({ appKey, version, prefix });
        res.json({ success: true });
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Failed to save release.' });
    }
});

export default router;