Source: Router/maintenance/rebuildMembershipTree.js

// @ts-check
import { query, UUID2hex, HEX2uuid, transaction } from '@commtool/sql-query';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';

/**
 * Resolves the UIDs to rebuild based on the identifier.
 * If the identifier is a known object type, fetches all UIDs of that type ordered by hierarchy.
 * If it's a UUID, converts it to hex. Returns an empty array if invalid.
 *
 * @param {string} identifyer - Object type ('person','group','extern','event','job') or a UUID string
 * @returns {Promise<{UIDs: Buffer[], isType: boolean}>}
 */
export async function resolveTreeUIDs(identifyer) {
    if (['person', 'group', 'extern', 'event', 'job'].includes(identifyer)) {
        // sorting by hierarchie will assure, that we build the tree from the root to all branches
        const result = await query(`SELECT DISTINCT ObjectBase.UID, ObjectBase.TUID, ObjectBase.Title FROM 
            ObjectBase   WHERE ObjectBase.Type=?  ORDER BY ObjectBase.hierarchie ASC `, [identifyer])
        return { UIDs: result.map((r) => (r.UID)), isType: true }
    }
    else {
        const hex = UUID2hex(identifyer)
        return { UIDs: hex ? [hex] : [], isType: false }
    }
}

/**
 * Rebuilds the membership tree for a set of UIDs.
 *
 * Uses a virtual transaction to compute the desired link state without committing,
 * then diffs against the current state and applies only the necessary inserts/deletes.
 * This avoids touching existing rows in the system-versioned Links table (updates would
 * create unnecessary history entries).
 *
 * @param {Buffer[]} UIDs - hex Buffer UIDs of the objects to rebuild
 * @param {{onProgress?: (status: object) => void}} [options]
 * @returns {Promise<{deleted: Array<{UID: Buffer, UIDTarget: Buffer, DisplayMain: string, Display: string}>, added: Array<{UID: Buffer, UIDTarget: Buffer, DisplayMain: string, Display: string, success: boolean}>}>}
 */
