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