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