Source: Router/person/personHelpers.js

/**
 * Person Helper Functions
 * 
 * Shared helper functions used by both person and extern controllers:
 * - Person data updates
 * - Group membership migration
 * - Event publishing (change, join, leave)
 */

// @ts-check

import { query, UUID2hex, HEX2uuid, transaction } from '@commtool/sql-query';
import { parseTimestampToSeconds } from '../../utils/parseTimestamp.js'
import { Templates, phonetikArray, getConfig } from '../../utils/compileTemplates.js';
import { upsertSearchIndex, emailValues } from '../../utils/searchIndex.js';
import { renderObject } from '../../utils/renderTemplates.js';
import { queueAdd } from '../../tree/treeQueue/treeQueue.js';
import { familyAddress } from '../../tree/familyAddress.js';
import { publishEvent } from '../../utils/events.js';
import { addToTree, migratePerson } from './migratePerson.js';
import { keysEqual } from '../../utils/keyCompare.js';
import { filterKeys } from '@commtool/object-filter';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';
import { addUpdateList, addUpdateEntry } from '../../server.ws.js';
import { updateUserSettings } from '../../server.ws.js';
import { encryptIbans } from '../../utils/crypto.js';
import { invalidateUserCache } from '../../utils/userUtils.js';
import { personRebuildAccess, personListRebuildAccess } from '../../tree/rebuildList.js';
import _ from 'lodash';

/**
 * Application error with an HTTP status code.
 * Used to distinguish validation/client errors (400) from server errors (500).
 */
export class AppError extends Error {
    /**
     * @param {string} message - Human-readable error description
     * @param {number} [statusCode=400] - HTTP status code to return
     */
    constructor(message, statusCode = 400) {
        super(message);
        this.name = 'AppError';
        this.statusCode = statusCode;
    }
}

/**
 * Validate an array of email entries, rejecting any that contain
 * display-name formatting like "Name <email@domain.com>".
 * Only bare email addresses are accepted.
 * @param {Array<{email?: string}>} [emailArray]
 * @returns {{ valid: boolean, errors: string[] }}
 */
const validateEmailArray = (emailArray) => {
    if (!Array.isArray(emailArray)) return { valid: true, errors: [] };
    const errors = [];
    for (let i = 0; i < emailArray.length; i++) {
        const raw = emailArray[i]?.email;
        if (!raw) continue;
        if (raw.includes('<') || raw.includes('>')) {
            errors.push(`email[${i}]: "${raw}" enthält einen Anzeigenamen; bitte nur die reine E-Mail-Adresse eingeben`);
            continue;
        }
        if (!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/.test(raw)) {
            errors.push(`email[${i}]: "${raw}" ist keine gültige E-Mail-Adresse`);
        }
    }
    return { valid: errors.length === 0, errors };
};

/**
 * Lodash merge customizer - do not merge arrays
 */
function mergeCustomizer(objValue, srcValue) {
    if (_.isArray(objValue)) {
        return srcValue;
    }
}




/**
 * Updates person/extern data with partial merge (used by POST /:UID endpoint)
 * 
 * This function performs partial data updates by merging the provided data with existing
 * Member data. It only updates the Member table, not ObjectBase fields. Used by both
 * person and extern POST endpoints for partial updates without changing group membership.
 * 
 * Difference from updatePersonData:
 * - Updates ONLY Member table (vs. both ObjectBase and Member)
 * - Performs partial merge with existing data (vs. full object replacement)
 * - Uses direct publishEvent (vs. publishChangeEvent through group hierarchy)
 * - No group membership handling (vs. full create/update flow)
 * 
 * @param {Buffer} UID - Person/Extern UID as buffer
 * @param {Object} partialData - Partial data to merge with existing data
 * @param {Object} req - Express request object
 * @returns {Promise<Object>} Result with updated data
 */
