Source: RouterProject/projectShare/access.js

// @ts-check
/**
 * Zugriffs-Vererbung **Projekt → Share**.
 *
 * Der Topf ist die `Visible`-Tabelle, und gepflegt wird sie **hier** — beim
 * Anlegen, Verlinken, Entlinken und Loeschen eines Shares. Kein Konsument
 * (RAG-Sync, IDE-Server) rechnet die Rechte nach
 * ([Shares eines Projekts](https://members.app.commtool.org/-/001-Backend/Datenstruktur/dProjectShares)).
 *
 * **Der Mechanismus ist derselbe wie bei Listen** — es gibt keinen Sonderweg:
 * das Projekt traegt `visible`/`changeable`-Filter (`Links.Type = 'list'`,
 * Quelle ist die Owner-Gruppe). Der Share **spiegelt diese Filter-Zeilen** und
 * wird danach ueber `listRebuildAccess` neu materialisiert. Daraus entstehen
 * automatisch die `/add|/remove/{shareType}/{level}/{uid}`-Events, die
 * `rag-sync` projiziert — inklusive Owner (`member`-Link) und Org-Superadmin
 * (`memberSys`), die `listRebuildAccess` selbst ergaenzt.
 *
 * Die Zeilen haengen **immer am Share-Objekt, das auf das Projekt zeigt**. Bei
 * einem Ableger also am **Ableger**, nie am Basis-Share — die Projekt-Sicht ist
 * je Objekt eindeutig.
 *
 * Die Link-Richtung wird in beiden Richtungen gelesen (alt: Projekt → Share,
 * neu: Share → Projekt), damit die Pflege auch vor/nach der Datenmigration
 * stimmt.
 */

import { query } from '@commtool/sql-query';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';

/** Enum-Werte der Share-Objekte in `ObjectBase.Type`. */
export const SHARE_TYPES = ['repositoryShare', 'directoryShare'];

/** SQL-Liste der Share-Typen. */
export const SHARE_TYPES_SQL = `('repositoryShare','directoryShare')`;

/** Filter-Objekte tragen ihre Zielbindung generisch als `list`-Link. */
const FILTER_LINK_TYPE = 'list';

/** Die Filter-Typen, die Rechte vergeben. */
const FILTER_TYPES_SQL = `('visible','changeable')`;

/**
 * SQL-Ausdruck: der **Root** (Basis-Share) einer Share-Zeile — die
 * Index-Identitaet des Repos (`UIDBelongsTo`-Kette bis zur Selbstreferenz).
 *
 * Nur **eine** Vererbungsstufe ist vorgesehen: Basis ist die Zeile, wenn
 * `UIDBelongsTo` leer ist, auf die eigene UID zeigt **oder** auf kein
 * Share-Objekt zeigt (Altbestand: dort stand die Organisation). Sonst ist
 * `UIDBelongsTo` die Basis.
 *
 * Bewusst in SQL: `binary(16)`-Werte sollen nicht als Buffer/Hex-String durch
 * JS-Vergleiche laufen.
 *
 * @param {string} alias
 * @returns {string}
 */
export const SHARE_ROOT_SQL = (alias) => `IF(
    ${alias}.UIDBelongsTo IS NULL
    OR ${alias}.UIDBelongsTo = ${alias}.UID
    OR NOT EXISTS (
        SELECT 1 FROM ObjectBase AS rootBase
         WHERE rootBase.UID = ${alias}.UIDBelongsTo
           AND rootBase.Type IN ${SHARE_TYPES_SQL}
    ),
    ${alias}.UID,
    ${alias}.UIDBelongsTo
)`;

/** SQL-Ausdruck: ist die Zeile selbst die Basis (kein Ableger)? */
export const SHARE_IS_BASE_SQL = (alias) => `(${SHARE_ROOT_SQL(alias)} = ${alias}.UID)`;

/** Waehlt `connection.query` (Transaktion) oder den Pool. */
const q = (connection) => (connection ? connection.query.bind(connection) : query);

/**
 * Die Shares eines Projekts — beide Link-Richtungen.
 *
 * @param {Buffer} projectHex
 * @param {any} [connection]
 * @returns {Promise<{UID: Buffer, Type: string}[]>}
 */
export const sharesOfProject = async (projectHex, connection = null) => {
    const run = q(connection);
    const rows = await run(
        `SELECT s.UID, s.Type FROM Links AS l
           JOIN ObjectBase AS s ON (s.UID = l.UID AND s.Type IN ${SHARE_TYPES_SQL})
          WHERE l.Type = 'memberA' AND l.UIDTarget = ?
         UNION
         SELECT s.UID, s.Type FROM Links AS l
           JOIN ObjectBase AS s ON (s.UID = l.UIDTarget AND s.Type IN ${SHARE_TYPES_SQL})
          WHERE l.Type IN ('memberA','member','projectShare') AND l.UID = ?`,
        [projectHex, projectHex],
    );
    return rows;
}

