Source: Router/maintenance/personConsistency.js

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

/**
 * @import {ExpressRequestAuthorized, ExpressResponse} from './../../types.js'
 */

/**
 * Person consistency maintenance
 *
 * The membership level of a person/extern is stored twice:
 *
 *  - `ObjectBase.stage` / `ObjectBase.hierarchie` — versioned columns, they drive the
 *    rendered `Title` and the group member lists.
 *  - `Member.Data.stage` / `Member.Data.hierarchie` — the (non-versioned) persona
 *    payload the UI reads for the stage colour and that `{"Data.stage": N}` filters,
 *    lists and bots match on.
 *
 * The primary group assignment lives in the `memberA` link (see
 * personHelpers.handleGroupMembershipMigration, which documents the invariant
 * "a person/extern must have at most one memberA link pointing to a group").
 *
 * Transfers that run through `updateGroupMembershipShared` keep all three in sync.
 * Transfers that bypass it — tree queue, list/DList sync, ActionBots, imports —
 * can leave two kinds of drift behind:
 *
 *  1. `Member.Data.stage` keeps the level of the group the person left, so the
 *     persona keeps the old level ("übergetretener Wichtel behält Wichtel").
 *  2. A surplus `memberA` link keeps the person listed in the group they left.
 *
 * This endpoint reports BOTH classes for the current organisation, but repairs only
 * the persona payload: with `?fix=true` it aligns `Member.Data.stage`/`hierarchie`
 * with `ObjectBase` (the authoritative column pair). Surplus `memberA` links are NOT
 * touched here — they are repaired by `memberAConsistency`, which resolves them for
 * every object type with a single rule ("the later link wins"). Two different rules
 * for the same links would contradict each other, so this endpoint only reports them
 * (`multiplePrimaryGroups`) and leaves the repair to its owner.
 */

/** Query parameter values that switch from report-only to repair mode. */
const TRUTHY = new Set(['1', 'true', 'yes', 'on']);

/**
 * @param {unknown} value
 * @returns {boolean}
 */
const isFixRequested = (value) => TRUTHY.has(String(value ?? '').toLowerCase());

/**
 * SQL returns `JSON_VALUE` results as strings; levels are compared numerically.
 * @param {unknown} value
 * @returns {number|null}
 */
const num = (value) => (value === null || value === undefined || value === '' ? null : Number(value));

/**
 * @param {unknown} a
 * @param {unknown} b
 * @returns {boolean}
 */
const same = (a, b) => num(a) === num(b);

/**
 * Every person/extern of the organisation with both level representations and one
 * row per `memberA` group link (LEFT JOIN, so persons without a group link are kept).
 *
 * @param {Buffer} rootHex - binary UID of the organisation root
 * @returns {Promise<any[]>}
 */
const fetchPersonRows = (rootHex) => query(
    `SELECT p.UID                             AS PersonUID,
            p.TUID                            AS PersonTUID,
            p.Title                           AS PersonTitle,
            p.Type                            AS PersonType,
            p.stage                           AS ObStage,
            p.hierarchie                      AS ObHierarchie,
            m.Data                            AS MemberData,
            JSON_VALUE(m.Data,'$.stage')      AS DataStage,
            JSON_VALUE(m.Data,'$.hierarchie') AS DataHierarchie,
            JSON_VALUE(m.Data,'$.firstName')  AS FirstName,
            JSON_VALUE(m.Data,'$.lastName')   AS LastName,
            g.UID                             AS GroupUID,
            g.TUID                            AS GroupTUID,
            g.Title                           AS GroupTitle,
            g.stage                           AS GroupStage,
            g.hierarchie                      AS GroupHierarchie
     FROM ObjectBase p
     INNER JOIN Member m ON (m.UID = p.UIDBelongsTo)
     LEFT JOIN Links gl ON (gl.UID = p.UID AND gl.Type = 'memberA' AND gl.ValidUntil > NOW())
     LEFT JOIN ObjectBase g ON (g.UID = gl.UIDTarget AND g.Type IN ('group','ggroup') AND g.ValidUntil > NOW())
     WHERE p.Type IN ('person','extern') AND p.ValidUntil > NOW()
       AND EXISTS (SELECT 1 FROM Links o
                   WHERE o.UID = p.UID AND o.Type IN ('member','memberA')
                     AND o.UIDTarget = ? AND o.ValidUntil > NOW())`,
    [rootHex],
    { cast: ['json'], log: false }
);

