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