Source: RouterProject/projectShare/service.js

// @ts-check
/**
 * Share service - repository/directory share objects belonging to the
 * organization (UIDBelongsTo=org), linked to one or more projects via
 * Links.Type IN ('memberA','member').
 *
 * Storage model:
 * - ObjectBase row, Type='repositoryShare'|'directoryShare', UIDBelongsTo=org UID
 * - Data JSON: { metadata }
 * - relation via Links.Type='memberA'|'member' (UID=project, UIDTarget=share),
 *   written in the same transaction as the object row.
 *   'memberA' = write/admin (Master-Projekt), 'member' = read only.
 *
 * The wire format follows the App-Standard: UID / Type / project_uid / metadata
 * plus `linkType` ('read'|'write') describing the project's link to the share.
 *
 * @import {ExpressRequestAuthorized} from '../../types.js'
 */

import { query, transaction, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { getUID } from '../../utils/UUIDs.js';
import { isAdmin, isListAdmin, 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';

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

/** @type {const} */
const LINK_TYPES = ['memberA', 'member'];

/** @type {const} */
const LINK_TYPES_SQL = `'memberA','member'`;

const LINK_LEVEL = { memberA: 'write', member: 'read' };

/**
 * Verify the project exists in the organization and the user may administer it.
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectHex
 * @returns {Promise<void>}
 */
const requireProjectAdmin = async (req, projectHex) => {
    const orgHex = UUID2hex(req.session.root);
    const [project] = await query(
        `SELECT UID FROM ObjectBase WHERE UID=? AND Type='project' AND UIDBelongsTo=?`,
        [projectHex, orgHex],
    );
    if (!project) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found in this organization');
    if (!(await isListAdmin(req, HEX2uuid(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 orgHex = UUID2hex(req.session.root);
    const [project] = await query(
        `SELECT UID FROM ObjectBase WHERE UID=? AND Type='project' AND UIDBelongsTo=?`,
        [projectHex, orgHex],
    );
    if (!project) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found in this organization');
    if (!(await isObjectVisible(req, projectHex))) {
        throw apiError(403, 'PROJECT_NOT_ACCESSIBLE', 'Project is not accessible');
    }
};

/**
 * Read a share row that is linked to the given project (current state).
 * Returns the share with the project's link type (link_type), or null when the
 * share does not belong to the organization or has no link to this project.
 * @param {string} shareHex
 * @param {string} projectHex
 * @param {string} orgHex
 */
const readShare = async (shareHex, projectHex, orgHex) => {
    const rows = await query(
        `SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo, ObjectBase.Data,
                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.UID=? AND Links.UIDTarget=ObjectBase.UID AND Links.Type IN (${LINK_TYPES_SQL})
         WHERE ObjectBase.UID=? AND ObjectBase.Type IN (${SHARE_TYPES_SQL}) AND ObjectBase.UIDBelongsTo=?`,
        [projectHex, shareHex, orgHex],
        { cast: ['json'] },
    );
    return rows[0] || null;
};

/**
 * Map a share row to the app Share object (UID / Type / project_uid / metadata
 * / linkType). The share belongs to the org, so project_uid comes from the
 * project context, not from UIDBelongsTo.
 * @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 || HEX2uuid(row.UIDBelongsTo),
        Type: row.Type,
        linkType: row.link_type ? LINK_LEVEL[row.link_type] : null,
        metadata,
        source_updated_at: row.source_updated_at,
    };
};

/**
 * Add a share to a project (requires admin on the project). The share is
 * created with UIDBelongsTo=org and linked to the project as 'memberA'
 * (write/admin = Master-Projekt).
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @returns {Promise<Object>}
 */
export const addShare = async (req, projectUid) => {
    try {
        const projectHex = UUID2hex(projectUid);
        const orgHex = UUID2hex(req.session.root);
        const userHex = UUID2hex(req.session.user);
        await requireProjectAdmin(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');
        }

        const UID = await getUID(req);
        const title = typeof metadata.repositoryKey === 'string' && metadata.repositoryKey ? metadata.repositoryKey : 'share';
        const events = [];

        await transaction(async (connection) => {
            await connection.query(
                `INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
                [UID, type, orgHex, title, title, title, 0, JSON.stringify({ metadata }), userHex],
            );
            await connection.query(
                `INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
                [projectHex, UID, userHex],
            );
            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 });
                // Master-Link (memberA → write): Projekt darf das Repo administrieren
                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);
        }

        const row = await readShare(UID, projectHex, orgHex);
        return {
            success: true,
            result: row
                ? toShare(row, HEX2uuid(projectHex))
                : { UID: HEX2uuid(UID), project_uid: HEX2uuid(projectHex), Type: type, linkType: 'write', metadata },
        };
    } 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 memberA/member linked projects is visible
 * to the user (admin users see all org shares). This is the server-side
 * enforcement for the "add existing repository" flow.
 *
 * @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) {
            visibilityJoin = `JOIN Links ON (Links.UIDTarget=ObjectBase.UID AND Links.Type IN (${LINK_TYPES_SQL}))
                 JOIN Visible ON (Visible.UID=Links.UID AND Visible.UIDUser=?)`;
            params.push(userHex);
        }

        const where = [`ObjectBase.Type IN (${SHARE_TYPES_SQL})`, `ObjectBase.UIDBelongsTo=?`];
        params.push(orgHex);

        if (excludeProjectUid) {
            where.push(
                `NOT EXISTS (
                    SELECT 1 FROM Links ex
                    WHERE ex.UID=? AND ex.UIDTarget=ObjectBase.UID AND ex.Type IN (${LINK_TYPES_SQL})
                )`
            );
            params.push(UUID2hex(excludeProjectUid));
        }

        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,
                    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 25`,
            params,
            { cast: ['json'] },
        );

        return {
            success: true,
            result: rows.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 };
            }),
        };
    } 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.
 * @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,
                    Links.Type AS link_type,
                    DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
             FROM Links
             JOIN ObjectBase ON ObjectBase.UID=Links.UIDTarget
             WHERE Links.UID=? AND Links.Type IN (${LINK_TYPES_SQL})
               AND ObjectBase.Type IN (${SHARE_TYPES_SQL})
             ORDER BY ObjectBase.UID`,
            [projectHex],
            { cast: ['json'] },
        );
        return { success: true, result: rows.map((r) => toShare(r, HEX2uuid(projectHex))) };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Update share metadata (requires admin on the project AND a 'memberA' link,
 * i.e. the project is a master for the repo).
 * @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 orgHex = UUID2hex(req.session.root);
        const shareHex = UUID2hex(shareUid);
        const userHex = UUID2hex(req.session.user);
        await requireProjectAdmin(req, projectHex);

        const existing = await readShare(shareHex, projectHex, orgHex);
        if (!existing) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found for this project');
        if (existing.link_type !== 'memberA') {
            throw apiError(403, 'SHARE_NOT_MASTER', 'Only a master (memberA) project may administrate this share');
        }

        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');
        }

        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}) AND UIDBelongsTo=?`,
                [title, title, title, JSON.stringify({ metadata }), userHex, shareHex, orgHex],
            );
            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, orgHex);
        return { success: true, result: row ? toShare(row, HEX2uuid(projectHex)) : existing };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Delete a share (requires admin on the project AND a 'memberA' link). The
 * project's link is removed; the share object itself is only deleted when no
 * further links exist.
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @param {string} shareUid
 * @returns {Promise<Object>}
 */
export const deleteShare = async (req, projectUid, shareUid) => {
    try {
        const projectHex = UUID2hex(projectUid);
        const orgHex = UUID2hex(req.session.root);
        const shareHex = UUID2hex(shareUid);
        await requireProjectAdmin(req, projectHex);

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

        const events = [];
        await transaction(async (connection) => {
            await connection.query(
                `DELETE Links FROM Links WHERE UID=? AND UIDTarget=? AND Type IN (${LINK_TYPES_SQL})`,
                [projectHex, shareHex],
            );
            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
            // Achtung: connection.query liefert hier direkt das Rows-Array
            // (`[{ n }]`), nicht `[rows, meta]` — daher kein `[[{ n }]]`.
            const [{ n }] = await connection.query(
                `SELECT COUNT(*) AS n FROM Links WHERE UIDTarget=? AND Type IN (${LINK_TYPES_SQL})`,
                [shareHex],
            );
            if (Number(n ?? 0) === 0) {
                await connection.query(`DELETE FROM ObjectBase WHERE UID=? AND Type IN (${SHARE_TYPES_SQL}) AND UIDBelongsTo=?`, [shareHex, orgHex]);
                await connection.query(`DELETE FROM Visible WHERE UID=?`, [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);
        }

        return { success: true, result: { UID: HEX2uuid(shareHex), removed: true, object_removed: events.some((e) => e.key.startsWith(`/remove/${existing.Type}/`)) } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Link an existing share to a project (requires admin on the project).
 * Sets a 'memberA' (write) or 'member' (read) link and fires the matching
 * level event so rag-sync can maintain the projection.
 * @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 orgHex = UUID2hex(req.session.root);
        const shareHex = UUID2hex(shareUid);
        const userHex = UUID2hex(req.session.user);
        await requireProjectAdmin(req, projectHex);

        const body = req.body || {};
        const linkType = body.linkType;
        if (!LINK_TYPES.includes(linkType)) {
            throw apiError(422, 'INVALID_LINK_TYPE', `linkType must be one of: ${LINK_TYPES.join(', ')}`);
        }

        const [share] = await query(
            `SELECT UID, Type FROM ObjectBase WHERE UID=? AND Type IN (${SHARE_TYPES_SQL}) AND UIDBelongsTo=?`,
            [shareHex, orgHex],
        );
        if (!share) throw apiError(404, 'SHARE_NOT_FOUND', 'Share not found in this organization');

        const events = [];
        await transaction(async (connection) => {
            await connection.query(
                `INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, ?, ?, ?)`,
                [projectHex, linkType, shareHex, userHex],
            );
            if (PROJECT_EVENTS_ENABLED) {
                const level = LINK_LEVEL[linkType];
                const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(shareHex)] });
                await writeEventLog(`/add/project/${level}/${HEX2uuid(projectHex)}`, payload, connection);
                events.push({ key: `/add/project/${level}/${HEX2uuid(projectHex)}`, payload });
            }
        });

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

        return { success: true, result: { UID: HEX2uuid(shareHex), project_uid: HEX2uuid(projectHex), linkType, level: LINK_LEVEL[linkType] } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Remove a link between a share and a project (requires admin on the project).
 * 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);
        const orgHex = UUID2hex(req.session.root);
        const shareHex = UUID2hex(shareUid);
        await requireProjectAdmin(req, projectHex);

        const [link] = await query(
            `SELECT Type FROM Links WHERE UID=? AND UIDTarget=? AND Type IN (${LINK_TYPES_SQL})`,
            [projectHex, shareHex],
        );
        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 UID=? AND UIDTarget=? AND Type IN (${LINK_TYPES_SQL})`,
                [projectHex, shareHex],
            );
            if (PROJECT_EVENTS_ENABLED) {
                const level = LINK_LEVEL[link.Type];
                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);
        }

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