Source: RouterProject/projectShare/service.js

// @ts-check
/**
 * Share service - repository/directory share objects belonging to a project.
 *
 * Storage model (target):
 * - ObjectBase row, Type='repositoryShare'|'directoryShare'
 * - `UIDBelongsTo` = own UID (base share) or the base share UID (derivative).
 *   It carries **no** organization: that is derived over the project chain.
 * - Data JSON: { metadata }  (mode, required, gitUrl, repositoryKey, …)
 * - Two links, both pointing **away** from the share (like a list):
 *     'memberA'  Share -> Projekt   this project uses the share
 *     'member'   Share -> Person    the owner (creator)
 *
 * Migration tolerance: rows written by the old code still carry
 * `UIDBelongsTo` = org and a `memberA`/`member` link **from the project to the
 * share**. Every read tolerates both directions; every write produces the new
 * shape only.
 *
 * The wire format follows the App-Standard: UID / Type / project_uid / metadata
 * plus `linkType` ('read'|'write'). `linkType` is **derived** from the share's
 * `metadata.mode` (readOnly -> 'read'), because the write/read split now lives on
 * the share object instead of the link. Requests may still send `linkType`
 * ('memberA'|'member'|'write'|'read') as a legacy alias for that setting.
 *
 * @import {ExpressRequestAuthorized} from '../../types.js'
 */

import { query, transaction, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { getUID } from '../../utils/UUIDs.js';
import { isAdmin, isListAdmin, isObjectAdmin, isObjectVisible } from '../../utils/authChecks.js';
import { apiError } from '../../utils/apiEnvelope.js';
import { SHARE_TYPES } from '../../utils/projectContract.js';
import { PROJECT_EVENTS_ENABLED } from '../../config/featureFlags.js';
import { writeEventLog, publishToRedis, buildEventPayload } from '../../utils/events.js';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';
import { getOrganizationForShare } from '../../utils/organizationUtils.js';
import { mirrorProjectFilters, reconcileShare, rootOfShare, projectShareRoots, hasDerivedShares, promoteRoot, projectsOfShares, dependentsOfRoots, SHARE_ROOT_SQL, SHARE_IS_BASE_SQL } from './access.js';
import { loadVisibleScopes, publishVisibilityDiff } from '../../tree/visibilityEvents.js';

const SHARE_TYPES_SQL = `'repositoryShare','directoryShare'`;

/**
 * Link types that connect a share with its project. The target model has exactly
 * one (`memberA`); `member` is tolerated while old rows exist. The owner link
 * (share -> person) uses the same type but a different target type (person), so
 * it is never matched by these predicates.
 */
const PROJECT_LINK_TYPES = ['memberA', 'member'];
/** @type {const} */
const PROJECT_LINK_TYPES_SQL = `'memberA','member'`;

/** Accepted `linkType` values in requests (legacy alias for the share mode). */
const LINK_TYPES = ['memberA', 'member', 'write', 'read'];

/** Stored `metadata.mode` values that mean "read only". `reference` is the legacy spelling. */
const READ_ONLY_MODES = ['readonly', 'reference'];
const MODE_READ_ONLY = 'readOnly';
const MODE_EDITABLE = 'editable';

/**
 * Canonical `metadata.mode`. Tolerates case/whitespace and the legacy
 * `reference` spelling, which stays a **block** (fail-closed).
 * @param {unknown} mode
 * @returns {string}
 */
const normalizeMode = (mode) => {
    const value = String(mode ?? '').trim().toLowerCase();
    return READ_ONLY_MODES.includes(value) ? MODE_READ_ONLY : MODE_EDITABLE;
};

/**
 * Is this share write-locked? (`metadata.mode` = readOnly / reference)
 * @param {Object} metadata
 */
const isReadOnly = (metadata) => normalizeMode(metadata?.mode) === MODE_READ_ONLY;

/**
 * Wire `linkType` derived from the share mode — the former write/read link level
 * is now a property of the share.
 * @param {Object} metadata
 * @returns {'read'|'write'}
 */
const levelOf = (metadata) => (isReadOnly(metadata) ? 'read' : 'write');

/**
 * Resolve an incoming `linkType` into a share mode. `memberA`/`write` mean
 * editable, `member`/`read` mean read only — that is the faithful translation of
 * the old link level into the new share property.
 * @param {unknown} linkType
 * @returns {string|null} the mode, or null for an unknown value
 */
const modeFromLinkType = (linkType) => {
    const value = String(linkType ?? '').trim();
    if (value === 'memberA' || value === 'write') return MODE_EDITABLE;
    if (value === 'member' || value === 'read') return MODE_READ_ONLY;
    return null;
};

/**
 * Normalisiert eine Git-URL fuer den **Hinweis**-Vergleich (nie fuer eine
 * Identitaet — die ist die UID-Kette). Protokoll, `git@host:`-Praefix,
 * `.git`-Suffix, Trailing-Slash und Gross-/Kleinschreibung fallen weg.
 *
 * @param {unknown} url
 * @returns {string} leer, wenn keine URL brauchbar ist
 */
const normalizeGitUrl = (url) => {
    let value = String(url ?? '').trim().toLowerCase();
    if (!value) return '';
    value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, ''); // https:// git:// ssh://
    value = value.replace(/^[^@/]+@/, ''); // git@host:pfad
    value = value.replace(/^([^/:]+):(?!\/)/, '$1/'); // host:pfad -> host/pfad
    value = value.replace(/\.git$/, '');
    value = value.replace(/\/+$/, '');
    return value;
};