/**
 * Die Projekte eines Shares — beide Link-Richtungen.
 *
 * @param {Buffer} shareHex
 * @param {any} [connection]
 * @returns {Promise<{UID: Buffer}[]>}
 */
export const projectsOfShare = async (shareHex, connection = null) => {
    const run = q(connection);
    return run(
        `SELECT p.UID FROM Links AS l
           JOIN ObjectBase AS p ON (p.UID = l.UIDTarget AND p.Type = 'project')
          WHERE l.Type = 'memberA' AND l.UID = ?
         UNION
         SELECT p.UID FROM Links AS l
           JOIN ObjectBase AS p ON (p.UID = l.UID AND p.Type = 'project')
          WHERE l.Type IN ('memberA','member','projectShare') AND l.UIDTarget = ?`,
        [shareHex, shareHex],
    );
};

/**
 * Der Root (Basis-Share) eines Share-Objekts, plus die Angabe, ob es selbst die
 * Basis ist. `null`, wenn die UID kein Share ist.
 *
 * @param {Buffer} shareHex
 * @param {any} [connection]
 * @returns {Promise<{share: Buffer, kind: string, root: Buffer, isBase: number}|null>}
 */
export const rootOfShare = async (shareHex, connection = null) => {
    const run = q(connection);
    const [row] = await run(
        `SELECT s.UID AS share, s.Type AS kind, ${SHARE_ROOT_SQL('s')} AS root,
                ${SHARE_IS_BASE_SQL('s')} AS isBase
           FROM ObjectBase AS s
          WHERE s.UID = ? AND s.Type IN ${SHARE_TYPES_SQL}`,
        [shareHex],
    );
    return row || null;
};

/**
 * Alle Shares eines Projekts mit ihrem Root — beide Link-Richtungen.
 *
 * Das ist die Grundlage der harten Root-Regel: **ein Root hoechstens einmal je
 * Projekt**. Ohne sie haengen Basis und Ableger desselben Repos doppelt im
 * Projekt.
 *
 * @param {Buffer} projectHex
 * @param {any} [connection]
 * @returns {Promise<Array<{share: Buffer, kind: string, root: Buffer, isBase: number}>>}
 */
export const projectShareRoots = async (projectHex, connection = null) => {
    const run = q(connection);
    return run(
        `SELECT s.UID AS share, s.Type AS kind, ${SHARE_ROOT_SQL('s')} AS root,
                ${SHARE_IS_BASE_SQL('s')} AS isBase
           FROM Links AS l
           JOIN ObjectBase AS s ON (s.UID = l.UID AND s.Type IN ${SHARE_TYPES_SQL})
          WHERE l.Type = 'memberA' AND l.UIDTarget = ?
         UNION
         SELECT s.UID AS share, s.Type AS kind, ${SHARE_ROOT_SQL('s')} AS root,
                ${SHARE_IS_BASE_SQL('s')} AS isBase
           FROM Links AS l
           JOIN ObjectBase AS s ON (s.UID = l.UIDTarget AND s.Type IN ${SHARE_TYPES_SQL})
          WHERE l.Type IN ('memberA','member','projectShare') AND l.UID = ?`,
        [projectHex, projectHex],
    );
};

/**
 * Haengt ein **anderes** Share-Objekt an dieser UID (ist sie eine Basis, die
 * noch gebraucht wird)? Letzte Absicherung beim Loeschen: lieber den Root
 * behalten als einem Ableger den Boden wegziehen.
 *
 * @param {Buffer} shareHex
 * @param {any} [connection]
 * @returns {Promise<boolean>}
 */
export const hasDerivedShares = async (shareHex, connection = null) => {
    const run = q(connection);
    const [row] = await run(
        `SELECT 1 AS n FROM ObjectBase
          WHERE UIDBelongsTo = ? AND UID <> ? AND Type IN ${SHARE_TYPES_SQL}
          LIMIT 1`,
        [shareHex, shareHex],
    );
    return Boolean(row);
};

/**
 * Die Projekte mehrerer Shares auf einmal — fuer die Anzeige der **Herkunft**
 * eines Ablegers („kommt von Projekt X") ohne eine Abfrage je Zeile.
 *
 * @param {Buffer[]} shareHexes
 * @param {any} [connection]
 * @returns {Promise<Array<{share: Buffer, project: Buffer, title: string|null}>>}
 */