export const updatePersonPartial = async (UID, partialData, req) => {
    // Validate required session parameters
    if (!req || !req.session || !req.session.root || !req.session.user) {
        throw new Error('Invalid request session - missing root or user');
    }
    
    // Get original data
    const origPerson = await query(`SELECT Member.Data, ObjectBase.Data AS BaseData, ObjectBase.Type FROM ObjectBase
        INNER JOIN Member ON (Member.UID=ObjectBase.UIDBelongsTo)
        WHERE ObjectBase.UID=?`, [UID], { cast: ['json'] });

    if (!origPerson.length) {
        throw new AppError('Person not found', 404);
    }

    const objectType = origPerson[0].Type || 'person';
    if (objectType !== 'person' && objectType !== 'extern') {
        throw new AppError(`Unsupported object type: ${objectType}`, 400);
    }
    const origData = _.cloneDeep(origPerson[0].Data);
    const newData = _.mergeWith(_.cloneDeep(origData), partialData, mergeCustomizer);

    // Validate email entries before saving
    if (partialData.email) {
        const emailValidation = validateEmailArray(partialData.email);
        if (!emailValidation.valid) {
            throw new AppError(`Email validation failed: ${emailValidation.errors.join('; ')}`, 400);
        }
        // Strip display-name formatting from email values as a safety net
        newData.email = partialData.email.map(e => ({
            ...e,
            email: e.email ? e.email.replace(/^.*<([^>]+)>.*$/, '$1').trim() : e.email
        }));
    }

    // Encrypt IBANs
    const unencryptedData = _.cloneDeep(newData); // store unencrypted data for websocket
    encryptIbans(await getConfig('db', req), newData);

    // Keep denormalized email lookup in sync for partial updates as well.
    await query(`UPDATE Member SET Data=? WHERE UID=?`, [JSON.stringify(newData), UID]);
    await upsertSearchIndex(UID, objectType, 'email', emailValues(newData.email));

    // Invalidate Redis user cache so the next config load gets fresh data (e.g. after settings change).
    // The cache key is built from the Keycloak UID, not the Member UID.
    // If the updated person is the currently logged-in user, we can invalidate directly from the session.
    // Otherwise, find the linked Keycloak identity to invalidate the right cache key.
    try {
        const sessionUserUID = req.session?.user; // UUID of the Member
        if (sessionUserUID && UUID2hex(sessionUserUID).equals(UID)) {
            // Updating own data — Keycloak UID is available in session
            await invalidateUserCache(req.session.authUser?.userUID);
        } else {
            // Updating another person — look up their Keycloak identity link
            const identLinks = await query(
                `SELECT Links.UID FROM Links WHERE Links.UIDTarget=? AND Links.Type='identifyer'`,
                [UID], { cast: ['UUID'] }
            );
            for (const link of identLinks) {
                await invalidateUserCache(link.UID);
            }
        }
    } catch (cacheErr) {
        console.error('[updatePersonPartial] Failed to invalidate user cache:', cacheErr.message);
    }

    // Check for changes and publish events
    const [equal, myDiff, filteredDiff] = keysEqual(origData, newData);
    if (!equal) {
        // queue should only be invoked if there is a relevant key for filters been updated
        if (filteredDiff)
            queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'personFilter', UID, UID, null, null);
        // Publish change events to all parent groups
        await publishChangeEvent({ Type: objectType, UID: UID }, myDiff, null, req.session.root, !!req.query.noUpdate);

        // Handle family address updates
        if ((Array.isArray(newData.address) && newData.address.find((a /** @type {any} */) => (a.type === 'family'))) ||
            (Array.isArray(newData.email) && newData.email.find((a /** @type {any} */) => (a.type === 'family'))) ||
            (Array.isArray(newData.phone) && newData.phone.find((a /** @type {any} */) => (a.type === 'family'))) ||
            (Array.isArray(newData.accounts) && newData.accounts.find((a /** @type {any} */) => (['family', 'familyFees'].includes(a.type))))) {
            familyAddress(/** @type {import('../../tree/familyAddress.js').FamilyMemberObject} */ ({ UID, Type: objectType, Data: newData }), req.session.root);
        }

        // WebSocket updates
        addUpdateEntry(UID, { person: { Type: objectType, Data: unencryptedData, UID: HEX2uuid(UID) } });
        if (!req.query.noUpdate) addUpdateList([UID]);

        // If settings changed, push the new settings directly to the affected user's WS connection
        // so the frontend can update the config context without a full page reload
        if (unencryptedData.settings && myDiff && Object.prototype.hasOwnProperty.call(myDiff, 'settings')) {
            const affectedKeycloakUID = req.session?.authUser?.userUID;
            if (affectedKeycloakUID) {
                updateUserSettings(affectedKeycloakUID, unencryptedData.settings);
            }
        }
    }
    const  result  /** @type {{Data: any, UID: string | undefined, BaseData?: any}}  */ = {Data: unencryptedData, UID: HEX2uuid(UID)};
    if(origPerson[0].BaseData!=='null' )
        result.BaseData=origPerson[0].BaseData;
    return result ;
};

