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