export const projectsOfShares = async (shareHexes, connection = null) => {
    if (!shareHexes || shareHexes.length === 0) return [];
    const run = q(connection);
    return run(
        `SELECT l.UID AS share, p.UID AS project, p.Title AS title, p.Display AS display
           FROM Links AS l
           JOIN ObjectBase AS p ON (p.UID = l.UIDTarget AND p.Type = 'project')
          WHERE l.Type IN ('memberA','member') AND l.UID IN (?)
         UNION
         SELECT l.UIDTarget AS share, p.UID AS project, p.Title AS title, p.Display AS display
           FROM Links AS l
           JOIN ObjectBase AS p ON (p.UID = l.UID AND p.Type = 'project')
          WHERE l.Type IN ('memberA','member','projectShare') AND l.UIDTarget IN (?)`,
        [shareHexes, shareHexes],
    );
};

/**
 * Die **Ableger** mehrerer Basis-Shares (mit ihrem Projekt) — die Gegenfrage zu
 * „wo kommt der Ableger her": „welche Projekte haengen an dieser Basis?"
 *
 * @param {Buffer[]} rootHexes
 * @param {any} [connection]
 * @returns {Promise<Array<{root: Buffer, share: Buffer, kind: string, project: Buffer|null, title: string|null}>>}
 */
export const dependentsOfRoots = async (rootHexes, connection = null) => {
    if (!rootHexes || rootHexes.length === 0) return [];
    const run = q(connection);
    return run(
        `SELECT d.UIDBelongsTo AS root, d.UID AS share, d.Type AS kind, p.UID AS project, p.Title AS title, p.Display AS display
           FROM ObjectBase AS d
           JOIN Links AS l ON (l.UID = d.UID AND l.Type IN ('memberA','member'))
           JOIN ObjectBase AS p ON (p.UID = l.UIDTarget AND p.Type = 'project')
          WHERE d.UIDBelongsTo IN (?) AND d.UID <> d.UIDBelongsTo AND d.Type IN ${SHARE_TYPES_SQL}
         UNION
         SELECT d.UIDBelongsTo AS root, d.UID AS share, d.Type AS kind, p.UID AS project, p.Title AS title, p.Display AS display
           FROM ObjectBase AS d
           JOIN Links AS l ON (l.UIDTarget = d.UID AND l.Type IN ('memberA','member','projectShare'))
           JOIN ObjectBase AS p ON (p.UID = l.UID AND p.Type = 'project')
          WHERE d.UIDBelongsTo IN (?) AND d.UID <> d.UIDBelongsTo AND d.Type IN ${SHARE_TYPES_SQL}`,
        [rootHexes, rootHexes],
    );
};

/**
 * Die Basis eines Roots auf einen Ableger umhaengen: der Nachfolger wird die
 * neue Basis (Selbstreferenz), alle uebrigen Ableger zeigen auf ihn. Danach ist
 * der alte Root frei — genau das braucht „Basis loeschen, aber ein Ableger soll
 * sie ersetzen".
 *
 * Innerhalb einer Transaktion **muss** `connection` mitgegeben werden.
 *
 * @param {Buffer} successorHex
 * @param {Buffer} oldRootHex
 * @param {any} [connection]
 * @returns {Promise<Buffer[]>} die **uebrigen** umgehaengten Objekte (ohne den Nachfolger)
 */
export const promoteRoot = async (successorHex, oldRootHex, connection = null) => {
    const run = q(connection);
    const others = await run(
        `SELECT UID FROM ObjectBase
          WHERE UIDBelongsTo = ? AND UID <> ? AND UID <> ? AND Type IN ${SHARE_TYPES_SQL}`,
        [oldRootHex, oldRootHex, successorHex],
    );
    // Der Nachfolger wird die Basis: Selbstreferenz statt Zeiger auf den Alten.
    await run(`UPDATE ObjectBase SET UIDBelongsTo = UID WHERE UID = ?`, [successorHex]);
    // Alles andere, was am Alten hing, haengt jetzt am Nachfolger.
    await run(`UPDATE ObjectBase SET UIDBelongsTo = ? WHERE UIDBelongsTo = ? AND UID <> ?`, [successorHex, oldRootHex, successorHex]);
    return others.map((row) => row.UID);
};

/**
 * Die Rechte-Filter des Projekts auf den Share spiegeln: hinzufuegen, was das
 * Projekt hat; entfernen, was es nicht (mehr) hat. Der Share bekommt damit
 * **denselben** Filter-Satz wie das Projekt und beim naechsten Rebuild
 * dieselben `Visible`-Zeilen.
 *
 * Idempotent und rein strukturell (keine Rechte-Auswertung).
 *
 * @param {Buffer} projectHex
 * @param {Buffer} shareHex
 * @param {any} [connection]
 * @returns {Promise<void>}
 */