/**
 * Nicht blockierender Hinweis: ein **anderer** Root derselben Organisation
 * traegt dieselbe Git-URL. Das ist ein Hinweis fuer die Oberflaeche („meintest
 * du ‚bestehendes hinzufuegen'?") — **keine** Regel: dieselbe URL kann legitim
 * zweimal existieren (Fork, zweiter Klon), und die Identitaet ist die UID, nie
 * die URL.
 *
 * @param {string|Buffer} rootUid
 * @param {unknown} gitUrl
 * @param {string} orgUuid
 * @returns {Promise<Array<{code: string, share_uid: string, repositoryKey: string|null}>>}
 */
const duplicateGitUrlWarnings = async (rootUid, gitUrl, orgUuid) => {
    const key = normalizeGitUrl(gitUrl);
    if (!key) return [];
    const rows = await query(
        `SELECT ObjectBase.UID, ObjectBase.Title,
                ${SHARE_ROOT_SQL('ObjectBase')} AS root,
                JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.metadata.gitUrl')) AS gitUrl
           FROM ObjectBase
          WHERE ObjectBase.Type IN (${SHARE_TYPES_SQL})
            AND JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.metadata.gitUrl')) IS NOT NULL`,
    ).catch(() => []);
    const selfKey = HEX2uuid(rootUid);
    const seen = new Set([selfKey]);
    const warnings = [];
    for (const row of rows) {
        const rootKey = HEX2uuid(row.root);
        if (seen.has(rootKey)) continue;
        seen.add(rootKey);
        if (normalizeGitUrl(row.gitUrl) !== key) continue;
        if (await getOrganizationForShare(row.UID) !== orgUuid) continue;
        warnings.push({ code: 'DUPLICATE_GIT_URL', share_uid: rootKey, repositoryKey: row.Title ?? null });
    }
    return warnings;
};

/**
 * Verify the project exists in the organization and the user may **change** it
 * (Visible `changeable` or `admin`). Changeable is enough to create or link
 * shares — the creator becomes the owner of the new share.
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectHex
 * @returns {Promise<void>}
 */
const requireProjectChangeable = async (req, projectHex) => {
    if (!(await req.session?.root)) throw apiError(401, 'UNAUTHENTICATED', 'Not authenticated');
    const [project] = await query(
        `SELECT UID FROM ObjectBase WHERE UID=? AND Type='project'`,
        [projectHex],
    );
    if (!project) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found');
    if (!(await isObjectAdmin(req, projectHex))) {
        throw apiError(403, 'PROJECT_NOT_CHANGEABLE', 'Project is not changeable by this user');
    }
};

/**
 * Verify the project is at least visible to the user (share read is derived
 * from the project right).
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectHex
 * @returns {Promise<void>}
 */
const requireProjectVisible = async (req, projectHex) => {
    const [project] = await query(
        `SELECT UID FROM ObjectBase WHERE UID=? AND Type='project'`,
        [projectHex],
    );
    if (!project) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found');
    if (!(await isObjectVisible(req, projectHex))) {
        throw apiError(403, 'PROJECT_NOT_ACCESSIBLE', 'Project is not accessible');
    }
};

/**
 * Verify the user may administer this share: project `admin` (inherited) or
 * `Visible.admin` on the share itself (the owner).
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectHex
 * @param {string} shareHex
 * @returns {Promise<void>}
 */
const requireShareAdmin = async (req, projectHex, shareHex) => {
    if (!(await isListAdmin(req, projectHex))) {
        const rows = await query(
            `SELECT UID FROM Visible WHERE UID=? AND UIDUser=? AND Type='admin'`,
            [shareHex, UUID2hex(req.session.user)],
        );
        if (rows.length === 0) {
            throw apiError(403, 'SHARE_NOT_ADMIN', 'Share is not changeable by this user');
        }
    }
};

/**
 * Das Share-Objekt, das **dieses** Projekt fuer einen Root benutzt.
 *
 * Ein Aufrufer darf den Root nennen (den die Suche vor dem Ableger-Modell
 * zeigte) oder das Projekt-Objekt selbst (Basis/Ableger) — beides muss denselben
 * Treffer geben. Die harte Regel „ein Root hoechstens einmal je Projekt" macht
 * die Auflösung eindeutig, deshalb ist sie kein Raten.
 *
 * @param {string} projectHex
 * @param {string} shareHex
 * @returns {Promise<string|null>} die UID des Projekt-Objekts
 */
const resolveProjectShare = async (projectHex, shareHex) => {
    const wanted = HEX2uuid(shareHex);
    const roots = await projectShareRoots(projectHex);
    const exact = roots.find((r) => HEX2uuid(r.share) === wanted);
    if (exact) return exact.share;
    const target = await rootOfShare(shareHex);
    if (!target) return null;
    const rootKey = HEX2uuid(target.root);
    const byRoot = roots.find((r) => HEX2uuid(r.root) === rootKey);
    return byRoot ? byRoot.share : null;
};

/**
 * Read a share row that is linked to the given project. Returns the share with
 * its link type, or null when the share does not exist or has no link to this
 * project. Both link directions are accepted during the migration.
 * @param {string} shareHex
 * @param {string} projectHex
 */
