Source: Router/orgaSettings.js

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

/**
 * OrgaSettings Router
 *
 * Endpoints for managing per-organisation settings stored in Vault.
 * All routes require admin privileges (checkAdmin middleware).
 *
 * Mounted at: /api/kpe20/orgaSettings  (see http-server.js)
 *
 * @swagger
 * tags:
 *   - name: OrgaSettings
 *     description: |
 *       Per-organisation settings stored in Vault —
 *       domain mappings and SMTP configuration.
 *       Schemas and examples are defined in ./orgaSettings/orgaSettings.swagger.yaml.
 *       Use $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/{SchemaName}' in route blocks.
 */

import express from 'express';
import { checkAdmin }               from '../utils/authChecks.js';
import { requestUpdateLogger }      from '../utils/requestLogger.js';
import * as orgaSettingsController  from './orgaSettings/controller.js';

/** @type {express.Express} */
const api = express();

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

/**
 * @swagger
 * /api/kpe20/orgaSettings/domains:
 *   get:
 *     summary: Load domain settings for the current organisation
 *     description: >
 *       Returns the full domain map (domain → type) stored in Vault
 *       for the authenticated user's organisation.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: Domain map retrieved successfully
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/DomainMap'
 *       400:
 *         description: No organisation in session
 *       500:
 *         description: Failed to load domain settings
 */
