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=organization UID
 * - 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
 *
 * 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 { 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.
 * @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} AND UIDBelongsTo=?`,
        [uidHex, orgHex],
    );
    return rows.length === 1;
};

/**
 * 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 = [];
        await transaction(async (connection) => {
            await connection.query(
                `INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
                 VALUES (?, 'project', ?, ?, ?, ?, ?, ?, ?)`,
                [UID, orgHex, 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);
        }

        // 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')) = ?`
            : '';
        const params = admin ? [orgHex] : [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 ObjectBase.UIDBelongsTo = ?
             ${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);
        }

        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). Removes the project's
 * memberA/member links (owner group, owner user and share links). Shares are
 * org-owned: only links are removed here; a share object is deleted only when
 * it has no further links (mirrors deleteShare).
 * @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 (memberA/member), not owned by the project anymore.
        const shareLinks = await query(
            `SELECT Links.UIDTarget AS share_uid, ObjectBase.Type AS share_type
             FROM Links
             JOIN ObjectBase ON ObjectBase.UID=Links.UIDTarget
             WHERE Links.UID=? AND Links.Type IN ('memberA','member')
               AND ObjectBase.Type IN (${SHARE_TYPES})`,
            [uidHex],
        );

        const events = [];
        await transaction(async (connection) => {
            await connection.query(`DELETE Links FROM Links WHERE UID=? AND Type IN ('memberA','member')`, [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 links left) are removed together with the project.
            // 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 UIDTarget=? AND Type IN ('memberA','member')`,
                    [link.share_uid],
                );
                if (Number(n ?? 0) > 0) continue;
                await connection.query(
                    `DELETE FROM ObjectBase WHERE UID=? AND Type IN (${SHARE_TYPES}) AND UIDBelongsTo=?`,
                    [link.share_uid, orgHex],
                );
                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'] });
};