const readShare = async (shareHex, projectHex) => {
    const rows = await query(
        `SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo, ObjectBase.Data,
                ObjectBase.Title,
                Links.Type AS link_type,
                DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
         FROM ObjectBase
         LEFT JOIN Links ON Links.Type IN (${PROJECT_LINK_TYPES_SQL})
              AND (
                    (Links.UID=? AND Links.UIDTarget=ObjectBase.UID)      -- alt: Projekt -> Share
                 OR (Links.UID=ObjectBase.UID AND Links.UIDTarget=?)     -- neu: Share -> Projekt
              )
         WHERE ObjectBase.UID=? AND ObjectBase.Type IN (${SHARE_TYPES_SQL})
         ORDER BY (Links.Type='memberA') DESC`,
        [projectHex, projectHex, shareHex],
        { cast: ['json'] },
    );
    return rows[0] || null;
};

/**
 * Map a share row to the app Share object (UID / Type / project_uid / metadata
 * / linkType). `project_uid` comes from the project context; the organization is
 * no longer readable from the share itself.
 * @param {any} row
 * @param {string} [projectUid]
 */
export const toShare = (row, projectUid) => {
    let metadata = {};
    try {
        metadata = row.Data && typeof row.Data === 'object' ? row.Data.metadata ?? {} : {};
    } catch (e) {
        metadata = {};
    }
    return {
        UID: HEX2uuid(row.UID),
        project_uid: projectUid || null,
        Type: row.Type,
        linkType: levelOf(metadata),
        metadata,
        source_updated_at: row.source_updated_at,
    };
};

/**
 * Add a share to a project. Requires **changeable** on the project. Creates the
 * share as a base share (`UIDBelongsTo` = own UID), links it to the project
 * (`memberA`) and records the creator as owner (`member` link + `Visible.admin`).
 *
 * Legacy `linkType` in the body (`member`/`read`) is translated into
 * `metadata.mode = readOnly`.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @returns {Promise<Object>}
 */
