Source: tree/treeQueue/treeAdd.memberAction.js

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