/**
 * Updates person data in both ObjectBase and Member tables (full object update)
 * 
 * This function is used during create/update operations (PUT /:group) where a complete
 * object with all rendered fields is provided. It updates both database tables, handles
 * event publishing, family address updates, and WebSocket notifications.
 * 
 * Difference from updatePersonPartial:
 * - Updates BOTH ObjectBase and Member tables (vs. Member only)
 * - Expects a fully rendered object with Title, Display, SortBase, etc. (vs. partial data)
 * - Used for PUT operations during create/update flows (vs. POST for partial updates)
 * - Uses publishChangeEvent for group hierarchy (vs. direct publishEvent)
 * 
 * @param {Buffer} UIDperson - Person UID as buffer
 * @param {Object} newData - New person data
 * @param {Object} object - Fully rendered object with all fields (Title, Display, SortBase, etc.)
 * @param {Object} req - Express request object
 * @param {number} timestamp - Optional timestamp for transaction
 * @returns {Promise<Object>} Update result with diff information
 */
export const updatePersonData = async (UIDperson, newData, object, req, timestamp = null) => {
    // Validate required session parameters
    if (!req || !req.session || !req.session.root || !req.session.user) {
        throw new Error('Invalid request session - missing root or user');
    }
    
    // Get original data
    const persons = await query(`SELECT Member.Data, ObjectBase.dindex FROM ObjectBase
        INNER JOIN Member ON (Member.UID=ObjectBase.UIDBelongsTo)
        WHERE ObjectBase.UID=?`, [UIDperson], { cast: ['json'] });

    if (!persons.length) {
        throw new AppError('Person not found', 404);
    }

    const origData = _.cloneDeep(persons[0].Data);
    const origDindex = persons[0].dindex;

    // Validate email entries before saving
    if (object.Data?.email) {
        const emailValidation = validateEmailArray(object.Data.email);
        if (!emailValidation.valid) {
            throw new AppError(`Email validation failed: ${emailValidation.errors.join('; ')}`, 400);
        }
        // Normalize: strip display-name formatting as a safety net
        object.Data.email = object.Data.email.map(e => ({
            ...e,
            email: e.email ? e.email.replace(/^.*<([^>]+)>.*$/, '$1').trim() : e.email
        }));
    }

    // Check for changes first
    const [equal, myDiff, filteredDiff] = keysEqual(origData, object.Data);

    // Only update ObjectBase if data actually changed (prevents unnecessary history entries in system-versioned tables)
    if (!equal) {
        await transaction(async (connection) => {
            await connection.query(`UPDATE ObjectBase SET 
                Title=?,dindex=?,hierarchie=?,stage=?,gender=?
                WHERE UID=?`,
                [object.Title, object.dindex,
                    object.hierarchie, object.stage, object.gender,  UIDperson]);
        }, { backDate: timestamp });

        // Update Member table - Display, FullTextIndex, Data should only be updated here for persons
        await query(`UPDATE Member SET Display=?,SortName=?,FullTextIndex=?,PhonetikIndex=?,Data=? WHERE UID=?`,
            [object.Display, object.SortIndex, object.FullTextIndex, phonetikArray([object.Data.firstName, object.Data.lastName]), JSON.stringify(object.Data), UIDperson]);
        await upsertSearchIndex(UIDperson, object.Type || 'person', 'email', emailValues(object.Data.email));

        // queue is only run for keys, which are relevant for filters
        if (filteredDiff)
            queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'personFilter', UIDperson, UIDperson, null, null);
        // event is published for all changes
        await publishChangeEvent({ Type: object.Type || 'person', UID: UIDperson }, myDiff, timestamp, req.session.root, !!req.query.noUpdate);
        // Handle family address
        if ((object.Data.address && object.Data.address.find(a => (a.type === 'family'))) ||
            (object.Data.email && object.Data.email.find(a => (a.type === 'family'))) ||
            (object.Data.phone && object.Data.phone.find(a => (a.type === 'family'))) ||
            (object.Data.accounts && object.Data.accounts.find(a => (['family', 'familyFees'].includes(a.type))))) {
            familyAddress(/** @type {import('../../tree/familyAddress.js').FamilyMemberObject} */ ({ ...object, Type: 'person' }), req.session.root);
        }
        addUpdateList([UIDperson]);
    }



    // Handle dindex changes
    if (origDindex !== object.dindex) {
        queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'listMember', UIDperson, UIDperson, null, UIDperson);
    }


    
    // WebSocket updates
    addUpdateEntry(UIDperson, { person: { Type: object.Type || 'person', Data: object.Data, UID: HEX2uuid(UIDperson) } });


    return { equal, myDiff, origData };
};

