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 { 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 '../../utils/objectfilter/filters/filterPerson.js';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';
import { addUpdateList, addUpdateEntry } from '../../server.ws.js';
import { encryptIbans } from '../../utils/crypto.js';
import { updateEntityEmbedding } from '../../utils/embeddings.js';
import _ from 'lodash';

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

/**
 * Updates or creates AI embeddings for a person/extern asynchronously
 * Wrapper around updateEntityEmbedding with sanitize option enabled
 * 
 * @param {Buffer} UID - Entity UID as buffer
 * @param {Object} data - Entity data object
 * @param {string} Type - Object type ('person', 'extern', 'group', etc.)
 * @param {Buffer} UIDOrganization - Organization UID as buffer
 * @returns {Promise<void>}
 */

export async function updatePersonEmbedding(UID, data, Type, UIDOrganization) {
    const titles= await query(`SELECT Title FROM ObjectBase WHERE UID=?`, [UID]);
    const eData= { Title: titles[0].Title};
    ['email','phone','address','accounts','firstName','lastName'].forEach((field) => {
        if (data[field]) eData[field]=data[field];
    });
    return updateEntityEmbedding(UID, eData, Type, UIDOrganization, { sanitize: true });
}



/**
 * 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 Error('Person not found');
    }

    const origData = _.cloneDeep(origPerson[0].Data);
    const newData = _.mergeWith(_.cloneDeep(origData), partialData, mergeCustomizer);
    
    // Encrypt IBANs
    encryptIbans(await getConfig('db', req), newData);

    // Update Member table only
    await query(`UPDATE Member SET Data=? WHERE UID=?`, [JSON.stringify(newData), UID]);

    // 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), 'member', UID, UID, null, UID);
        // Publish change events to all parent groups
        await publishChangeEvent({ Type: 'person', UID: UID }, myDiff, null, req.session.root);
        updatePersonEmbedding(UID, newData, origPerson[0].Type || 'person', req.session.root).catch(err => {
            console.error('Error updating person embedding:', err);
        });
    }

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

    // WebSocket updates
    addUpdateEntry(UID, { person: { Type: 'person', Data: newData, UID: HEX2uuid(UID) } });
    addUpdateList([UID]);

    return { ...origPerson[0].BaseData, Data: newData, UID: HEX2uuid(UID) };
};

/**
 * 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 Error('Person not found');
    }

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

    // 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]);

        // queue is only run for keys, which are relevant for filters
        if (filteredDiff)
            queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'member', UIDperson, UIDperson, filteredDiff, UIDperson);
        // event is published for all changes
        await publishChangeEvent({ Type: object.Type || 'person', UID: UIDperson }, myDiff, timestamp, req.session.root);
    }

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

    // Handle dindex changes
    if (origDindex !== object.dindex) {
        queueAdd(UUID2hex(req.session.root), UUID2hex(req.session.user), 'listMember', UIDperson, UIDperson, null, UIDperson);
    }
    // Asynchronously update AI embeddings (non-blocking)
    updatePersonEmbedding(UIDperson, object.Data, object.Type || 'person', req.session.root).catch(err => {
        console.error('Background embedding update failed:', err);
    });

    
    // WebSocket updates
    addUpdateEntry(UIDperson, { person: { Type: object.Type || 'person', Data: object.Data, UID: HEX2uuid(UIDperson) } });
    addUpdateList([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');
    }
    
    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,  type, 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(`UPDATE Links SET Links.Type='family' WHERE Links.UID=? AND Links.UIDTarget=? AND Links.Type='familyFees' `, [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);
                
                // Publish new event for person joining organization
                await publishGroupEvent('new', UIDperson,  type, UIDgroup, req.session.root, 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(`UPDATE Links SET Links.Type='familyFees' WHERE Links.UID=? AND Links.UIDTarget=? AND Links.Type='family' `, [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) => {
    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
                });
            }
        }
    }
    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') 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 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];

        // 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;

        // 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 or other fields need updating
        if (isTypeConversion || 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);
                }
            }
            
            // 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 });
    } catch (e) {
        errorLoggerUpdate(e);
        res.status(500).json({ success: false, message: 'Internal server error' });
    }
};