export const mirrorProjectFilters = async (projectHex, shareHex, connection = null) => {
    const run = q(connection);
    // 1. Was das Projekt hat, bekommt der Share auch.
    await run(
        `INSERT IGNORE INTO Links (UID, Type, UIDTarget)
         SELECT f.UID, ?, ?
           FROM Links AS l
           JOIN ObjectBase AS f ON (f.UID = l.UID AND f.Type IN ${FILTER_TYPES_SQL})
          WHERE l.UIDTarget = ? AND l.Type = ?`,
        [FILTER_LINK_TYPE, shareHex, projectHex, FILTER_LINK_TYPE],
    );
    // 2. Was das Projekt nicht (mehr) hat, verliert der Share.
    //    Anti-Join statt NOT IN: MySQL verbietet sonst das Unterabfragen der
    //    Zieltabelle innerhalb eines DELETE.
    await run(
        `DELETE l FROM Links AS l
           JOIN ObjectBase AS f ON (f.UID = l.UID AND f.Type IN ${FILTER_TYPES_SQL})
           LEFT JOIN Links AS keep ON (keep.UID = l.UID AND keep.UIDTarget = ? AND keep.Type = ?)
          WHERE l.UIDTarget = ? AND l.Type = ? AND keep.UID IS NULL`,
        [projectHex, FILTER_LINK_TYPE, shareHex, FILTER_LINK_TYPE],
    );
};

/** Alle gespiegelten Filter-Links eines Shares entfernen (kein Projekt mehr). */
const clearMirroredFilters = async (shareHex, connection = null) => {
    const run = q(connection);
    await run(
        `DELETE l FROM Links AS l
           JOIN ObjectBase AS f ON (f.UID = l.UID AND f.Type IN ${FILTER_TYPES_SQL})
          WHERE l.UIDTarget = ? AND l.Type = ?`,
        [shareHex, FILTER_LINK_TYPE],
    );
};

/**
 * Den Filter-Spiegel eines Shares an seinen aktuellen Projekt-Link angleichen.
 * Ohne Projekt (z. B. nach dem Entlinken) fallen die gespiegelten Filter weg.
 * Kein Rebuild — nur die Struktur.
 *
 * Innerhalb einer Transaktion **muss** `connection` mitgegeben werden, damit
 * Share und Filter-Satz zusammen committen.
 *
 * @param {Buffer} shareHex
 * @param {any} [connection] - laufende Transaktion
 * @returns {Promise<boolean>} true, wenn ein Projekt gefunden wurde
 */
export const syncShareFilters = async (shareHex, connection = null) => {
    const [project] = await projectsOfShare(shareHex, connection);
    if (project) {
        await mirrorProjectFilters(project.UID, shareHex, connection);
        return true;
    }
    await clearMirroredFilters(shareHex, connection);
    return false;
};

/**
 * `Visible` eines Shares neu materialisieren und die Rechte-Events melden.
 *
 * Bewusst ueber die Queue (wie `rebuildListVisibility` bei Listen): der Rebuild
 * laeuft asynchron, ein Fehler dort darf das Anlegen/Linken nicht zurueckrollen.
 *
 * @param {import('../../types.js').ExpressRequestAuthorized} req
 * @param {Buffer} shareHex
 * @returns {Promise<void>}
 */
export const rebuildShareAccess = async (req, shareHex) => {
    try {
        const { rebuildListVisibility } = await import('../../utils/listVisibilty.js');
        rebuildListVisibility(req, shareHex);
    } catch (e) {
        errorLoggerUpdate(e);
    }
};

/**
 * Einen Share vollstaendig nachziehen: Filter spiegeln **und** Rechte
 * materialisieren. Nach Anlegen, Verlinken und Entlinken aufrufen.
 *
 * @param {import('../../types.js').ExpressRequestAuthorized} req
 * @param {Buffer} shareHex
 * @returns {Promise<void>}
 */
export const reconcileShare = async (req, shareHex) => {
    try {
        await syncShareFilters(shareHex);
        await rebuildShareAccess(req, shareHex);
    } catch (e) {
        errorLoggerUpdate(e);
    }
};

/**
 * Alle Shares eines Projekts nachziehen — nach einer Aenderung an den
 * Projekt-Rechten und beim Anlegen/Entfernen von Shares.
 *
 * @param {import('../../types.js').ExpressRequestAuthorized} req
 * @param {Buffer} projectHex
 * @returns {Promise<number>} Anzahl der nachgezogenen Shares
 */
export const reconcileSharesOfProject = async (req, projectHex) => {
    try {
        const shares = await sharesOfProject(projectHex);
        for (const share of shares) {
            await syncShareFilters(share.UID);
            await rebuildShareAccess(req, share.UID);
        }
        return shares.length;
    } catch (e) {
        errorLoggerUpdate(e);
        return 0;
    }
};