/**
 * Handles group membership migration for both person and extern types
 * @param {Object} existingPerson - Current person/extern data with MemberA field
 * @param {Buffer} UIDperson - Person UID as buffer
 * @param {Buffer} UIDgroup - Target group UID as buffer
 * @param {Object} req - Express request object
 * @param {number} timestamp - Optional timestamp for migration
 * @param {string} type - 'person' or 'extern' type for specific handling
 * @returns {Promise<void>}
 */
export const handleGroupMembershipMigration = async (existingPerson, UIDperson, UIDgroup, req, timestamp = null, type = 'person') => {
    // Validate required session parameters
    if (!req || !req.session || !req.session.root || !req.session.user) {
        throw new Error('Invalid request session - missing root or user');
    }

    // Guard: a person/extern must have at most one memberA link pointing to a group.
    // Delete any surplus memberA-to-group links that differ from the known current group.
    if (existingPerson.MemberA) {
        await query(
            `DELETE Links FROM Links
             INNER JOIN ObjectBase ON (ObjectBase.UID = Links.UIDTarget AND ObjectBase.Type IN ('group','ggroup'))
             WHERE Links.UID = ? AND Links.Type = 'memberA' AND Links.UIDTarget != ?`,
            [UIDperson, existingPerson.MemberA]
        )
    }
    

    const hasCurrentGroup = existingPerson.MemberA && existingPerson.MemberA.equals(UIDgroup);
    const isTypeConversion = existingPerson.Type !== type;

    // Check if migration is needed
    if (!hasCurrentGroup || isTypeConversion) {
        if (existingPerson.MemberA) {
            // Migrate from existing group to new group
            // add/remove events will be sent from migratePerson
            await migratePerson(UIDperson, existingPerson.MemberA, UIDgroup, timestamp);
            
            // Handle type-specific queue updates
            if (type === 'extern' && existingPerson.Type === 'person') {
                // Person -> Extern conversion: handle as person exit
                queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'person', UIDperson, UIDperson, existingPerson.MemberA, null, timestamp);
                queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'extern', UIDperson, UIDperson, null, UIDgroup, timestamp);
                
                // Publish exit event for person leaving organization
                
                await publishGroupEvent('exit', UIDperson,  'person', existingPerson.MemberA, req.session.root, timestamp);
                
                // Handle family fees change for person -> extern
                const family = await query(`SELECT Links.UIDTarget FROM Links 
                    INNER Join Member ON (Links.UIDTarget=Member.UID AND Links.Type IN ('family','familyFees')) 
                    WHERE Links.UID=? `, [UIDperson]);

                if (family.length > 0) {
                    const UIDfamily = family[0].UIDTarget;
                    await query(`DELETE FROM Links WHERE UID=? AND UIDTarget=? AND Type='familyFees'`, [UIDperson, UIDfamily]);
                    await query(`INSERT IGNORE INTO Links (UID, Type, UIDTarget) VALUES (?, 'family', ?)`, [UIDperson, UIDfamily]);
                    queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'family', UIDperson, UIDperson, null, UIDfamily);
                }
            } else if (type === 'person' && existingPerson.Type === 'extern') {
                // Extern -> Person conversion: person becomes member
                queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'extern', UIDperson, UIDperson, existingPerson.MemberA, null, timestamp);
                queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'person', UIDperson, UIDperson, null, UIDgroup, timestamp);
                
                // Handle family fees change for extern -> person (upgrade to familyFees)
                const family = await query(`SELECT Links.UIDTarget FROM Links 
                    INNER Join Member ON (Links.UIDTarget=Member.UID AND Links.Type IN ('family','familyFees')) 
                    WHERE Links.UID=? `, [UIDperson]);

                if (family.length > 0) {
                    const UIDfamily = family[0].UIDTarget;
                    await query(`DELETE FROM Links WHERE UID=? AND UIDTarget=? AND Type='family'`, [UIDperson, UIDfamily]);
                    await query(`INSERT IGNORE INTO Links (UID, Type, UIDTarget) VALUES (?, 'familyFees', ?)`, [UIDperson, UIDfamily]);
                    queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'family', UIDperson, UIDperson, null, UIDfamily);
                }
            } else if (type === 'person') {
                // Regular person migration between groups
                queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'person', UIDperson, UIDperson, existingPerson.MemberA, UIDgroup, timestamp);
                
                // Trigger family fees recalculation on group change
                const family = await query(`SELECT Links.UIDTarget FROM Links 
                    INNER Join Member ON (Links.UIDTarget=Member.UID AND Links.Type IN ('family','familyFees')) 
                    WHERE Links.UID=? `, [UIDperson]);

                if (family.length > 0) {
                    const UIDfamily = family[0].UIDTarget;
                    queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'family', UIDperson, UIDperson, null, UIDfamily);
                }
            } else {
                // Extern -> Extern migration
                queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'extern', UIDperson, UIDperson, existingPerson.MemberA, UIDgroup, timestamp);
                
                // Trigger family fees recalculation on group change
                const family = await query(`SELECT Links.UIDTarget FROM Links 
                    INNER Join Member ON (Links.UIDTarget=Member.UID AND Links.Type IN ('family','familyFees')) 
                    WHERE Links.UID=? `, [UIDperson]);

                if (family.length > 0) {
                    const UIDfamily = family[0].UIDTarget;
                    queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'family', UIDperson, UIDperson, null, UIDfamily);
                }
            }
        } else {
            // No existing group - add to tree
            await addToTree(UIDperson, UIDgroup, timestamp);
        }
    }
};