export async function rebuildMembershipTree(UIDs, options = {}) {
    const onProgress = options.onProgress || (() => { })

    // Snapshot the current member links before any changes
    const oldSet = await query(`SELECT CONCAT (Main.Title,' ',MainMember.Display) AS DisplayMain, Links.UID,Links.UIDTarget, 
                CONCAT(ObjectBase.Title,' ',TargetMember.Display) AS Display,
                UNIX_TIMESTAMP(Links.validFrom) AS validFrom 
        FROM Links
        INNER JOIN ObjectBase AS Main ON (Main.UID=Links.UID)
        INNER JOIN Member AS MainMember ON (MainMember.UID=Main.UIDBelongsTo)
        INNER JOIN ObjectBase ON (Links.UIDTarget=ObjectBase.UID AND ObjectBase.Type='group')
        INNER JOIN Member AS TargetMember ON (TargetMember.UID=ObjectBase.UIDBelongsTo)
        WHERE Links.UID IN (?) AND Links.Type='member'`,
        [UIDs]
    )

    // Compute the desired state via a virtual transaction (rolled back, not committed)
    let newSet = []
    await transaction(async (connection) => {
        let i = 0
        const total = UIDs.length
        for (const UID of UIDs) {
            ++i
            // Delete existing member links for this UID (only in virtual transaction)
            await connection.query(`DELETE Links FROM Links
                WHERE Links.Type='member'AND Links.UID IN(?) `, [UID])

            onProgress({
                action: 'progress',
                text: `virtually add actual links for ${HEX2uuid(UID)}`,
                current: i,
                total,
                progress: Math.round((i / total) * 100)
            })

            // Rebuild member links from memberA -> member/memberA/memberS chain
            await connection.query(`INSERT INTO Links (UID,Type,UIDTarget) 
                (SELECT ObjectBase.UID,'member',gLink.UIDTarget
                    FROM ObjectBase
                    INNER JOIN Links AS aLink ON (aLink.UID=ObjectBase.UID AND aLink.Type='memberA')
                    INNER JOIN Links AS gLink ON (gLink.UID=aLink.UIDTarget AND gLink.Type IN ('member','memberA','memberS'))
                    LEFT JOIN Links AS existLink ON (existLink.UID=ObjectBase.UID AND existLink.UIDTarget=gLink.UIDTarget 
                        AND  existLink.Type ='member')
                    WHERE ObjectBase.UID IN (?))
                    ON DUPLICATE KEY UPDATE Type='member'
                `, [UID])
        }

        onProgress({
            action: 'progress',
            text: `storing the virtually created new links`,
            current: total,
            total,
            progress: 100
        })

        // Read the computed new state from the virtual transaction
        newSet = await connection.query(`
                SELECT DISTINCT CONCAT (Main.Title,' ',MainMember.Display) AS DisplayMain, Links.UID,Links.UIDTarget, 
                CONCAT(ObjectBase.Title,' ',TargetMember.Display) AS Display,
                UNIX_TIMESTAMP(aLink.validFrom) AS validFromALink
                    FROM Links
                    INNER JOIN ObjectBase AS Main ON (Main.UID=Links.UID)
                    INNER JOIN Member AS MainMember ON (MainMember.UID=Main.UIDBelongsTo)
                    INNER JOIN ObjectBase ON (Links.UIDTarget=ObjectBase.UID AND ObjectBase.Type='group')
                    INNER JOIN Member AS TargetMember ON (TargetMember.UID=ObjectBase.UIDBelongsTo)
                    INNER JOIN Links AS aLink ON (aLink.UID=Main.UID AND aLink.Type='memberA')
                    WHERE Links.UID IN (?) AND Links.Type='member'`,
            [UIDs]
        )
    },
        {
            virtual: true,
            beforeTransaction: async (connection) => await connection.query(`SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;`)
        })

    // Diff: links that exist in oldSet but not in newSet → need to be deleted
    const diffDelete = oldSet.filter(old => !newSet.some(newS => (newS.UID.equals(old.UID) && newS.UIDTarget.equals(old.UIDTarget))))
    const deleted = []
    const deleteTotal = diffDelete.length
    for (let i = 0; i < diffDelete.length; i++) {
        const old = diffDelete[i]
        onProgress({
            action: 'progress',
            text: `removing invalid link from ${old.DisplayMain} to ${old.Display}`,
            current: i + 1,
            total: deleteTotal,
            progress: Math.round(((i + 1) / Math.max(deleteTotal, 1)) * 100)
        })
        // backdate the deletion so that the history is fixed as well
        let result = await query(`DELETE FROM Links WHERE Links.UID=? And Links.UIDTarget=? AND Links.Type='member'`,
            [old.UID, old.UIDTarget]
            , { backDate: old.validFrom + 10 })
        if (!result) {
            // retry without backdating
            result = await query(`DELETE FROM Links WHERE Links.UID=? And Links.UIDTarget=? AND Links.Type='member'`,
                [old.UID, old.UIDTarget]
            )
        }
        deleted.push({ UID: old.UID, UIDTarget: old.UIDTarget, DisplayMain: old.DisplayMain, Display: old.Display })
    }

    // Deduplicate newSet – the virtual-txn query can return duplicates when an
    // object has multiple memberA links (the JOIN on aLink multiplies rows)
    const seen = new Set()
    const uniqueNewSet = newSet.filter(row => {
        const key = row.UID.toString('hex') + ':' + row.UIDTarget.toString('hex')
        if (seen.has(key)) return false
        seen.add(key)
        return true
    })

    // Diff: links that exist in newSet but not in oldSet → need to be inserted
    const diffAdd = uniqueNewSet.filter(newS => !oldSet.some(old => (old.UID.equals(newS.UID) && old.UIDTarget.equals(newS.UIDTarget))))
    const added = []
    const addTotal = diffAdd.length
    for (let i = 0; i < diffAdd.length; i++) {
        const newS = diffAdd[i]
        onProgress({
            action: 'progress',
            text: `adding missing link from ${newS.DisplayMain} to ${newS.Display}`,
            current: i + 1,
            total: addTotal,
            progress: Math.round(((i + 1) / Math.max(addTotal, 1)) * 100)
        })
        // backdate the insertion so that the history is consistent
        // INSERT IGNORE to guard against race conditions (link created between snapshot and apply)
        const result = await query(`INSERT IGNORE INTO Links (UID,Type,UIDTarget) VALUES(?,'member',?)`,
            [newS.UID, newS.UIDTarget]
            , { backDate: newS.validFromALink })
        added.push({
            UID: newS.UID,
            UIDTarget: newS.UIDTarget,
            DisplayMain: newS.DisplayMain,
            Display: newS.Display,
            success: !!result
        })
    }

    return { deleted, added }
}

/**
 * High-level rebuild that ensures groups are rebuilt first (sorted by hierarchie ASC),
 * so that group-to-group member links are correct before rebuilding persons/jobs/externs
 * whose member links depend on the group tree.
 *
 * @param {string} identifyer - Object type ('person','group','extern','event','job') or a UUID string
 * @param {{onProgress?: (status: object) => void}} [options]
 * @returns {Promise<{deleted: Array, added: Array}>}
 */
export async function rebuildFullTree(identifyer, options = {}) {
    const onProgress = options.onProgress || (() => { })

    let allDeleted = []
    let allAdded = []

    const { UIDs, isType } = await resolveTreeUIDs(identifyer)
    if (!UIDs.length) return { deleted: [], added: [] }

    // For non-group types, rebuild the group tree first so that
    // group→group member links are in place before computing person/job/extern member links
    if (isType && identifyer !== 'group') {
        const { UIDs: groupUIDs } = await resolveTreeUIDs('group')
        if (groupUIDs.length) {
            onProgress({ action: 'progress', text: 'rebuilding group tree first…', current: 0, total: groupUIDs.length, progress: 0 })
            const groupResult = await rebuildMembershipTree(groupUIDs, { onProgress })
            allDeleted.push(...groupResult.deleted)
            allAdded.push(...groupResult.added)
        }
    }

    const result = await rebuildMembershipTree(UIDs, { onProgress })
    allDeleted.push(...result.deleted)
    allAdded.push(...result.added)

    return { deleted: allDeleted, added: allAdded }
}