export const addShare = async (req, projectUid) => {
    try {
        const projectHex = UUID2hex(projectUid);
        const userHex = UUID2hex(req.session.user);
        await requireProjectChangeable(req, projectHex);

        const body = req.body || {};
        const type = body.type;
        if (!SHARE_TYPES.includes(type)) {
            throw apiError(422, 'INVALID_SHARE_TYPE', `Share type must be one of: ${SHARE_TYPES.join(', ')}`);
        }
        const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
        // contract: share metadata never contains an ACL or secret values
        if (metadata.acl !== undefined || metadata.capabilities !== undefined) {
            throw apiError(422, 'INVALID_SHARE_METADATA', 'Share metadata must not carry an ACL or capabilities');
        }
        // Legacy `linkType` from the migration-era UI: translate into the share mode.
        if (typeof body.linkType !== 'undefined') {
            const legacyMode = modeFromLinkType(body.linkType);
            if (legacyMode === null) {
                throw apiError(422, 'INVALID_LINK_TYPE', `linkType must be one of: ${LINK_TYPES.join(', ')}`);
            }
            if (typeof metadata.mode === 'undefined') metadata.mode = legacyMode;
        }

        const UID = await getUID(req);
        const title = typeof metadata.repositoryKey === 'string' && metadata.repositoryKey ? metadata.repositoryKey : 'share';
        const events = [];
        // Vorher-Stand der Rechte-Zeilen: beim Anlegen eines Shares leer, aber
        // gemessen statt angenommen.
        const beforeOwner = await loadVisibleScopes([UID]);

        await transaction(async (connection) => {
            // Base share: UIDBelongsTo points at itself (the object rule for base objects).
            await connection.query(
                `INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
                [UID, type, UID, title, title, title, 0, JSON.stringify({ metadata }), userHex],
            );
            // New direction: the share points at its project (like a list points at its owner group).
            await connection.query(
                `INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
                [UID, projectHex, userHex],
            );
            // Owner: the creator owns the share (like a list's `member` link to its creator).
            await connection.query(
                `INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'member', ?, ?)`,
                [UID, userHex, userHex],
            );
            await connection.query(
                `INSERT INTO Visible (UID, Type, UIDUser) VALUES (?, 'admin', ?)`,
                [UID, userHex],
            );
            // Rechte-Vererbung: denselben Filter-Satz wie das Projekt spiegeln.
            // `rebuildListAccess` materialisiert daraus die Visible-Zeilen und
            // meldet die Rechte-Events (siehe ./access.js).
            await mirrorProjectFilters(projectHex, UID, connection);
            if (PROJECT_EVENTS_ENABLED) {
                const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(UID)] });
                await writeEventLog(`/add/${type}/${HEX2uuid(UID)}`, payload, connection);
                events.push({ key: `/add/${type}/${HEX2uuid(UID)}`, payload });
                // Project link (memberA -> write level event for the projection)
                const linkPayload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(UID)] });
                await writeEventLog(`/add/project/write/${HEX2uuid(projectHex)}`, linkPayload, connection);
                events.push({ key: `/add/project/write/${HEX2uuid(projectHex)}`, payload: linkPayload });
            }
        });

        for (const ev of events) {
            publishToRedis(ev.key, ev.payload);
        }

        // Die Owner-Zeile entsteht **in** der Transaktion. `reconcileShare` legt
        // den Rebuild nur in die Queue, und der liest seinen Vorher-Stand erst
        // dann — die Zeile steht also schon darin und der Rebuild meldet sie
        // niemals. Ohne diese Meldung hat der Anleger in der Projektion kein
        // Recht an seinem eigenen Share, sobald ihm die gespiegelten
        // Projekt-Filter nichts geben (siehe 057 §16.3).
        //
        // Nach dem Lifecycle-Event (`/add/{shareType}/{uid}`), damit die
        // Projektion den Share kennt, bevor das Recht darauf kommt.
        publishVisibilityDiff(beforeOwner, await loadVisibleScopes([UID]), req.session.root);

        // Rechte materialisieren (asynchron): Owner + geerbte Projekt-Rechte +
        // Rechte-Events fuer die Projektion.
        await reconcileShare(req, UID);

        const row = await readShare(UID, projectHex);
        // Nicht blockierender Hinweis: traegt ein **anderer** Root derselben
        // Organisation dieselbe Git-URL, ist das ein Vorschlag fuer die
        // Oberflaeche („bestehendes hinzufuegen"), keine Regel. Die Identitaet
        // ist die UID, nie die URL — deshalb wird hier nichts abgewiesen.
        const warnings = await duplicateGitUrlWarnings(UID, metadata.gitUrl, HEX2uuid(req.session.root));
        const result = row
            ? toShare(row, HEX2uuid(projectHex))
            : { UID: HEX2uuid(UID), project_uid: HEX2uuid(projectHex), Type: type, linkType: levelOf(metadata), metadata };
        return { success: true, result: { ...result, warnings } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Search org-owned repository/directory shares the current user may see.
 *
 * Visibility is derived from the projects a share is linked to: a share is
 * returned when at least one of its linked projects is visible to the user
 * (admin users see all org shares). Because a share carries no organization
 * itself, the result is additionally scoped to the caller's organization via
 * the project chain.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {string} [searchQuery] - terms (SearchSelect sends "+portal* +back*")
 * @param {string} [excludeProjectUid] - skip shares already linked to this project
 * @returns {Promise<Object>}
 */
export const searchShares = async (req, searchQuery, excludeProjectUid) => {
    try {
        const orgHex = UUID2hex(req.session.root);
        const userHex = UUID2hex(req.session.user);
        const admin = await isAdmin(req.session);

        // Boolean-Mode-Syntax der SearchSelect-Komponente in LIKE-Terme wandeln
        const terms = String(searchQuery || '')
            .replace(/[+*@"]/g, ' ')
            .split(/\s+/)
            .filter((t) => t.length > 0)
            .slice(0, 8);

        const params = [];

        let visibilityJoin = '';
        if (!admin) {
            // A share is visible when at least one project it is linked to is
            // visible to the user. The link may point either way (migration):
            // new = share -> project, legacy = project -> share.
            visibilityJoin = `JOIN Links AS vis ON (
                     (vis.UIDTarget=ObjectBase.UID AND vis.Type IN (${PROJECT_LINK_TYPES_SQL}))
                  OR (vis.UID=ObjectBase.UID AND vis.Type='memberA')
                 )
                 JOIN Visible ON (Visible.UID=IF(vis.UIDTarget=ObjectBase.UID, vis.UID, vis.UIDTarget) AND Visible.UIDUser=?)`;
            params.push(userHex);
        }

        const where = [`ObjectBase.Type IN (${SHARE_TYPES_SQL})`];

        // Ein Root hoechstens einmal je Projekt: alles ausschliessen, dessen Root
        // im Zielprojekt schon vertreten ist — egal ob als Basis oder als
        // Ableger. Sonst bote die Suche ein Repo an, das das Projekt schon hat.
        if (excludeProjectUid) {
            const present = await projectShareRoots(UUID2hex(excludeProjectUid));
            if (present.length > 0) {
                where.push(`NOT ((${SHARE_ROOT_SQL('ObjectBase')}) IN (${present.map(() => '?').join(', ')}))`);
                params.push(...present.map((r) => r.root));
            }
        }

        if (terms.length > 0) {
            const likeClauses = terms
                .map(
                    () =>
                        `(ObjectBase.Title LIKE ?
                            OR ObjectBase.Display LIKE ?
                            OR JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.metadata.repositoryKey')) LIKE ?
                            OR JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.metadata.gitUrl')) LIKE ?)`
                )
                .join('\n               AND ');
            where.push(`(${likeClauses})`);
            for (const t of terms) {
                const like = `%${t}%`;
                params.push(like, like, like, like);
            }
        }

        const rows = await query(
            `SELECT DISTINCT ObjectBase.UID, ObjectBase.Type, ObjectBase.Title, ObjectBase.Display, ObjectBase.Data,
                    ${SHARE_ROOT_SQL('ObjectBase')} AS root,
                    DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
             FROM ObjectBase
             ${visibilityJoin}
             WHERE ${where.join('\n               AND ')}
             ORDER BY ObjectBase.Display, ObjectBase.SortName
             LIMIT 50`,
            params,
            { cast: ['json'] },
        );

        // Org-Scope ueber die Projekt-Kette **und** ein Treffer je Root: mit
        // Ablegern liegen Basis und Ableger sonst als mehrere Zeilen desselben
        // Repos in der Liste. Die Basis gewinnt (stabile Anzeige).
        const byRoot = new Map();
        for (const row of rows) {
            if (await getOrganizationForShare(row.UID) !== HEX2uuid(orgHex)) continue;
            const key = HEX2uuid(row.root);
            const prev = byRoot.get(key);
            const rowIsBase = HEX2uuid(row.UID) === key;
            if (!prev || (rowIsBase && HEX2uuid(prev.UID) !== key)) byRoot.set(key, row);
        }

        return {
            success: true,
            result: [...byRoot.values()].map((row) => {
                const share = toShare(row);
                const metadata = share.metadata || {};
                const title =
                    (typeof metadata.repositoryKey === 'string' && metadata.repositoryKey) ||
                    row.Title ||
                    row.Display ||
                    share.UID;
                return { ...share, project_uid: null, title, value: share.UID, root_uid: HEX2uuid(row.root) };
            }),
        };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * List all shares of a project (requires at least visible on the project).
 * Shares are resolved through the project's memberA/member links — in both
 * directions while the old rows still exist.
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @returns {Promise<Object>}
 */
export const listShares = async (req, projectUid) => {
    try {
        const projectHex = UUID2hex(projectUid);
        await requireProjectVisible(req, projectHex);

        const rows = await query(
            `SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo, ObjectBase.Data,
                    ${SHARE_ROOT_SQL('ObjectBase')} AS root,
                    ${SHARE_IS_BASE_SQL('ObjectBase')} AS is_base,
                    Links.Type AS link_type,
                    DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
             FROM ObjectBase
             JOIN Links ON Links.Type IN (${PROJECT_LINK_TYPES_SQL})
                  AND (
                        (Links.UID=? AND Links.UIDTarget=ObjectBase.UID)      -- alt: Projekt -> Share
                     OR (Links.UID=ObjectBase.UID AND Links.UIDTarget=?)     -- neu: Share -> Projekt
                  )
             WHERE ObjectBase.Type IN (${SHARE_TYPES_SQL})
             GROUP BY ObjectBase.UID
             ORDER BY ObjectBase.Title`,
            [projectHex, projectHex],
            { cast: ['json'] },
        );

        // Herkunft und Abhaengige in je einer Abfrage fuer die ganze Liste:
        // „wo kommt dieser Ableger her" (Projekt der Basis) und „welche Projekte
        // haengen an dieser Basis". Beides braucht die Oberflaeche, und beides
        // gehoert hierher, nicht in den Client (der kennt nur dieses Projekt).
        const rootUids = [...new Set(rows.map((r) => r.root))];
        const roots = rows.filter((r) => r.root && HEX2uuid(r.root) === HEX2uuid(r.UID)).map((r) => r.UID);
        const [rootProjects, dependents] = await Promise.all([
            projectsOfShares(rootUids),
            dependentsOfRoots(roots),
        ]);
        const projectOfRoot = new Map(rootProjects.map((p) => [HEX2uuid(p.share), { UID: HEX2uuid(p.project), Title: p.title || p.display || '' }]));
        const dependentsOf = new Map();
        for (const d of dependents) {
            const key = HEX2uuid(d.root);
            if (!dependentsOf.has(key)) dependentsOf.set(key, []);
            dependentsOf.get(key).push({ share_uid: HEX2uuid(d.share), type: d.kind, project_uid: d.project ? HEX2uuid(d.project) : null, project_title: d.title || d.display || null });
        }

        return {
            success: true,
            result: rows.map((r) => {
                const share = toShare(r, HEX2uuid(projectHex));
                const rootKey = HEX2uuid(r.root);
                const isRoot = Boolean(r.is_base);
                const origin = isRoot ? null : projectOfRoot.get(rootKey) || null;
                return {
                    ...share,
                    root_uid: rootKey,
                    is_root: isRoot,
                    // Wo der Ableger herkommt: das Projekt der Basis. `null`,
                    // wenn dieses Objekt selbst die Basis ist (oder die Basis in
                    // keinem Projekt mehr haengt).
                    origin: isRoot ? null : projectOfRoot.get(rootKey) || null,
                    // Nur an der Basis sinnvoll: die Projekte, die davon abhaengen.
                    dependents: isRoot ? dependentsOf.get(rootKey) || [] : [],
                };
            }),
        };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Update share metadata (requires admin on the project or `Visible.admin` on
 * the share, i.e. the owner).
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @param {string} shareUid
 * @returns {Promise<Object>}
 */
export const updateShare = async (req, projectUid, shareUid) => {
    try {
        const projectHex = UUID2hex(projectUid);
        const shareHex = UUID2hex(shareUid);
        const userHex = UUID2hex(req.session.user);
        await requireProjectChangeable(req, projectHex);

        const existing = await readShare(shareHex, projectHex);
        if (!existing) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found for this project');
        await requireShareAdmin(req, projectHex, shareHex);

        const body = req.body || {};
        const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)
            ? body.metadata
            : (existing.Data?.metadata ?? {});
        if (metadata.acl !== undefined || metadata.capabilities !== undefined) {
            throw apiError(422, 'INVALID_SHARE_METADATA', 'Share metadata must not carry an ACL or capabilities');
        }
        // Legacy `linkType` acts as an alias for the mode (see addShare).
        if (typeof body.linkType !== 'undefined') {
            const legacyMode = modeFromLinkType(body.linkType);
            if (legacyMode === null) {
                throw apiError(422, 'INVALID_LINK_TYPE', `linkType must be one of: ${LINK_TYPES.join(', ')}`);
            }
            if (typeof metadata.mode === 'undefined') metadata.mode = legacyMode;
        }

        const title = typeof metadata.repositoryKey === 'string' && metadata.repositoryKey ? metadata.repositoryKey : existing.Title || 'share';
        const events = [];

        await transaction(async (connection) => {
            await connection.query(
                `UPDATE ObjectBase SET Title=?, Display=?, SortName=?, Data=?, UIDuser=?
                 WHERE UID=? AND Type IN (${SHARE_TYPES_SQL})`,
                [title, title, title, JSON.stringify({ metadata }), userHex, shareHex],
            );
            if (PROJECT_EVENTS_ENABLED) {
                const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
                await writeEventLog(`/change/${existing.Type}/${HEX2uuid(shareHex)}`, payload, connection);
                events.push({ key: `/change/${existing.Type}/${HEX2uuid(shareHex)}`, payload });
            }
        });

        for (const ev of events) {
            publishToRedis(ev.key, ev.payload);
        }

        const row = await readShare(shareHex, projectHex);
        return { success: true, result: row ? toShare(row, HEX2uuid(projectHex)) : existing };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Eine Zuordnung aus einem Projekt entfernen.
 *
 * Ist das Objekt die **Basis** („Root") und haengen Ableger daran, wird **nicht
 * stillschweigend** die Basis behalten: der Aufrufer bestimmt mit
 * `successor` (`body.successor` oder `?successor=`), **welcher Ableger die neue
 * Basis wird** — also in welches Projekt der Root wandert. Ohne diese Angabe
 * antwortet der Endpunkt mit `409 SHARE_ROOT_NEEDS_SUCCESSOR` und der Liste der
 * moeglichen Nachfolger, damit die Oberflaeche waehlen kann.
 *
 * Der Aufruf bleibt idempotent-tolerant: zeigt der `successor` nicht auf einen
 * Ableger dieses Roots, ist das ein `422 INVALID_SUCCESSOR`.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @param {string} shareUid
 * @returns {Promise<Object>}
 */
export const deleteShare = async (req, projectUid, shareUid) => {
    try {
        const projectHex = UUID2hex(projectUid);
        await requireProjectChangeable(req, projectHex);

        // Der Aufrufer darf den Root nennen (Alt-Client) oder das Projekt-Objekt
        // (Ableger) — die harte Root-Regel macht die Aufloesung eindeutig.
        const shareHex = await resolveProjectShare(projectHex, UUID2hex(shareUid));
        if (!shareHex) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found for this project');

        const existing = await readShare(shareHex, projectHex);
        if (!existing) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found for this project');
        await requireShareAdmin(req, projectHex, shareHex);
        const linkType = existing.link_type === 'memberA' ? 'write' : 'read';

        // Root-Umzug: wer beerbt diese Basis? Nur noetig, wenn sie wirklich eine
        // Basis **mit** abhaengigen Ablegern ist.
        const rootInfo = await rootOfShare(shareHex);
        /** @type {Array<any>} */
        let dependents = [];
        let successorHex = null;
        if (rootInfo && rootInfo.isBase) {
            dependents = await dependentsOfRoots([shareHex]);
            if (dependents.length > 0) {
                const requested = String(req.body?.successor ?? req.query?.successor ?? '').trim();
                const matches = requested ? dependents.find((d) => HEX2uuid(d.share) === requested) : null;
                if (!requested) {
                    throw apiError(409, 'SHARE_ROOT_NEEDS_SUCCESSOR', 'This base repository has dependent copies — choose which one becomes the new base', {
                        root_uid: HEX2uuid(shareHex),
                        dependents: dependents.map((d) => ({
                            share_uid: HEX2uuid(d.share),
                            project_uid: HEX2uuid(d.project),
                            project_title: d.title || d.display || null,
                        })),
                    });
                }
                if (!matches) {
                    throw apiError(422, 'INVALID_SUCCESSOR', 'successor must be one of the dependent shares of this repository');
                }
                successorHex = matches.share;
            }
        }

        const events = [];
        await transaction(async (connection) => {
            // Erst den Root umziehen (Nachfolger wird Basis, die anderen folgen),
            // dann die Zuordnung loesen — so ist der Root nie kurzzeitig ohne
            // Basis.
            if (successorHex) {
                const repointed = await promoteRoot(successorHex, shareHex, connection);
                if (PROJECT_EVENTS_ENABLED) {
                    // Ein Lifecycle-Event je umgehaengtem Objekt: rag-sync liest
                    // `root_uid` beim Objekt-Read neu, das ist die Index-Identitaet.
                    for (const rep of [successorHex, ...repointed]) {
                        const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(rep)] });
                        await writeEventLog(`/add/${existing.Type}/${HEX2uuid(rep)}`, payload, connection);
                        events.push({ key: `/add/${existing.Type}/${HEX2uuid(rep)}`, payload });
                    }
                }
            }
            // Remove the project link in both directions (legacy rows).
            await connection.query(
                `DELETE Links FROM Links
                 WHERE Type IN (${PROJECT_LINK_TYPES_SQL})
                   AND ((UID=? AND UIDTarget=?) OR (UID=? AND UIDTarget=?))`,
                [projectHex, shareHex, shareHex, projectHex],
            );
            if (PROJECT_EVENTS_ENABLED) {
                const linkPayload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
                await writeEventLog(`/remove/project/${linkType}/${HEX2uuid(projectHex)}`, linkPayload, connection);
                events.push({ key: `/remove/project/${linkType}/${HEX2uuid(projectHex)}`, payload: linkPayload });
            }
            // Only remove the object when no project links it any more **and** no
            // Ableger depends on it: a base share stays as long as one of its
            // Ableger needs it (then it simply has no project of its own). The
            // owner link (`member` to a person) does not count as a project link.
            const [{ n }] = await connection.query(
                `SELECT COUNT(*) AS n FROM Links
                 WHERE Type IN (${PROJECT_LINK_TYPES_SQL})
                   AND ((UID=? AND UIDTarget IN (SELECT UID FROM ObjectBase WHERE Type='project'))
                     OR (UIDTarget=? AND UID IN (SELECT UID FROM ObjectBase WHERE Type='project')))`,
                [shareHex, shareHex],
            );
            if (Number(n ?? 0) === 0 && !(await hasDerivedShares(shareHex, connection))) {
                await connection.query(`DELETE FROM ObjectBase WHERE UID=? AND Type IN (${SHARE_TYPES_SQL})`, [shareHex]);
                await connection.query(`DELETE FROM Visible WHERE UID=?`, [shareHex]);
                await connection.query(`DELETE Links FROM Links WHERE UID=? OR UIDTarget=?`, [shareHex, shareHex]);
                if (PROJECT_EVENTS_ENABLED) {
                    const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
                    await writeEventLog(`/remove/${existing.Type}/${HEX2uuid(shareHex)}`, payload, connection);
                    events.push({ key: `/remove/${existing.Type}/${HEX2uuid(shareHex)}`, payload });
                }
            }
        });

        for (const ev of events) {
            publishToRedis(ev.key, ev.payload);
        }

        const objectRemoved = events.some((e) => e.key.startsWith(`/remove/${existing.Type}/`));
        // Wurde das Objekt **behalten** (weil ein Ableger es als Basis braucht),
        // hat es jetzt kein Projekt mehr: die geerbten Rechte-Zeilen muessen weg,
        // sonst traegt der Bestands-Root sie weiter in die Projektion. Der Owner
        // bleibt — seine Zeile ist nicht projekt-abgeleitet.
        if (!objectRemoved) await reconcileShare(req, shareHex);

        return {
            success: true,
            result: {
                UID: HEX2uuid(shareHex),
                removed: true,
                object_removed: objectRemoved,
                new_root_uid: successorHex ? HEX2uuid(successorHex) : null,
            },
        };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Ein **bestehendes** Repo an ein Projekt haengen (verlangt **changeable**).
 *
 * Das legt einen **Ableger** an: ein eigenes `ObjectBase`-Objekt mit eigener
 * UID, das in `UIDBelongsTo` auf den **Basis-Share** (= der Root, die
 * Index-Identitaet) zeigt und die projekt-eigenen Einstellungen traegt. Der
 * Basis-Share bleibt unangetastet — derselbe Code wird damit **einmal**
 * indexiert, auch wenn er in mehreren Projekten haengt
 * ([Shares eines Projekts](https://members.app.commtool.org/-/001-Backend/Datenstruktur/dProjectShares)).
 *
 * **Harte Regel: ein Root hoechstens einmal je Projekt.** Haengt bereits ein
 * Objekt desselben Roots am Projekt, ist der Aufruf idempotent (es ist genau
 * dieses Objekt) oder ein `409 SHARE_ALREADY_IN_PROJECT`. Damit kann derselbe
 * Root nicht als Basis **und** als Ableger im selben Projekt liegen.
 *
 * `linkType` wird nur noch **validiert** (Alt-Alias), nie geschrieben: der
 * Modus ist eine Eigenschaft des Shares (§13.9 Punkt 3).
 *
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @param {string} shareUid
 * @returns {Promise<Object>}
 */
export const linkShare = async (req, projectUid, shareUid) => {
    try {
        const projectHex = UUID2hex(projectUid);
        const shareHex = UUID2hex(shareUid);
        const userHex = UUID2hex(req.session.user);
        await requireProjectChangeable(req, projectHex);

        const body = req.body || {};
        const linkType = typeof body.linkType === 'undefined' ? 'memberA' : body.linkType;
        // `linkType` wird nur noch **validiert**, nicht mehr geschrieben.
        //
        // Der Link traegt keinen Level mehr (§13.6: genau ein Link-Typ,
        // `memberA`), und `metadata.mode` gehoert ausschliesslich in
        // `updateShare`/`RepoModal` (§13.9 Punkt 3). Vorher setzte dieser Pfad bei
        // `read` ein `metadata.mode = readOnly` — und **nie** zurueck: der
        // Schalter konnte einen Share sperren, aber nicht entsperren. Der Modus
        // muss in beide Richtungen setzbar bleiben; das ist eine Eigenschaft des
        // Shares, nicht des Links. Unbekannte Werte bleiben ein 422.
        if (modeFromLinkType(linkType) === null) {
            throw apiError(422, 'INVALID_LINK_TYPE', `linkType must be one of: ${LINK_TYPES.join(', ')}`);
        }

        // Das gewaehlte Objekt auf seinen Root ziehen. Der Root ist die Vorlage:
        // seine Metadaten (gitUrl, repositoryKey, mode …) gelten fuer alle
        // Projekte. Ein projekt-lokaler Wert am gewaehlten Ableger wird **nicht**
        // weitergegeben — sonst traegt P1 die Sperre von P2.
        const picked = await rootOfShare(shareHex);
        if (!picked) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found');
        if (await getOrganizationForShare(shareHex) !== HEX2uuid(req.session.root)) {
            throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found in this organization');
        }

        const [base] = await query(
            `SELECT UID, Type, Title, Display, SortName, Data FROM ObjectBase
              WHERE UID=? AND Type IN (${SHARE_TYPES_SQL})`,
            [picked.root],
            { cast: ['json'] },
        );
        if (!base) throw apiError(404, 'SHARE_ROOT_NOT_FOUND', 'Base share not found');

        // Ein Root hoechstens einmal je Projekt.
        const rootKey = HEX2uuid(picked.root);
        const already = (await projectShareRoots(projectHex)).find((r) => HEX2uuid(r.root) === rootKey);
        if (already) {
            if (HEX2uuid(already.share) === HEX2uuid(shareHex)) {
                const row = await readShare(shareHex, projectHex);
                const linked = row ? toShare(row, HEX2uuid(projectHex)) : null;
                // Idempotent: das Projekt hat diesen Root schon — ueber genau
                // dieses Objekt. Kein zweiter Traeger, kein Fehler.
                return {
                    success: true,
                    result: linked
                        ? { ...linked, level: linked.linkType, root_uid: rootKey, created: false }
                        : { UID: HEX2uuid(shareHex), project_uid: HEX2uuid(projectHex), root_uid: rootKey, created: false },
                };
            }
            throw apiError(409, 'SHARE_ALREADY_IN_PROJECT', 'This repository is already part of the project', {
                share_uid: HEX2uuid(already.share),
                root_uid: rootKey,
            });
        }

        const metadata = base.Data?.metadata ?? {};
        const title = base.Title || (typeof metadata.repositoryKey === 'string' && metadata.repositoryKey) || 'share';
        const UID = await getUID(req);
        const events = [];
        // Vorher-Stand der Rechte-Zeilen: beim Anlegen eines Ablegers leer, aber
        // gemessen statt angenommen (siehe addShare).
        const beforeOwner = await loadVisibleScopes([UID]);

        await transaction(async (connection) => {
            // Ableger: eigene UID, `UIDBelongsTo` = Basis-Share (Root).
            await connection.query(
                `INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
                [UID, base.Type, picked.root, title, base.Display || title, base.SortName || title, 0, JSON.stringify({ metadata }), userHex],
            );
            // Neue Richtung: der Ableger zeigt auf sein Projekt.
            await connection.query(
                `INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
                [UID, projectHex, userHex],
            );
            // Owner: wer den Ableger anlegt, besitzt ihn (wie beim Anlegen).
            await connection.query(
                `INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'member', ?, ?)`,
                [UID, userHex, userHex],
            );
            await connection.query(
                `INSERT INTO Visible (UID, Type, UIDUser) VALUES (?, 'admin', ?)`,
                [UID, userHex],
            );
            // Rechte-Vererbung: Filter-Satz des Projekts auf den **Ableger** spiegeln.
            await mirrorProjectFilters(projectHex, UID, connection);
            if (PROJECT_EVENTS_ENABLED) {
                const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(UID)] });
                await writeEventLog(`/add/${base.Type}/${HEX2uuid(UID)}`, payload, connection);
                events.push({ key: `/add/${base.Type}/${HEX2uuid(UID)}`, payload });
                const linkPayload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(UID)] });
                await writeEventLog(`/add/project/write/${HEX2uuid(projectHex)}`, linkPayload, connection);
                events.push({ key: `/add/project/write/${HEX2uuid(projectHex)}`, payload: linkPayload });
            }
        });

        for (const ev of events) {
            publishToRedis(ev.key, ev.payload);
        }

        // Owner-Zeile melden (siehe addShare): sie entsteht **in** der
        // Transaktion, der Rebuild sieht sie daher nie als Delta.
        publishVisibilityDiff(beforeOwner, await loadVisibleScopes([UID]), req.session.root);

        // Rechte materialisieren: Owner + geerbte Projekt-Rechte + Rechte-Events.
        await reconcileShare(req, UID);

        const row = await readShare(UID, projectHex);
        const linked = row
            ? toShare(row, HEX2uuid(projectHex))
            : { UID: HEX2uuid(UID), project_uid: HEX2uuid(projectHex), Type: base.Type, linkType: levelOf(metadata), metadata };
        return { success: true, result: { ...linked, level: linked.linkType, root_uid: rootKey, created: true } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Remove a link between a share and a project (requires **changeable**).
 * Fires the matching level event; the share object itself is never removed here.
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @param {string} shareUid
 * @returns {Promise<Object>}
 */
export const unlinkShare = async (req, projectUid, shareUid) => {
    try {
        const projectHex = UUID2hex(projectUid);
        await requireProjectChangeable(req, projectHex);

        // Root **oder** Projekt-Objekt (Ableger) werden akzeptiert — wie beim
        // Loeschen, und eindeutig durch die harte Root-Regel.
        const shareHex = await resolveProjectShare(projectHex, UUID2hex(shareUid));
        if (!shareHex) throw apiError(404, 'SHARE_LINK_NOT_FOUND', 'Share is not linked to this project');

        const [link] = await query(
            `SELECT Type FROM Links
             WHERE Type IN (${PROJECT_LINK_TYPES_SQL})
               AND ((UID=? AND UIDTarget=?) OR (UID=? AND UIDTarget=?))
             LIMIT 1`,
            [projectHex, shareHex, shareHex, projectHex],
        );
        if (!link) throw apiError(404, 'SHARE_LINK_NOT_FOUND', 'Share is not linked to this project');

        const events = [];
        await transaction(async (connection) => {
            await connection.query(
                `DELETE Links FROM Links
                 WHERE Type IN (${PROJECT_LINK_TYPES_SQL})
                   AND ((UID=? AND UIDTarget=?) OR (UID=? AND UIDTarget=?))`,
                [projectHex, shareHex, shareHex, projectHex],
            );
            if (PROJECT_EVENTS_ENABLED) {
                const level = link.Type === 'memberA' ? 'write' : 'read';
                const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
                await writeEventLog(`/remove/project/${level}/${HEX2uuid(projectHex)}`, payload, connection);
                events.push({ key: `/remove/project/${level}/${HEX2uuid(projectHex)}`, payload });
            }
        });

        for (const ev of events) {
            publishToRedis(ev.key, ev.payload);
        }

        // Rechte nachziehen: ohne Projekt-Link fallen die gespiegelten Filter und
        // die geerbten Zeilen weg; Owner und Org-Superadmin bleiben.
        await reconcileShare(req, shareHex);

        return { success: true, result: { UID: HEX2uuid(shareHex), project_uid: HEX2uuid(projectHex), removed: true } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};