/**
 * Publishes change events for person modifications.
 * Notifies related entities (groups, organizations) about changes to a person.
 * 
 * @param {Object} person - The person object containing at least `Type` and `UID` properties.
 * @param {Object} diff - An object representing the differences or changes to be published.
 * @param {number} timestamp - The timestamp to associate with the event.
 * @param {string} organization - The organization UID for multi-tenant context.
 */
export const publishChangeEvent = async (person, diff, timestamp, organization, noUpdate = false) => {
    try {
        if (!diff || Object.keys(diff).length === 0) return;
        
        // Get all objects with the same UIDBelongsTo (person and all their entries/jobs/guests)
        // along with their membership links to groups
        const entries = await query(`
            SELECT 
                ObjectBase.UID AS UID,
                ObjectBase.Type AS BaseType,
                Links.UIDTarget,
                Target.Type AS TargetType
            FROM ObjectBase
            INNER JOIN Links ON (Links.UID = ObjectBase.UID AND Links.Type IN ('member','memberA'))
            INNER JOIN ObjectBase AS Target ON (Target.UID = Links.UIDTarget)
            WHERE ObjectBase.UIDBelongsTo = ? 
                AND ObjectBase.Type IN ('person','extern','job','guest','entry')
            GROUP BY ObjectBase.UID, Links.UIDTarget
        `, [person.UID], { backDate: timestamp, log: true });
        
        // Publish change events for each object-group relationship
        for (const entry of entries) {
            // Always publish event for the base type (entry/job/guest/person/extern)
            publishEvent(`/change/${entry.TargetType}/${entry.BaseType}/${HEX2uuid(entry.UIDTarget)}`, {
                organization: organization,
                data: { UID: HEX2uuid(entry.UID), diff },
                backDate: timestamp
            });
            
            // For entries, jobs, and guests, also publish event for the person type
            if (['entry', 'job', 'guest'].includes(entry.BaseType)) {
                publishEvent(`/change/${entry.TargetType}/${person.Type}/${HEX2uuid(entry.UIDTarget)}`, {
                    organization: organization,
                    data: { UID: HEX2uuid(entry.UID), diff },
                    backDate: timestamp
                });
            }
        }
        if (!noUpdate) addUpdateList(entries.map(e => e.UIDTarget));
    }
    catch (e) {
        errorLoggerUpdate(e)
    }
};

