// @ts-check
/**
* @import {treeAction} from './../../types.js'
*/
import { query, transaction, HEX2uuid } from '@commtool/sql-query'
import { addUpdateList } from '../../server.ws.js'
import { addVisibility } from '../matchObjects/matchObjects.js'
import { matchObjectsLists } from '../matchObjects/matchObjectsLists.js'
import { checkEntries } from '../matchObjects/checkEntries.js'
import { addGroupGuest, addGuests } from '../../Router/guest.js'
import { queueAdd } from './treeQueue.js'
import { publishEvent } from '../../utils/events.js'
import { errorLoggerUpdate } from '../../utils/requestLogger.js'
// ─────────────────────────────────────────────────────────────────────────────
// Pure helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Parses the timestamp from a tree action and produces the SQL
* `FOR SYSTEM_TIME AS OF` clause string.
*
* @param {treeAction} action
* @returns {{ timestamp: number | null, asOf: string }}
*/
export const parseTimestamp = (action) => {
const timestamp = action.timestamp
? (typeof action.timestamp === 'string' ? parseFloat(action.timestamp) : action.timestamp)
: null
const asOf = timestamp ? `FOR SYSTEM_TIME AS OF FROM_UNIXTIME(${timestamp})` : ''
return { timestamp, asOf }
}
/**
* Merges `delta` and `secondLevel` into a deduplicated `deltaVisual` array.
*
* @param {Array} delta
* @param {Array} secondLevel
* @returns {Array}
*/
export const buildDeltaVisual = (delta, secondLevel) => {
if (secondLevel.length === 0) return [...delta]
return [...delta, ...secondLevel].reduce((result, actual) => {
if (!result.find(el => el.UIDTarget.equals(actual.UIDTarget))) {
return [...result, actual]
}
return result
}, [])
}
// ─────────────────────────────────────────────────────────────────────────────
// DB query helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Queries the transitive group memberships (delta) for the given target group.
*
* @param {Buffer} UIDnewTarget
* @returns {Promise<Array<{UIDTarget: Buffer, Type: string, Display: string, LinkType: string}>>}
*/
export const resolveGroupDelta = (UIDnewTarget) =>
query(
`SELECT ObjectBase.UID AS UIDTarget, ObjectBase.Type, Member.Display, Links.Type AS LinkType
FROM Links
INNER JOIN ObjectBase ON (ObjectBase.UID=Links.UIDTarget
AND Links.Type IN ('member','memberA','memberS')
AND ObjectBase.Type IN ('group','ggroup'))
INNER JOIN Member ON (Member.UID=ObjectBase.UIDBelongsTo)
WHERE Links.UID=?
GROUP BY ObjectBase.UID`,
[UIDnewTarget],
{ log: true },
)
/**
* Queries the second-level parent groups needed for visual display updates.
*
* @param {Buffer[]} deltaPlus
* @returns {Promise<Array>}
*/
export const resolveSecondLevelGroups = (deltaPlus) =>
query(
`SELECT ObjectBase.UID AS UIDTarget, ObjectBase.Type, Member.Display, Links.Type AS LinkType
FROM Links
INNER JOIN ObjectBase ON (ObjectBase.UID=Links.UIDTarget
AND Links.Type IN ('member','memberA')
AND ObjectBase.Type IN ('group','ggroup'))
INNER JOIN Member ON (Member.UID=ObjectBase.UID)
WHERE Links.UID IN (?)`,
[deltaPlus],
)
/**
* Filters out delta groups where the person/guest is already a member.
* Used for `guest` and `ggroup` action types.
*
* @param {Array} delta
* @param {Buffer} UIDBelongsTo
* @param {string} asOf
* @returns {Promise<Array>}
*/
export const filterGuestDelta = async (delta, UIDBelongsTo, asOf) => {
const exists = await query(
`SELECT UIDTarget, ObjectBase.Type, ObjectBase.Display
FROM Links ${asOf}
INNER JOIN ObjectBase ON (ObjectBase.UID=Links.UIDTarget)
WHERE Links.Type IN ('member','memberA','memberS')
AND Links.UID=?
AND ObjectBase.Type IN ('guest','person')`,
[UIDBelongsTo],
)
return delta.filter(el => !exists.find(exist => exist.UIDTarget.equals(el.UIDTarget)))
}
// ─────────────────────────────────────────────────────────────────────────────
// Type-specific handlers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Handles the `group` type cascade: queues member actions for all current members
* of the added group, and pre-inserts delta member Links for persons/externs.
*
* @param {treeAction} action
* @param {Array} delta
* @param {number | null} timestamp
* @returns {Promise<void>}
*/
export const handleGroupType = async (action, delta, timestamp) => {
const members = await query(
`SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo
FROM Links
INNER JOIN ObjectBase ON (Links.UID=ObjectBase.UID AND Links.Type IN ('member','memberA'))
WHERE ObjectBase.Type IN ('job','guest','extern','person')
AND Links.UIDTarget=?`,
[action.UIDObjectID],
)
if (members.length > 0) {
const persons = members.filter(m => m.Type === 'extern' || m.Type === 'person')
if (persons.length > 0 && delta.length > 0) {
const ADD = persons.flatMap(p => delta.map(gr => [p.UID, gr.UIDTarget]))
await query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget) VALUES (?, 'member', ?)`,
ADD,
{ backDate: timestamp, batch: true },
)
}
await query(
`INSERT INTO TreeQueue
(UIDRoot, UIDuser, Type, UIDObjectID, UIDBelongsTo, UIDoldTarget, UIDnewTarget, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
members.map(m => [
action.UIDroot, action.UIDuser,
m.Type, m.UID, m.UIDBelongsTo,
null, action.UIDnewTarget, timestamp,
]),
{ batch: true },
)
}
addUpdateList(action.UIDObjectID)
}
/**
* Handles the `person` type: removes duplicate guest memberships and creates
* guests for `memberGA` groups within a transaction.
*
* @param {treeAction} action
* @param {Array} delta
* @param {Array} deltaVisual
* @param {number | null} timestamp
* @returns {Promise<void>}
*/
export const handlePersonType = async (action, delta, deltaVisual, timestamp) => {
await transaction(
async (connection) => {
if (delta.length) {
connection.query(
`DELETE Links FROM Links
INNER JOIN ObjectBase ON (ObjectBase.UIDBelongsTo=? AND ObjectBase.Type='guest' AND ObjectBase.UID=Links.UID)
WHERE Links.UIDTarget IN (?)`,
[action.UIDObjectID, delta.map(el => el.UIDTarget)],
)
}
const groupAdmin = delta.find(d => d.LinkType === 'memberGA')
if (groupAdmin) {
const [guest] = await addGuests(
[action.UIDBelongsTo],
groupAdmin.UIDTarget,
'guest',
HEX2uuid(action.UIDroot),
)
await connection.query(
`INSERT INTO Links (UID, Type, UIDTarget) VALUES (?, 'member', ?)`,
[deltaVisual.map(UID => [guest.UID, UID])],
{ batch: true },
)
}
},
{ backDate: timestamp },
)
}
/**
* Handles the `job` type: creates `visible` and `changeable` filter objects
* based on the function template attached to the job.
*
* @param {treeAction} action
* @returns {Promise<void>}
*/
export const handleJobType = async (action) => {
const functionTemplate = await query(
`SELECT ObjectBase.Data FROM ObjectBase
INNER JOIN Links ON (Links.UID=ObjectBase.UID AND Links.Type='function')
WHERE Links.UIDTarget=?`,
[action.UIDObjectID],
{ cast: ['json'] },
)
if (!functionTemplate.length) return
const functionData = functionTemplate[0].Data
if (functionData.access && Object.keys(functionData.access).length > 0) {
const hGroups = await query(
`SELECT ObjectBase.UID,
JSON_VALUE(Member.Data,'$.hierarchie') AS Hierarchie,
JSON_VALUE(Member.Data,'$.gender') AS Gender,
ObjectBase.Title
FROM ObjectBase
INNER JOIN Member ON (Member.UID=ObjectBase.UID)
INNER JOIN Links ON (Links.UIDTarget=ObjectBase.UID)
WHERE (Links.UID=? OR ObjectBase.UID=?)
AND ObjectBase.Type='group'
AND JSON_VALUE(Member.Data,'$.hierarchie') IN (?)
GROUP BY ObjectBase.UID`,
[action.UIDnewTarget, action.UIDnewTarget, Object.keys(functionData.access)],
)
if (hGroups.length > 0) {
for (const hGroup of hGroups) {
if (
hGroup.Gender === 'C' ||
!hGroups.find(hg => hg.Hierarchie === hGroup.Hierarchie && hg.Gender === 'C')
) {
const [{ UID: filterUID }] = await query(`SELECT UIDV1() AS UID`, [])
await query(
`INSERT INTO ObjectBase
(UID, Type, UIDBelongsTo, Title, Display, SortName, FullTextIndex, dindex, Data)
VALUES (?, 'visible', ?, ?, ?, 'visible', '', 0, ?)`,
[
filterUID,
hGroup.UID,
functionData.name,
hGroup.Title,
JSON.stringify(functionData.access[hGroup.Hierarchie]),
],
)
queueAdd(
action.UIDroot, action.UIDuser,
'visible', filterUID, hGroup.UID,
null, action.UIDObjectID,
)
}
}
} else {
console.error('invalid hgroups in treeAdd.memberAction.js')
}
}
if (functionData.writeAccess) {
const [{ UID: filterUID }] = await query(`SELECT UIDV1() AS UID`, [])
await query(
`INSERT INTO ObjectBase
(UID, Type, UIDBelongsTo, Title, Display, SortName, FullTextIndex, dindex, Data)
VALUES (?, 'changeable', ?, ?, ?, 'changeable', '', 0, ?)`,
[
filterUID,
action.UIDnewTarget,
functionData.name,
'',
JSON.stringify(functionData.writeAccess),
],
)
queueAdd(
action.UIDroot, action.UIDuser,
'changeable', filterUID, action.UIDnewTarget,
null, action.UIDObjectID,
)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Cross-cutting side-effect helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Applies visibility records and object-list matching for supported types.
*
* @param {treeAction} action
* @param {Array} deltaVisual
* @returns {Promise<void>}
*/
export const applyVisibilityAndMatching = async (action, deltaVisual) => {
const sourceDelta = [action.UIDnewTarget, ...deltaVisual.map(el => el.UIDTarget)]
await addVisibility(action.UIDObjectID, sourceDelta)
if (!['group', 'event', 'eventT', 'ggroup'].includes(action.Type)) {
matchObjectsLists(action.UIDObjectID, sourceDelta, HEX2uuid(action.UIDroot))
}
}
/**
* Inserts `member` Links for delta groups, skipping person/extern/guest types
* (which handle their own link creation).
*
* @param {treeAction} action
* @param {Array} delta
* @param {number | null} timestamp
* @returns {Promise<void>}
*/
export const insertMemberLinks = async (action, delta, timestamp) => {
if (!delta.length) return
const ADD = delta.map(gr => [action.UIDObjectID, gr.UIDTarget])
await query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget) VALUES (?, 'member', ?)`,
ADD,
{ backDate: timestamp, batch: true },
)
}
/**
* Handles guest-group (ggroup) membership for the object being added.
* Creates guest records or cascades group guests as appropriate.
*
* @param {treeAction} action
* @param {string} asOf
* @returns {Promise<void>}
*/
export const processGgroupMemberships = async (action, asOf) => {
const ggroups = await query(
`SELECT ObjectBase.UID, UNIX_TIMESTAMP(ObjectBase.validFrom) AS validFrom
FROM ObjectBase ${asOf}
WHERE ObjectBase.Type='ggroup' AND ObjectBase.UIDBelongsTo=?`,
[action.UIDnewTarget],
)
if (!ggroups.length) return
if (action.Type === 'person' || action.Type === 'guest') {
for (const group of ggroups) {
const guest = await addGuests(
[action.UIDBelongsTo],
group.UID,
'guest',
HEX2uuid(action.UIDroot),
Math.max(action.timestamp, group.validFrom),
action.UIDuser,
)
if (guest.length)
queueAdd(
action.UIDroot, action.UIDuser,
'guest', guest[0].UID, guest[0].UIDBelongsTo,
null, group.UID, action.timestamp,
)
}
} else if (action.Type === 'group' || action.Type === 'ggroup') {
for (const group of ggroups)
addGroupGuest(
{ session: { root: action.UIDroot, user: action.UIDuser } },
action.UIDObjectID,
group.UID,
action.timestamp,
)
}
}
/**
* Publishes membership-change events for all groups in `deltaPlus`.
*
* @param {treeAction} action
* @param {Buffer[]} deltaPlus
* @param {number | null} timestamp
* @returns {void}
* @exported-for-testing
*/
export const publishMembershipEvents = (action, deltaPlus, timestamp) => {
for (const group of deltaPlus) {
publishEvent(`/add/group/${action.Type}/${HEX2uuid(group)}`, {
organization: HEX2uuid(action.UIDroot),
data: [HEX2uuid(action.UIDObjectID)],
backDate: timestamp,
})
}
}
/**
* Sends WebSocket update notifications for all affected groups.
* Person and extern updates are skipped (handled by migratePerson storage).
*
* @param {treeAction} action
* @param {Array} deltaVisual
* @returns {void}
*/
export const sendWebSocketUpdates = (action, deltaVisual) => {
if (!['person', 'extern'].includes(action.Type))
addUpdateList(deltaVisual.map(el => el.UIDTarget))
}
/**
* Handles the `personFilter` action type: re-evaluates dynamic list memberships
* and filter entries for a person/extern whose data-relevant fields changed.
*
* Unlike `addMemberAction` for `person`/`extern`, this does NOT insert new
* member Links and does NOT publish `/add/group/…` events — the person is
* already in the tree; only their list/filter placement needs to be refreshed.
*
* @param {treeAction} action
* @returns {Promise<void>}
*/
export const addPersonFilterAction = async (action) => {
try {
const { asOf } = parseTimestamp(action)
const rows = await query(
`SELECT UIDTarget FROM Links ${asOf}
WHERE Type IN ('member','memberA') AND Links.UID=?`,
[action.UIDObjectID],
)
const allGroupUIDs = rows.map(el => el.UIDTarget)
if (allGroupUIDs.length) {
await matchObjectsLists(action.UIDObjectID, allGroupUIDs, HEX2uuid(action.UIDroot))
}
await checkEntries(action.UIDObjectID, HEX2uuid(action.UIDroot))
} catch (e) {
errorLoggerUpdate(e || new Error('treeAdd.memberAction addPersonFilterAction: Unknown error occurred'))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Orchestrator
// ─────────────────────────────────────────────────────────────────────────────
/**
* Handles tree-structural add actions: adding an object to a group hierarchy.
*
* Supported types: `group`, `person`, `extern`, `job`, `guest`, `ggroup`, `eventJob`.
*
* The function:
* 1. Resolves the transitive group memberships of the target group (delta).
* 2. Applies type-specific logic (guest deduplication, group cascade, person guest-group
* handling, job visibility filter creation).
* 3. Inserts missing `member` Links for non-person types.
* 4. Adds the object to any guest groups (ggroups) defined on the target.
* 5. Fires WebSocket and event-bus notifications.
*
* @param {treeAction} action
* @returns {Promise<void>}
*/
export const addMemberAction = async (action) => {
try {
const { timestamp, asOf } = parseTimestamp(action)
// ── 1. Resolve transitive memberships of the target group ────────────
let delta = await resolveGroupDelta(action.UIDnewTarget)
// deltaPlus is computed BEFORE the guest-deduplication filter so the
// events section below gets the full original set.
const deltaPlus = [...delta.map(d => d.UIDTarget), action.UIDnewTarget]
const secondLevel = await resolveSecondLevelGroups(deltaPlus)
let deltaVisual = buildDeltaVisual(delta, secondLevel)
// ── 2. Type-specific logic ───────────────────────────────────────────
if (action.Type === 'guest' || action.Type === 'ggroup') {
delta = await filterGuestDelta(delta, action.UIDBelongsTo, asOf)
}
if (action.Type === 'group') {
await handleGroupType(action, delta, timestamp)
}
if (action.Type === 'person') {
await handlePersonType(action, delta, deltaVisual, timestamp)
}
if (action.Type === 'job') {
await handleJobType(action)
}
// ── 3. Visibility + list matching ────────────────────────────────────
if (['person', 'guest', 'ggroup', 'extern', 'job', 'group'].includes(action.Type)) {
await applyVisibilityAndMatching(action, deltaVisual)
}
// ── 4. Insert delta member links (non-person/extern/guest) ───────────
if (!['person', 'extern', 'guest'].includes(action.Type)) {
await insertMemberLinks(action, delta, timestamp)
}
// ── 5. Guest group (ggroup) membership ───────────────────────────────
if (['person', 'group', 'guest', 'ggroup', 'job'].includes(action.Type)) {
await processGgroupMemberships(action, asOf)
}
// ── 6. Publish membership events ─────────────────────────────────────
if (['person', 'group', 'guest', 'extern', 'job', 'ggroup', 'eventJob'].includes(action.Type)) {
publishMembershipEvents(action, deltaPlus, timestamp)
}
// ── 7. WebSocket updates ──────────────────────────────────────────────
deltaVisual.push({ UIDTarget: action.UIDnewTarget })
// person and extern WebSocket updates are handled by migratePerson storage
sendWebSocketUpdates(action, deltaVisual)
} catch (e) {
errorLoggerUpdate(e || new Error('treeAdd.memberAction: Unknown error occurred'))
}
}