/**
 * @param {any} person
 * @returns {any}
 */
const toReport = (person) => ({
    UID: HEX2uuid(person.UID),
    name: person.name,
    type: person.type,
    title: person.title,
    objectBase: { stage: person.obStage, hierarchie: person.obHierarchie },
    personaData: { stage: person.dataStage, hierarchie: person.dataHierarchie },
    primaryGroups: person.groups.map((/** @type {any} */ g) => ({
        UID: HEX2uuid(g.uid),
        title: g.title,
        stage: g.stage,
        hierarchie: g.hierarchie
    }))
});

/**
 * Report and (optionally) repair person level drift for the current organisation.
 *
 * GET /maintenance/personConsistency
 *   → report only, no writes.
 * GET /maintenance/personConsistency?fix=true
 *   → additionally align `Member.Data.stage`/`hierarchie` with `ObjectBase`.
 *     Surplus `memberA` links are handled by `memberAConsistency`.
 *
 * @param {ExpressRequestAuthorized} req - Express request object
 * @param {ExpressResponse} res - Express response object
 */
export const personConsistency = async (req, res) => {
    try {
        const fix = isFixRequested(req.query.fix);
        const rows = await fetchPersonRows(UUID2hex(req.session.root));

        /** @type {Map<any, any>} */
        const persons = new Map();
        for (const row of rows) {
            // Key by the string form: two Buffers with the same content are DIFFERENT
            // Map keys, so keying by `row.PersonUID` would split one person with several
            // group links into several "persons" with a single link each — and the
            // surplus-link detection would never see more than one group.
            const personKey = HEX2uuid(row.PersonUID);
            let person = persons.get(personKey);
            if (!person) {
                person = {
                    UID: row.PersonUID,
                    TUID: row.PersonTUID,
                    title: row.PersonTitle,
                    type: row.PersonType,
                    name: [row.FirstName, row.LastName].filter(Boolean).join(' ') || row.PersonTitle,
                    obStage: num(row.ObStage),
                    obHierarchie: num(row.ObHierarchie),
                    dataStage: num(row.DataStage),
                    dataHierarchie: num(row.DataHierarchie),
                    memberData: row.MemberData,
                    groups: []
                };
                persons.set(personKey, person);
            }
            if (row.GroupTUID) {
                person.groups.push({
                    uid: row.GroupUID,
                    tuid: row.GroupTUID,
                    title: row.GroupTitle,
                    stage: num(row.GroupStage),
                    hierarchie: num(row.GroupHierarchie)
                });
            }
        }

        const result = {
            checked: persons.size,
            /** `Member.Data` level differs from the versioned `ObjectBase` level. */
            stageMismatch: /** @type {any[]} */ ([]),
            /** More than one `memberA` group link — the person is listed in several groups. */
            multiplePrimaryGroups: /** @type {any[]} */ ([]),
            /** No `memberA` group link at all. */
            missingPrimaryGroup: /** @type {any[]} */ ([]),
            repairedStage: /** @type {any[]} */ ([])
        };

        for (const person of persons.values()) {
            // A group link whose target is not a group object counts as "missing group".
            const hasGroups = person.groups.length > 0;
            const stageDrift = !same(person.dataStage, person.obStage)
                || !same(person.dataHierarchie, person.obHierarchie);

            if (stageDrift) result.stageMismatch.push(toReport(person));
            if (!hasGroups) result.missingPrimaryGroup.push(toReport(person));
            if (person.groups.length > 1) result.multiplePrimaryGroups.push(toReport(person));

            if (!fix) continue;

            // --- persona payload --------------------------------------------
            // `ObjectBase` is the authoritative level (it drives the rendered title),
            // so the persona payload is aligned to it — never the other way round.
            if (stageDrift && person.obStage !== null) {
                const next = { ...person.memberData, stage: person.obStage };
                if (person.obHierarchie !== null) next.hierarchie = person.obHierarchie;

                await query(`UPDATE Member SET Data=? WHERE UID=?`, [JSON.stringify(next), person.UID]);

                result.repairedStage.push({
                    UID: HEX2uuid(person.UID),
                    name: person.name,
                    from: { stage: person.dataStage, hierarchie: person.dataHierarchie },
                    to: { stage: person.obStage, hierarchie: person.obHierarchie }
                });
            }
        }

        res.json({ success: true, fix, result });
    } catch (e) {
        errorLoggerRead(e);
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Internal server error' });
    }
};