/**
 * Publishes an event for a person joining or leaving an organization.
 * Notifies the group and its parent groups about the person's status change.
 * 
 * @param {'new'|'exit'} eventType - The type of event: 'new' for joining, 'exit' for leaving
 * @param {Buffer|string} UIDperson - The UID of the person (can be Buffer or from object.UID)
 * @param {string} ObjectType - The type of the object ('person' or 'extern')
 * @param {Buffer} UIDgroup - The UID of the group
 * @param {string} organization - The organization UID for multi-tenant context
 * @param {number} timestamp - The timestamp to associate with the event
 */
export const publishGroupEvent = async (eventType, UIDperson, ObjectType, UIDgroup, organization, timestamp) => {
    try {
        // Query for the group and its parents
        const groups = await query(`SELECT ObjectBase.UID AS UID,ObjectBase.Type 
            FROM Links
            INNER JOIN ObjectBase ON (ObjectBase.UID=Links.UIDTarget  
            AND Links.Type IN ('member','memberA','memberS','memberSys') AND ObjectBase.Type IN('group','ggroup'))
            WHERE Links.UID=?
            GROUP BY ObjectBase.UID`, [UIDgroup], { backDate: timestamp });

        const groupUIDs = [...groups.map(el => el.UID), UIDgroup];
        
        // Convert UIDperson to hex string if it's a Buffer
        const personUIDHex = Buffer.isBuffer(UIDperson) ? HEX2uuid(UIDperson) : HEX2uuid(UIDperson);
        
        // Publish event to all groups
        groupUIDs.forEach(group =>
            publishEvent(`/${eventType}/group/${ObjectType}/${HEX2uuid(group)}`, {
                organization: organization,
                data: [personUIDHex],
                backDate: timestamp
            })
        );
    } catch (e) {
        errorLoggerUpdate(e);
    }
};

/**
 * Shared function to update group membership for person or extern
 * @param {Object} req - Express request object
 * @param {Object} res - Express response object
 * @param {'person'|'extern'} targetType - The target type to convert to
 * @param {Function} isObjectAdmin - Function to check if user has admin rights
 */