// @ts-ignore
api.get('/domains', checkAdmin, orgaSettingsController.getDomainsController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/domains:
 *   put:
 *     summary: Save domain settings for the current organisation
 *     description: >
 *       Validates and persists the domain map for the authenticated user's
 *       organisation in Vault.  Each entry maps a domain name to its type
 *       ("internal" or "external").  Validation checks format rules and
 *       conflicts with other organisations.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/DomainMap'
 *           example:
 *             myclub: internal
 *             myclub.de: external
 *     responses:
 *       200:
 *         description: Domains saved successfully
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *       400:
 *         description: Validation errors or missing organisation
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: false
 *                 errors:
 *                   type: array
 *                   items:
 *                     $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/DomainValidationError'
 *       500:
 *         description: Failed to save domain settings
 */
// @ts-ignore
api.put('/domains', checkAdmin, requestUpdateLogger, orgaSettingsController.saveDomainsController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/domains/{domain}/verify:
 *   post:
 *     summary: Confirm ownership of an external domain via DNS
 *     description: >
 *       Two records can prove ownership, and the challenge decides which one is
 *       read (both prove the same thing — whoever can edit the zone's DNS can
 *       set either record).
 *
 *       With `challenge: "acme-dns"` the **CNAME** records of every challenge
 *       name (`_acme-challenge.app.<domain>`, `_acme-challenge.test.<domain>`)
 *       must point at their stored acme-dns account. This is the record the
 *       customer needs for issuance anyway, so verifying it proves the
 *       prerequisite itself rather than a stand-in: if only one of the labels is
 *       delegated, the whole certificate order fails.
 *
 *       Otherwise the TXT record at `_commtool-verify.<domain>` is compared with
 *       the `verifyToken` on the entry.
 *
 *       Only on a match is the status set to `verified` (with
 *       `verifiedVia: "cname" | "txt"`); the change is published as
 *       `registry:domain:changed`, which wakes the zone controller.
 *
 *       This is the **only** way an external domain becomes `verified` — a
 *       status sent through `PUT /domains` is reduced to `pending`, because a
 *       self-declared proof would let one organisation claim a domain it does
 *       not own.
 *
 *       Idempotent: verifying an already verified domain returns success
 *       without writing anything.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: domain
 *         required: true
 *         schema:
 *           type: string
 *         example: myclub.de
 *     responses:
 *       200:
 *         description: Domain verified (or already verified)
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/DomainVerification'
 *       400:
 *         description: >
 *           A record is missing or points elsewhere (`cname-missing`,
 *           `cname-mismatch`, `mismatch`), the domain is internal, or the name is
 *           invalid. Carries `record`/`expected`/`found` or the per-name
 *           `records[]` so the UI can show what is actually in DNS.
 *       404:
 *         description: The domain does not belong to this organisation
 *       409:
 *         description: >
 *           Nothing to compare against — the entry has no `verifyToken`, or it
 *           asks for `acme-dns` without a prepared delegation
 *           (`POST /domains/{domain}/delegation`).
 *       502:
 *         description: DNS lookup failed (no verdict about the record)
 */
// @ts-ignore
api.post('/domains/:domain/verify', checkAdmin, requestUpdateLogger, orgaSettingsController.verifyDomainController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/domains/{domain}/delegation:
 *   post:
 *     summary: Prepare the acme-dns delegation for an external zone
 *     description: >
 *       Registers one acme-dns account per challenge name
 *       (`_acme-challenge.app.<domain>`, `_acme-challenge.test.<domain>`) and
 *       returns the CNAME records the customer has to set once.
 *
 *       This has to happen **before** verification: without a stored account
 *       there is no target to compare a CNAME against (see
 *       `POST /domains/{domain}/verify`). The accounts are created here rather
 *       than by the zone controller because the controller would have to invent
 *       a target name the customer never saw — acme-dns never hands out the same
 *       name twice, so such an account is unreachable forever.
 *
 *       Idempotent: existing accounts are kept, only missing ones are
 *       registered. Writes `acmeDns` and `challenge: "acme-dns"` on the entry but
 *       does **not** verify it and does not publish an event.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: domain
 *         required: true
 *         schema:
 *           type: string
 *         example: myclub.de
 *     responses:
 *       200:
 *         description: Records ready (accounts reused or newly registered)
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/DomainDelegation'
 *       400:
 *         description: Invalid name or internal prefix
 *       404:
 *         description: The domain is not registered
 *       409:
 *         description: The zone is claimed more than once
 *       502:
 *         description: acme-dns answered with an error
 *       503:
 *         description: ACME_DNS_API_BASE is not configured
 */
// @ts-ignore
api.post(
    '/domains/:domain/delegation',
    checkAdmin,
    requestUpdateLogger,
    orgaSettingsController.prepareDelegationController,
);

// ── Apps ─────────────────────────────────────────────────────────────────────

/**
 * @swagger
 * /api/kpe20/orgaSettings/apps:
 *   get:
 *     summary: Load app registry for the current organisation
 *     description: >
 *       Returns all configured apps (domain, roles, title, etc.) stored in
 *       Vault for the authenticated user's organisation.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: App registry retrieved successfully
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/AppsMap'
 *       400:
 *         description: No organisation in session
 *       500:
 *         description: Failed to load app settings
 */
// @ts-ignore
api.get('/apps', checkAdmin, orgaSettingsController.getAppsController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/apps:
 *   put:
 *     summary: Save app registry for the current organisation
 *     description: >
 *       Persists the full app registry for the authenticated user's organisation
 *       in Vault.  The entire object is replaced; pass the full updated map.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/AppsMap'
 *     responses:
 *       200:
 *         description: App registry saved
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *       400:
 *         description: Invalid request body or missing organisation
 *       500:
 *         description: Failed to save app settings
 */
// @ts-ignore
api.post('/apps', checkAdmin, requestUpdateLogger, orgaSettingsController.saveAppsController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/apps/{appId}:
 *   put:
 *     summary: Save a single app entry
 *     description: >
 *       Persists exactly one app (its identity and assignment) for the
 *       authenticated user's organisation. The edit dialog writes one row, so
 *       the endpoint takes one row — the rest of the map is left untouched.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appId
 *         required: true
 *         schema:
 *           type: string
 *         description: The app identifier key (e.g. member.app)
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/AppEntry'
 *     responses:
 *       200:
 *         description: App saved
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *       400:
 *         description: Invalid app entry or missing organisation
 *       500:
 *         description: Failed to save app settings
 */
// @ts-ignore
api.put('/apps/:appId', checkAdmin, requestUpdateLogger, orgaSettingsController.saveAppController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/apps/{appId}:
 *   delete:
 *     summary: Delete a single app entry
 *     description: >
 *       Removes exactly one app (and its links) for the authenticated user's
 *       organisation.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appId
 *         required: true
 *         schema:
 *           type: string
 *         description: The app identifier key (e.g. ext-kpe-wiki)
 *     responses:
 *       200:
 *         description: App deleted
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *       400:
 *         description: Missing organisation
 *       500:
 *         description: Failed to delete app settings
 */
// @ts-ignore
api.delete('/apps/:appId', checkAdmin, requestUpdateLogger, orgaSettingsController.deleteAppController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/apps/{appId}/icon:
 *   post:
 *     summary: Upload an icon for an app
 *     description: >
 *       Uploads an image file (PNG, JPG, SVG, GIF or WebP) to the
 *       `commtool-public` MinIO bucket under `{orgId}/icons/{appId}.{ext}`
 *       and persists the resulting public URL in Vault under
 *       `apps[appId].icon` for the current organisation.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appId
 *         required: true
 *         schema:
 *           type: string
 *         description: The app identifier key (e.g. member.app)
 *     requestBody:
 *       required: true
 *       content:
 *         multipart/form-data:
 *           schema:
 *             type: object
 *             properties:
 *               files:
 *                 type: string
 *                 format: binary
 *                 description: Image file (PNG / JPG / SVG / GIF / WebP, max recommended 512 KB)
 *     responses:
 *       200:
 *         description: Icon uploaded and URL stored in Vault
 *         content:
 *           application/json:
 *             schema:
 *               $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/IconUploadResponse'
 *       400:
 *         description: Unsupported file type, missing app or missing organisation
 *       500:
 *         description: Upload or Vault write failed
 */
// @ts-ignore
api.post('/apps/:appId/icon', checkAdmin, requestUpdateLogger, orgaSettingsController.uploadAppIconController);

// ── Deployments ───────────────────────────────────────────────────────────────

/**
 * @swagger
 * /api/kpe20/orgaSettings/deployments:
 *   get:
 *     summary: Catalogue and current choice for this organisation's apps
 *     description: >
 *       Returns, per app, the selectable versions and backend branches **by
 *       name** plus the organisation's current choice. Backend URLs are
 *       deliberately absent: the choice belongs to the customer admin, the
 *       targets are CommTool's. A `version` of `null` means "follow the
 *       pointer" (`AppRelease.Current`).
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: Deployment catalogue keyed by appKey
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/DeploymentCatalog'
 *       400:
 *         description: No organisation in session
 *       500:
 *         description: Failed to load deployments
 */
// @ts-ignore
api.get('/deployments', checkAdmin, orgaSettingsController.getDeploymentsController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/app-offers:
 *   get:
 *     summary: The contract's offer — products and their environments
 *     description: >
 *       What an organisation may **add**: per product (`member`, `admin`,
 *       `portal`) its display defaults and its selectable environments (backend
 *       branches). Source is the imported contract (`AppCatalog` +
 *       `AppBackendBranch`); the branch **targets** (URLs) are stripped — the
 *       customer admin chooses a name, not a host. Adding an app creates the org
 *       app `<produkt>.<umgebung>` (`member.test`) and its deployment.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: Offer keyed by product
 *       400:
 *         description: No organisation in session
 *       500:
 *         description: Failed to load app offers
 */
// @ts-ignore
api.get('/app-offers', checkAdmin, orgaSettingsController.getAppOffersController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/deployments/{appKey}:
 *   put:
 *     summary: Choose version and backend branch for this organisation
 *     description: >
 *       The only write an organisation admin has in the release model. Both
 *       values are validated against the existing catalogue and releases, so a
 *       choice can never point at something that does not exist. The catalogue
 *       itself and the pointer (`AppRelease.Current`) stay CommTool-only — there
 *       is no shared write path, which is what keeps the two authorities apart.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/DeploymentChoice'
 *           example:
 *             branch: prod
 *             version: null
 *     responses:
 *       200:
 *         description: Choice saved
 *       400:
 *         description: Unknown branch, unknown release or missing organisation
 *       500:
 *         description: Failed to save deployment
 */
// @ts-ignore
api.put('/deployments/:appKey', checkAdmin, requestUpdateLogger, orgaSettingsController.saveDeploymentController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/deployments/{appKey}:
 *   delete:
 *     summary: Drop this organisation's choice for an app
 *     description: >
 *       The organisation follows the pointer and the release's backends again.
 *       This is the rollback, and it is a `DELETE` — no deployment is torn down.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: appKey
 *         required: true
 *         schema:
 *           type: string
 *         example: member.app
 *     responses:
 *       200:
 *         description: Choice removed (`removed: false` when there was none)
 *       400:
 *         description: No organisation in session
 *       500:
 *         description: Failed to clear deployment
 */
// @ts-ignore
api.delete('/deployments/:appKey', checkAdmin, requestUpdateLogger, orgaSettingsController.clearDeploymentController);

// ── Mail / SMTP ───────────────────────────────────────────────────────────────

/**
 * @swagger
 * /api/kpe20/orgaSettings/mail:
 *   get:
 *     summary: Load SMTP settings for the current organisation
 *     description: >
 *       Returns the SMTP configuration stored in Vault for the authenticated
 *       user's organisation (`orgas/data/{orgId}/mailserver` — derselbe
 *       Schlüssel, den der Mailversand liest).  The `mailPassword` field is
 *       masked before being returned.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     responses:
 *       200:
 *         description: SMTP settings retrieved (password masked)
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 result:
 *                   $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/MailSettings'
 *       400:
 *         description: No organisation in session
 *       500:
 *         description: Failed to load mail settings
 */
// @ts-ignore
api.get('/mail', checkAdmin, orgaSettingsController.getMailController);

/**
 * @swagger
 * /api/kpe20/orgaSettings/mail:
 *   post:
 *     summary: Save SMTP settings for the current organisation
 *     description: >
 *       Validates and persists SMTP configuration for the authenticated
 *       user's organisation in Vault (`orgas/data/{orgId}/mailserver` — der
 *       Schlüssel, den der Mailversand liest).  If `mailPassword` is the mask
 *       sentinel (••••••••) the existing password is preserved unchanged.
 *     tags: [OrgaSettings]
 *     security:
 *       - bearerAuth: []
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: './orgaSettings/orgaSettings.swagger.yaml#/components/schemas/MailSettings'
 *     responses:
 *       200:
 *         description: SMTP settings saved
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *       400:
 *         description: Validation error (missing mailHost/mailUser/mailPassword or invalid mailPort)
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: false
 *                 message:
 *                   type: string
 *                   example: 'Field "mailHost" is required.'
 *       500:
 *         description: Failed to save mail settings
 */
// @ts-ignore
api.post('/mail', checkAdmin, requestUpdateLogger, orgaSettingsController.saveMailController);

export default api;