Source: RouterProject/project/service.js

// @ts-check
/**
 * Project service - source of truth for project objects and project-wide
 * metadata (see 080-Workspaces/025-Members-Backend-Implementation-Plan.mdx).
 *
 * Storage model:
 * - ObjectBase row, Type='project', `UIDBelongsTo` = **own UID** (self-reference,
 *   the object rule for base objects — like list/dlist/location). The project
 *   carries **no** organization: that is derived over the owner group.
 * - Data JSON: { description, metadata, groupUID }
 * - group membership via Links.Type='memberA' (project -> group)
 * - personal owner via Links.Type='member' (project -> user)
 * - Access is materialized in Visible (visible | changeable | admin) only
 *
 * Migration tolerance: rows written by the old code still carry
 * `UIDBelongsTo` = organization. All organization checks therefore go through
 * the link chain (`objectInOrgSql` / `getOrganizationForObject`), which answers
 * for both shapes.
 *
 * The wire format follows the App-Standard like list/dlist/location/event:
 * UID / Title / Display / Data (+ source_updated_at).
 *
 * @import {ExpressRequestAuthorized} from '../../types.js'
 */

import { query, transaction, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { getUID } from '../../utils/UUIDs.js';
import { hasActionPermission } from '../../utils/actionPermissions.js';
import { isAdmin, isListAdmin, isObjectVisible } from '../../utils/authChecks.js';
import { apiError } from '../../utils/apiEnvelope.js';
import { metadataFromRow } 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 { addVisibility } from '../../utils/listVisibilty.js';
import { isObjectInOrg, objectInOrgSql, OBJECT_IN_ORG_PARAMS } from '../../utils/organizationUtils.js';
import { reconcileSharesOfProject, hasDerivedShares, dependentsOfRoots } from '../projectShare/access.js';
import { loadVisibleScopes, publishVisibilityDiff } from '../../tree/visibilityEvents.js';
import { runnerInOrg, isUsableSharedRunner } from '../../RouterRunner/runner/service.js';

const PROJECT_TYPES = `'project'`;
const SHARE_TYPES = `'repositoryShare','directoryShare'`;

/** @typedef {import('../../utils/events.js').EventLogConnection} EventLogConnection */

/**
 * Full project SELECT used for reads. `source_updated_at` is derived from the
 * temporal row start (ValidFrom) with microsecond precision.
 */
const projectSelect = `
    SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo,
           ObjectBase.Title, ObjectBase.Display, ObjectBase.Data,
           ObjectBase.UIDuser,
           JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.description')) AS description,
           JSON_OBJECT('value', JSON_EXTRACT(ObjectBase.Data, '$.metadata')) AS metadata_json,
           JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.groupUID')) AS group_uid,
           DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
    FROM ObjectBase
`;

/**
 * Convert a raw project row (with metadata_json envelope) to the app object
 * shape: UID/Title/Display/Data (+ source_updated_at). Same convention as
 * list/dlist/location/event objects.
 * @param {any} row
 */
export const toProject = (row) => {
    return {
        UID: HEX2uuid(row.UID),
        Title: row.Title || row.Display || '',
        Display: row.Display || row.Title || '',
        Data: {
            description: row.description ?? null,
            metadata: metadataFromRow(row),
            groupUID: row.group_uid ? HEX2uuid(row.group_uid) : null,
        },
        group_uid: row.group_uid ? HEX2uuid(row.group_uid) : null,
        source_updated_at: row.source_updated_at,
    };
};

/**
 * Resolve the project group (owner group) for a project.
 *
 * Follows the list/dlist pattern: the requested group is loaded, falling back
 * to the root group of the organization if it does not exist or is not a
 * group/event object.
 *
 * @param {string} groupHex - requested group UID (hex) or organization UID
 * @param {string} orgHex - organization UID (hex)
 * @returns {Promise<{UID: string, Title: string, Display: string, Data: Object}|null>}
 */
const resolveProjectGroup = async (groupHex, orgHex) => {
    const load = async (uidHex, types) => {
        const rows = await query(
            `SELECT ObjectBase.UID, Member.Display, Member.Data, ObjectBase.Title
             FROM ObjectBase
             INNER JOIN Member ON (Member.UID = ObjectBase.UID)
             WHERE ObjectBase.UID=? AND ObjectBase.Type IN (${types})`,
            [uidHex],
            { cast: ['UUID', 'json'] },
        );
        return rows[0] || null;
    };
    let group = await load(groupHex, `'group','event'`);
    if (!group) {
        group = await load(orgHex, `'group'`);
    }
    return group;
};

/**
 * Verify that a project exists within the current organization.
 *
 * The organization is derived over the owner group (`project → memberA → group
 * → … → root group`), not read from `UIDBelongsTo` — that field now holds the
 * project's own UID.
 *
 * @param {string} uidHex
 * @param {string} orgHex
 * @returns {Promise<boolean>}
 */
export const projectInOrg = async (uidHex, orgHex) => {
    const rows = await query(
        `SELECT UID FROM ObjectBase WHERE UID=? AND Type=${PROJECT_TYPES}`,
        [uidHex],
    );
    if (rows.length !== 1) return false;
    return isObjectInOrg(uidHex, orgHex);
};

/**
 * Authorize: the user may administrate the project (Visible.Type='admin' or org admin).
 * @param {ExpressRequestAuthorized} req
 * @param {string} uidHex
 */
const requireProjectAdmin = async (req, uidHex) => {
    if (!(await isListAdmin(req, HEX2uuid(uidHex)))) {
        throw apiError(403, 'PROJECT_NOT_CHANGEABLE', 'Project is not changeable by this user');
    }
};

/**
 * Projekt-Runner aus `metadata.runnerUid` lesen (Wire-UID „UUID-…" oder null).
 * Quelle: Project.Data.metadata.runnerUid (Projekteinstellung „öffentlicher
 * Runner", Soll-Auflösung beim Session-Anlegen).
 * @param {Buffer|string} projectHex
 * @returns {Promise<string|null>}
 */
export const readProjectRunnerUid = async (projectHex) => {
    const rows = await query(
        `SELECT JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.metadata.runnerUid')) AS runner_uid
         FROM ObjectBase WHERE UID=? AND Type='project'`,
        [projectHex],
    );
    return rows[0]?.runner_uid || null;
};

/**
 * Validiert einen Projekt-Runner (metadata.runnerUid): muss ein `shared`-/
 * `team`-Runner derselben Orga sein (nicht `personal`, nicht `revoked`).
 * Leerer Wert (null/'') ist erlaubt und entfernt die Einstellung.
 * @param {unknown} runnerUid — Wire-UID („UUID-…") oder leer
 * @param {string} orgHex
 */
const assertUsableProjectRunner = async (runnerUid, orgHex) => {
    if (runnerUid == null || runnerUid === '') return;
    const hex = UUID2hex(String(runnerUid));
    if (!(await isUsableSharedRunner(hex, orgHex))) {
        throw apiError(422, 'INVALID_PROJECT_RUNNER',
            'Projekt-Runner ungültig — muss ein shared-/team-Runner derselben Orga sein (nicht personal, nicht revoked)');
    }
};

/**
 * Create a new project.
 *
 * Mirrors the list/dlist pattern (`Router/list/service.js`):
 * - `memberA` link points to the project group (owner group), not the organization
 * - `member` link points to the creating user
 * - creator gets `admin` in Visible
 * - a default visibility filter rule is created via `addVisibility`, so all
 *   job holders of the project group (and super admins) see the project
 *
 * @param {ExpressRequestAuthorized} req
 * @returns {Promise<Object>} project object (UID/Title/Display/Data)
 */
export const createProject = async (req) => {
    try {
        const orgHex = UUID2hex(req.session.root);
        const userHex = UUID2hex(req.session.user);

        if (!(await hasActionPermission(req, 'project.create'))) {
            throw apiError(403, 'PROJECT_CREATE_FORBIDDEN', 'No project.create job action permission in this organization');
        }

        const body = req.body || {};
        const UID = await getUID(req);
        const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Untitled Project';
        const description = typeof body.description === 'string' ? body.description : null;
        const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
        if (typeof metadata.runnerUid !== 'undefined') {
            await assertUsableProjectRunner(metadata.runnerUid, orgHex);
        }
        // Project group: the `:group` path parameter (like list `PUT /:owner`),
        // falling back to body.group_uid for the global new-project flow,
        // otherwise the root group of the organization.
        let groupUID = req.params.group ? UUID2hex(req.params.group)
            : (body.group_uid ? UUID2hex(body.group_uid) : orgHex);
        const group = await resolveProjectGroup(groupUID, orgHex);
        if (group) {
            groupUID = UUID2hex(group.UID);
        }
        const data = { description, metadata, groupUID: HEX2uuid(groupUID) };

        const events = [];
        // Vorher-Stand der Rechte-Zeilen des neuen Projekts (beim Anlegen leer,
        // aber gemessen statt angenommen).
        const beforeOwner = await loadVisibleScopes([UID]);
        await transaction(async (connection) => {
            // Base object: UIDBelongsTo points at the project itself (object rule).
            // The organization is derived over the memberA link to the owner group.
            await connection.query(
                `INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
                 VALUES (?, 'project', ?, ?, ?, ?, ?, ?, ?)`,
                [UID, UID, title, title, title, 0, JSON.stringify(data), userHex],
            );
            await connection.query(
                `INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
                [UID, groupUID, userHex],
            );
            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],
            );
            if (PROJECT_EVENTS_ENABLED) {
                const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(UID)] });
                await writeEventLog(`/add/project/${HEX2uuid(UID)}`, payload, connection);
                events.push({ key: `/add/project/${HEX2uuid(UID)}`, payload });
            }
        });

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

        // Die Owner-Zeile entsteht **in** der Transaktion; die spaetere
        // `addVisibility` meldet nur ihre eigene Superadmin-Zeile (ihr
        // Vorher-Stand enthaelt die Owner-Zeile schon). Ohne diese Meldung fehlt
        // der Anleger in `project_rights_projection` (siehe 057 §16.3).
        publishVisibilityDiff(beforeOwner, await loadVisibleScopes([UID]), req.session.root);

        // Default visibility rule exactly like list/dlist creation: a
        // `changeable` filter attached to the project group plus super admin
        // rights. `addVisibility` builds the filter from `group.Data.hierarchie`
        // and queues the visibility calculation.
        if (group) {
            addVisibility(req, UID, group);
        }

        const [row] = await readProjectRows(UID);
        if (!row) throw apiError(500, 'INTERNAL_ERROR', 'Project not readable after insert');
        return { success: true, result: toProject(row) };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * List projects the user can see (visible | changeable | admin). Org admins
 * and bots see all projects of the organization.
 *
 * Optional `group_uid` query filter: only projects whose owner group matches
 * (used by the project group tab).
 * @param {ExpressRequestAuthorized} req
 * @returns {Promise<Array>} array of project objects
 */
export const getListing = async (session, queryParams = {}) => {
    try {
        const orgHex = UUID2hex(session.root);
        const userHex = UUID2hex(session.user);
        const admin = await isAdmin(session);
        const groupFilter = typeof queryParams.group_uid === 'string' && queryParams.group_uid
            ? `AND JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.groupUID')) = ?`
            : '';
        // The organization is derived over the owner group (two placeholders).
        const params = admin
            ? Array(OBJECT_IN_ORG_PARAMS).fill(orgHex)
            : [...Array(OBJECT_IN_ORG_PARAMS).fill(orgHex), userHex];
        if (groupFilter) params.push(queryParams.group_uid);
        const rows = await query(
            `${projectSelect}
             INNER JOIN Visible ON (Visible.UID = ObjectBase.UID)
             WHERE ObjectBase.Type = 'project' AND (${objectInOrgSql('ObjectBase')})
             ${admin ? '' : 'AND Visible.UIDUser = ?'}
             ${groupFilter}
             GROUP BY ObjectBase.UID
             ORDER BY ObjectBase.SortName, ObjectBase.ValidFrom DESC`,
            params,
            { cast: ['json'] },
        );
        return rows.map(toProject);
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Get a single project (requires at least visible).
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @returns {Promise<Object>}
 */
export const getProject = async (req, projectUid) => {
    try {
        const uidHex = UUID2hex(projectUid);
        const orgHex = UUID2hex(req.session.root);
        if (!(await projectInOrg(uidHex, orgHex))) {
            throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found in this organization');
        }
        if (!(await isObjectVisible(req, uidHex))) {
            throw apiError(403, 'PROJECT_NOT_ACCESSIBLE', 'Project is not accessible');
        }
        const [row] = await readProjectRows(uidHex);
        if (!row) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found in this organization');
        return { success: true, result: toProject(row) };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Update project metadata (requires admin on the project).
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @returns {Promise<Object>}
 */
export const updateProject = async (req, projectUid) => {
    try {
        const uidHex = UUID2hex(projectUid);
        const orgHex = UUID2hex(req.session.root);
        if (!(await projectInOrg(uidHex, orgHex))) {
            throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found in this organization');
        }
        await requireProjectAdmin(req, uidHex);

        const [existing] = await readProjectRows(uidHex);
        if (!existing) throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found in this organization');

        const body = req.body || {};
        const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : existing.Title;
        const description = typeof body.description === 'string' ? body.description : existing.description;
        const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)
            ? body.metadata
            : metadataFromRow(existing);
        if (typeof metadata.runnerUid !== 'undefined') {
            await assertUsableProjectRunner(metadata.runnerUid, orgHex);
        }
        const prevGroupUID = existing.group_uid ? UUID2hex(existing.group_uid) : orgHex;
        // Group change: resolve the new project group and keep memberA in sync,
        // exactly like the list owner change (insertList).
        let groupUID = body.group_uid ? UUID2hex(body.group_uid) : prevGroupUID;
        const group = await resolveProjectGroup(groupUID, orgHex);
        if (group) {
            groupUID = UUID2hex(group.UID);
        }
        const data = { description, metadata, groupUID: HEX2uuid(groupUID) };

        const events = [];
        await transaction(async (connection) => {
            await connection.query(
                `UPDATE ObjectBase SET Title=?, Display=?, SortName=?, Data=?, UIDuser=?
                 WHERE UID=? AND Type='project'`,
                [title, title, title, JSON.stringify(data), UUID2hex(req.session.user), uidHex],
            );
            if (groupUID !== prevGroupUID) {
                // Nur den Owner-Gruppen-Link verschieben; Share-Links (memberA → share)
                // dürfen nicht getroffen werden.
                const [ownerLinkRows] = await connection.query(
                    `SELECT UID FROM Links WHERE UID=? AND Type='memberA' AND UIDTarget=?`,
                    [uidHex, prevGroupUID],
                );
                if (ownerLinkRows.length) {
                    await connection.query(
                        `UPDATE Links SET UIDTarget=?, UIDuser=? WHERE UID=? AND Type='memberA' AND UIDTarget=?`,
                        [groupUID, UUID2hex(req.session.user), uidHex, prevGroupUID],
                    );
                } else {
                    await connection.query(
                        `INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
                        [uidHex, groupUID, UUID2hex(req.session.user)],
                    );
                }
            }
            if (PROJECT_EVENTS_ENABLED) {
                const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(uidHex)] });
                await writeEventLog(`/change/project/${HEX2uuid(uidHex)}`, payload, connection);
                events.push({ key: `/change/project/${HEX2uuid(uidHex)}`, payload });
            }
        });

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

        // On group change: rebuild the visibility so the new group's job holders
        // get the default project rights and the old group's are recalculated.
        if (groupUID !== prevGroupUID && group) {
            addVisibility(req, uidHex, group);
            // Shares erben die Projekt-Rechte — nach einer Rechte-Aenderung am
            // Projekt muessen ihre Filter und Visible-Zeilen nachziehen.
            await reconcileSharesOfProject(req, uidHex);
        }

        const [row] = await readProjectRows(uidHex);
        return { success: true, result: toProject(row) };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Delete a project (requires admin on the project).
 *
 * **Nur bei leeren Root-Shares.** Haengt an einer Basis dieses Projekts ein
 * Ableger in einem anderen Projekt, bricht der Aufruf mit `409
 * PROJECT_HAS_DEPENDENT_SHARES` ab und nennt die betroffenen Repositories samt
 * ihrer abhaengigen Projekte. Der Aufrufer muss sie vorher aufloesen: die
 * Zuordnung mit `successor` entfernen (der Root wandert dann in eines der
 * abhaengigen Projekte) oder den Ableger loesen. Danach ist der Root leer, und
 * das Projekt kann weg. Die Alternative waere, die Basis still stehen zu lassen
 * — dann haengt sie projektlos im Raum, und niemand sieht, dass sein Repository
 * woanders verwurzelt ist.
 *
 * @param {ExpressRequestAuthorized} req
 * @param {string} projectUid
 * @returns {Promise<Object>}
 */
export const deleteProject = async (req, projectUid) => {
    try {
        const uidHex = UUID2hex(projectUid);
        const orgHex = UUID2hex(req.session.root);
        if (!(await projectInOrg(uidHex, orgHex))) {
            throw apiError(404, 'PROJECT_NOT_FOUND', 'Project not found in this organization');
        }
        await requireProjectAdmin(req, uidHex);

        // Shares are linked, not owned by the project. The target model links
        // Share -> Projekt; legacy rows link Projekt -> Share.
        const shareLinks = await query(
            `SELECT Links.UIDTarget AS share_uid, ObjectBase.Type AS share_type, ObjectBase.Title AS title
             FROM Links
             JOIN ObjectBase ON ObjectBase.UID=Links.UIDTarget
             WHERE Links.UID=? AND Links.Type IN ('memberA','member')
               AND ObjectBase.Type IN (${SHARE_TYPES})
             UNION
             SELECT Links.UID AS share_uid, ObjectBase.Type AS share_type, ObjectBase.Title AS title
             FROM Links
             JOIN ObjectBase ON ObjectBase.UID=Links.UID
             WHERE Links.UIDTarget=? AND Links.Type IN ('memberA','member')
               AND ObjectBase.Type IN (${SHARE_TYPES})`,
            [uidHex, uidHex],
        );

        // Der Waechter steht **vor** dem ersten Schreibzugriff: wer hier
        // abbricht, hat nichts angefasst (die Transaktion wuerde ohnehin
        // zurueckrollen, aber so ist die Absicht eindeutig). „Leer" heisst:
        // an keinem Share dieses Projekts haengt ein anderes Share-Objekt.
        await transaction(async (connection) => {
            const dependents = await dependentsOfRoots(shareLinks.map((l) => l.share_uid), connection);
            if (dependents.length === 0) return;

            const byRoot = new Map();
            for (const d of dependents) {
                const key = HEX2uuid(d.root);
                if (!byRoot.has(key)) byRoot.set(key, []);
                byRoot.get(key).push({
                    share_uid: HEX2uuid(d.share),
                    project_uid: HEX2uuid(d.project),
                    project_title: d.title || d.display || null,
                });
            }
            throw apiError(
                409,
                'PROJECT_HAS_DEPENDENT_SHARES',
                'This project holds base repositories that other projects depend on — resolve them first',
                {
                    project_uid: HEX2uuid(uidHex),
                    shares: [...byRoot.entries()].map(([rootUid, deps]) => ({
                        share_uid: rootUid,
                        title: shareLinks.find((l) => HEX2uuid(l.share_uid) === rootUid)?.title ?? null,
                        dependents: deps,
                    })),
                },
            );
        });

        const events = [];
        await transaction(async (connection) => {
            await connection.query(`DELETE Links FROM Links WHERE UID=? AND Type IN ('memberA','member')`, [uidHex]);
            // New direction: links whose target is this project and whose source is a share.
            await connection.query(
                `DELETE Links FROM Links
                 WHERE UIDTarget=? AND Type IN ('memberA','member')
                   AND UID IN (SELECT UID FROM ObjectBase WHERE Type IN (${SHARE_TYPES}))`,
                [uidHex],
            );
            await connection.query(`DELETE FROM Visible WHERE UID=?`, [uidHex]);
            await connection.query(`DELETE FROM ObjectBase WHERE UID=? AND Type='project'`, [uidHex]);

            if (PROJECT_EVENTS_ENABLED) {
                const base = { organization: req.session.root };
                await writeEventLog(`/remove/project/${HEX2uuid(uidHex)}`, buildEventPayload({ ...base, data: [HEX2uuid(uidHex)] }), connection);
                events.push({ key: `/remove/project/${HEX2uuid(uidHex)}`, payload: buildEventPayload({ ...base, data: [HEX2uuid(uidHex)] }) });
            }

            // Orphaned shares (no project link left) are removed together with the
            // project. The owner link (`member` to a person) does not count.
            // connection.query liefert direkt das Rows-Array (`[{ n }]`).
            for (const link of shareLinks) {
                const [{ n }] = await connection.query(
                    `SELECT COUNT(*) AS n FROM Links
                     WHERE Type IN ('memberA','member')
                       AND ((UIDTarget=? AND UID IN (SELECT UID FROM ObjectBase WHERE Type='project'))
                         OR (UID=? AND UIDTarget IN (SELECT UID FROM ObjectBase WHERE Type='project')))`,
                    [link.share_uid, link.share_uid],
                );
                if (Number(n ?? 0) > 0) continue;
                // Letzte Absicherung, nicht der Waechter: der Waechter oben liest
                // in einer eigenen Transaktion, dazwischen kann ein Link
                // entstehen. Dann bleibt die Basis stehen (sie haengt danach
                // projektlos) — lieber das als ein Ableger ins Leere.
                if (await hasDerivedShares(link.share_uid, connection)) continue;
                await connection.query(
                    `DELETE FROM ObjectBase WHERE UID=? AND Type IN (${SHARE_TYPES})`,
                    [link.share_uid],
                );
                await connection.query(`DELETE FROM Visible WHERE UID=?`, [link.share_uid]);
                if (PROJECT_EVENTS_ENABLED) {
                    const payload = buildEventPayload({ organization: req.session.root, data: [HEX2uuid(link.share_uid)] });
                    await writeEventLog(`/remove/${link.share_type}/${HEX2uuid(link.share_uid)}`, payload, connection);
                    events.push({ key: `/remove/${link.share_type}/${HEX2uuid(link.share_uid)}`, payload });
                }
            }
        });

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

        return { success: true, result: { UID: HEX2uuid(uidHex), removed_shares: shareLinks.length } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
 * Read current project rows.
 * @param {string} uidHex
 */
const readProjectRows = async (uidHex) => {
    return query(`${projectSelect} WHERE ObjectBase.UID=? AND ObjectBase.Type='project'`, [uidHex], { cast: ['json'] });
};