export const updateGroupMembershipShared = async (req, res, targetType, isObjectAdmin) => {
    try {
        const config = await getConfig('db', req);
        const timestamp = parseTimestampToSeconds(req.query.timestamp ? String(req.query.timestamp) : undefined);
        
        if (!isObjectAdmin(req, req.params.UID)) {
            res.status(403).json({ success: false, message: 'user not authorized for this person' });
            return;
        }

        const UIDgroup = UUID2hex(req.params.group);
        const UIDperson = UUID2hex(req.params.UID);

        // Validate group exists
        const groups = await query(`SELECT ObjectBase.UID,ObjectBase.Title,Member.Display,
                ObjectBase.dindex,Member.Data, ObjectBase.gender, ObjectBase.hierarchie,
                ObjectBase.stage, ObjectBase.validFrom, ObjectBase.UIDBelongsTo
                FROM ObjectBase 
                INNER JOIN Member ON (Member.UID=ObjectBase.UID)
                WHERE ObjectBase.UID=?`, [UIDgroup], { cast: ['json', 'UUID'] });

        if (!groups || groups.length === 0) {
            res.status(404).json({ success: false, message: 'group not found' });
            return;
        }

        const group = groups[0];

        // Combined-gender groups (gender='C') are structural containers only;
        // individual persons/externs may not be added directly to them.
        if (group.gender === 'C') {
            res.status(400).json({ success: false, message: 'persons cannot be added to a combined-gender group (gender C)' });
            return;
        }

        // Fetch person data
        const persons = await query(`SELECT Member.Data,Links.UIDTarget AS MemberA, 
            ObjectBase.Data AS BaseData, ObjectBase.dindex, ObjectBase.Type,ObjectBase.SortName AS SortBase,
            ObjectBase.ValidFrom, ObjectBase.ValidUntil
            FROM ObjectBase 
            INNER JOIN Member ON (Member.UID=ObjectBase.UIDBelongsTo)
            INNER JOIN Links  ON(Links.Type='memberA' AND Links.UID=ObjectBase.UID) 
            INNER JOIN ObjectBase  AS MainGroup ON (Links.UIDTarget=MainGroup.UID AND MainGroup.Type='group')
            WHERE ObjectBase.UID=? AND ObjectBase.Type IN ('person','extern')`,
            [UIDperson],
            {
                log: false,
                cast: ['json']
            });

        if (persons.length === 0) {
            res.status(300).json({ success: false, message: 'person not found' });
            return;
        }
         
        const person = persons[0];
        const PersonData = person.Data;
        const GroupData = group.Data;

        // adjust hierarchie and stage for new rendered title
        PersonData.hierarchie = GroupData.hierarchie;
        PersonData.stage = GroupData.stage === 0 ? config.AdultStage : GroupData.stage;

        // Render new object data with appropriate template
        const template = Templates[req.session.root][targetType];
        const object = await renderObject(template, { UID: UIDperson, ...PersonData, group: GroupData }, req);
        object.UID = UIDperson;

        // Determine if type conversion is happening
        const sourceType = person.Type;
        const isTypeConversion = sourceType !== targetType;

        // Update ObjectBase if type changed, group changed, or rendered fields differ.
        // Title depends on hierarchie/stage/gender which come from the group, so any
        // group change must trigger an update even when SortBase happens to be identical.
        if (isTypeConversion || !person.MemberA.equals(UIDgroup) || object.SortBase !== person.SortBase) {
            await query(`UPDATE ObjectBase SET Type=?,Title=?,SortName=?,dindex=?,hierarchie=?,stage=?,gender=?,Data=? WHERE UID=?`,
                [targetType, object.Title, object.SortBase, object.dindex, object.hierarchie, object.stage, object.gender, JSON.stringify(person.BaseData), object.UID],
                { backDate: timestamp });
        }

        // Handle group membership migration if group changed or type changed
        if (!person.MemberA.equals(UIDgroup) || isTypeConversion) {
            await handleGroupMembershipMigration(person, UIDperson, UIDgroup, req, timestamp, targetType);
            
            // Publish appropriate event for type conversion
            if (isTypeConversion) {
                if (targetType === 'person' && sourceType === 'extern') {
                    // Extern -> Person: joining organization
                    await publishGroupEvent('new', object.UID, targetType, UIDgroup, req.session.root, timestamp);
                } else if (targetType === 'extern' && sourceType === 'person') {
                    // Person -> Extern: leaving organization
                    await publishGroupEvent('exit', UIDperson, targetType, UIDgroup, req.session.root, timestamp);
                }
            }
        }
         if (isTypeConversion || !person.MemberA.equals(UIDgroup) || object.SortBase !== person.SortBase || object.hierarchie !== person.hierarchie || object.stage !== person.stage) {
            // Update WebSocket clients
            addUpdateEntry(req.params.UID, { 
                person: { 
                    Type: targetType, 
                    ...object, 
                    Data: PersonData, 
                    UID: HEX2uuid(object.UID) 
                }, 
                parent: group 
            });
            addUpdateList([person.MemberA, UIDgroup]);
        }
        res.json({ success: true , result:null});
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Internal server error' });
    }
};

// ---------------------------------------------------------------------------
// Shared DB helpers — used by both person and extern flows
// ---------------------------------------------------------------------------

/**
 * Fetch a group record (ObjectBase + Member) by its binary UID.
 * Shared entry point for person and extern create/update flows.
 *
 * @param {Buffer} UIDgroup
 * @returns {Promise<Object|null>}
 */
export const fetchGroup = async (UIDgroup) => {
    const rows = await query(
        `SELECT ObjectBase.UID, ObjectBase.Title, Member.Display,
            ObjectBase.dindex, Member.Data, ObjectBase.hierarchie, ObjectBase.gender,
            ObjectBase.stage, ObjectBase.validFrom, ObjectBase.UIDBelongsTo
        FROM ObjectBase
        INNER JOIN Member ON (Member.UID = ObjectBase.UID)
        WHERE ObjectBase.UID = ?`,
        [UIDgroup],
        { cast: ['json', 'UUID'] }
    );
    return rows[0] ?? null;
};

/**
 * Rebuild visibility and list access for a person or extern and all its job objects.
 * Shared implementation for /person/rebuildAccess and /extern/rebuildAccess.
 *
 * @param {Buffer} UID   Binary UID of the person or extern
 * @param {string} sessionRoot  req.session.root
 */
/**
 * Return the UNIX timestamp (seconds) of the most recent ObjectBase row for a UID
 * across all system-time history.  Used to prevent backdated type-change operations
 * (extern → person or person → extern) from landing before existing history rows,
 * which would create impossible temporal ordering in the versioned table.
 *
 * @param {Buffer} UID  Binary UID of the person or extern
 * @returns {Promise<number|null>}  Latest validFrom in seconds, or null if not found
 */
export const fetchLatestObjectValidFrom = async (UID) => {
    const rows = await query(
        `SELECT UNIX_TIMESTAMP(MAX(validFrom)) AS latestValidFrom
        FROM ObjectBase FOR SYSTEM_TIME ALL
        WHERE UID = ?`,
        [UID]
    );
    return rows[0]?.latestValidFrom ?? null;
};

/**
 * Check whether a person or extern record already exists for the given binary UID.
 * Shared by both person and extern create/update flows.
 *
 * @param {Buffer} UID   Binary UID of the person or extern
 * @param {string} asOf  SQL FOR SYSTEM_TIME clause or empty string
 * @returns {Promise<Object[]>}
 */
export const fetchMemberExists = async (UID, asOf) => {
    return query(
        `SELECT Member.UID, Member.Data, Links.UIDTarget AS MemberA, ObjectBase.Data AS BaseData,
            ObjectBase.Type, ObjectBase.dindex, ObjectBase.SortName AS SortBase
        FROM ObjectBase ${asOf}
        INNER JOIN Member ON (Member.UID = ObjectBase.UIDBelongsTo)
        INNER JOIN Links ${asOf} ON (Links.Type = 'memberA' AND Links.UID = ObjectBase.UID)
        INNER JOIN ObjectBase ${asOf} AS MainGroup
            ON (Links.UIDTarget = MainGroup.UID AND MainGroup.Type = 'group')
        WHERE ObjectBase.UID = ? AND ObjectBase.Type IN ('person', 'extern')`,
        [UID],
        { cast: ['json'] }
    );
};

export const rebuildMemberAccess = async (UID, sessionRoot) => {
    const jobs = await query(
        `SELECT UID FROM ObjectBase WHERE UIDBelongsTo = ? AND Type = 'job'`,
        [UID]
    );
    for (const job of jobs) {
        await personRebuildAccess(job.UID);
        await personListRebuildAccess(job.UID, sessionRoot);
    }
};