Source: tree/matchObjects/matchObjectsLists.js

// @ts-check

/**
 * @typedef {Object} DatabaseObject
 * @property {Buffer} UID - The unique identifier as a Buffer
 * @property {Buffer} UIDBelongsTo - The parent object identifier as a Buffer
 * @property {string} Type - The object type
 * @property {string} [Title] - Optional title for the object
 */

/**
 * @typedef {Object} FilterObject
 * @property {Buffer} UID - The filter unique identifier
 * @property {string} Type - Filter type ('include', 'exclude', 'intersect')
 * @property {Object} Data - Filter configuration data
 */

import {query, HEX2uuid, UUID2hex} from '@commtool/sql-query'
import {filterObjects} from '../../utils/objectfilter/filters/index.js'
import { publishEvent } from '../../utils/events.js'
import { addUpdateList } from '../../server.ws.js'
import { errorLoggerUpdate } from '../../utils/requestLogger.js'
/*
    This module handles the update of lists, when a data object is changed
    The function machcObjectsLists is called by treeAdd or treeMigrate
*/

/**
 * Reduces duplicate objects to unique entries based on UIDBelongsTo comparison
 * @param {any[]} objects - Array of objects to reduce
 * @returns {any[]|undefined} - Array with duplicate UIDBelongsTo entries removed
 */
export const  reduceObjects=(objects)=>
{
    try 
    {
        // should reduce the duplicate persons coming from avatars to one

        return objects.reduce(/** @param {any} result */ /** @param {any} current */ (result,current)=>(result.find(/** @param {any} el */ el=>el.UIDBelongsTo.equals(current.UIDBelongsTo))?
                 result: [...result,current]),[])
    }
    catch(e)
    {
        errorLoggerUpdate(e)
    }
}

/**
 * Removes objects based on exclude filter criteria
 * @param {any[]} myDEObjects - Array of objects to potentially remove
 * @param {any} exclude - Exclude filter object
 * @param {any} listUID - List identifier
 * @returns {Promise<any[]|undefined>} - Array of removed objects
 */
const removeObjects=async ( myDEObjects,exclude,listUID )=>
{
    try {
        let removed=[]
        let added=[]
        // link the entries via dynamik link to the filter
        await query(`INSERT IGNORE INTO Links (UID,Type,UIDTarget) VALUES (?,'dynamic',?)`,
            myDEObjects.map(/** @param {any} o */ o=>[o.UID,exclude.UID]),{batch:true})
        // generate the missing membership link
        const existingMember0= await query(`SELECT Links.UID FROM Links
            WHERE Links.UID IN (?) AND Links.Type='member0' AND Links.UIDTarget=?`,
            [myDEObjects.map(/** @param {any} o */ o=>o.UID), listUID])
        removed=myDEObjects.filter(/** @param {any} el */ el=>!existingMember0.some(/** @param {any} e */ e=>e.UID.equals(el.UID)))
        if(removed.length>0)
        {
            await query(`INSERT IGNORE INTO Links (UID,Type,UIDTarget) 
                VALUES (?,'member0',?)`,
                removed.map(/** @param {any} o */ o=>([o.UID,listUID])),{batch:true})
        }
        return removed
    }
    catch(e)
    {
        errorLoggerUpdate(e)
    }
}

/**
 * Matches objects against excluder lists and handles membership updates
 * @param {any[]} Objects - Array of objects to process
 * @param {string|Buffer} listUID - List identifier 
 * @param {string} UIDOrga - Organization identifier
 * @returns {Promise<void>}
 */
export const matchExcludersList=async (Objects,listUID, UIDOrga)=>
{
    try 
    {
        listUID=UUID2hex(listUID)
        
        // deduct the entries, which have to be excluded by excluders 
        let addResult=reduceObjects(Objects.filter(/** @param {any} ad */ ad=>ad.new))
        const excluders=await query(`
            SELECT ObjectBase.Type,ObjectBase.UID,ObjectBase.Data 
            FROM ObjectBase
            INNER JOIN Links  ON (Links.UID=ObjectBase.UID  AND Links.Type = 'list') 
            WHERE Links.UIDTarget=? AND ObjectBase.Type IN ('exclude','intersect')`,
            [listUID],
        )
        // get the includers/intersects for this list
        /** @type {any[]} */
        let toBeRemoved=[]
    
        if(excluders.length>0 && Objects.length>0)
        {
            // filter for newly created entries
            for(const exclude of excluders)
            {
                let filter
                try{
                filter=JSON.parse(exclude.Data)
                }
                catch(e)
                {
                    console.log(e, filter.Data)
                }
                const myDEObjects=filterObjects(Objects,filter)

                if(exclude.Type==='exclude' && myDEObjects.length>0)
                {
                    const removeResult = await removeObjects( myDEObjects,exclude,listUID );
                    if (removeResult) {
                        toBeRemoved=[...toBeRemoved, ...removeResult];
                    }
                    if (addResult) {
                        addResult= addResult.filter(/** @param {any} ob */ ob=>(!myDEObjects.some(/** @param {any} fo */ fo=>ob.UIDBelongsTo.equals(fo.UIDBelongsTo))));
                    }
                
                }
                if(exclude.Type==='intersect')
                {
                    const invertedFiltered=Objects.filter(/** @param {any} no */ (no)=>(myDEObjects.find(/** @param {any} dObj */ dObj=>(dObj.UID.compare(no.UID)===0))===undefined))
                    if(invertedFiltered.length>0)
                    {
                        const removeResult = await removeObjects( invertedFiltered,exclude,listUID );
                        if (removeResult) {
                            toBeRemoved=[...toBeRemoved,...removeResult];
                        }
                        if (addResult) {
                            addResult=  addResult.filter(/** @param {any} ob */ ob=>(!invertedFiltered.some(/** @param {any} fo */ fo=>ob.UIDBelongsTo.equals(fo.UIDBelongsTo))));
                        }
                    }
                }
            }

        }

        // send the events for everything which is now added after all filters
        if(addResult && addResult.length>0)
        {
            publishEvent(`/add/dlist/person/${HEX2uuid(listUID)}`, { organization: UIDOrga, data: addResult.map(/** @param {any} added */ added=>HEX2uuid(added.UIDBelongsTo)) })
            publishEvent(`/add/dlist/entry/${HEX2uuid(listUID)}`, { organization: UIDOrga, data: addResult.map(/** @param {any} added */ added=>HEX2uuid(added.UID)) })
        }
        const reducedToBeRemoved = reduceObjects(toBeRemoved);
        toBeRemoved = reducedToBeRemoved || [];
        if(toBeRemoved.length>0)
        {
            publishEvent(`/remove/dlist/person/${HEX2uuid(listUID)}`, { organization: UIDOrga, data: toBeRemoved.map(/** @param {any} removed */ removed=>HEX2uuid(removed.UIDBelongsTo)) })
            publishEvent(`/remove/dlist/entry/${HEX2uuid(listUID)}`, { organization: UIDOrga, data: toBeRemoved.map(/** @param {any} removed */ removed=>HEX2uuid(removed.UID)) })
        }
        if((addResult && addResult.length>0) || toBeRemoved.length >0 )
            addUpdateList(listUID)
    }
    catch(e)
    {
        errorLoggerUpdate(e)
    }


}

/**
 * Retrieves all filters for the supplied sources
 * @param {Buffer[]} sources - Array of source identifiers  
 * @returns {Promise<any[]|undefined>} - Array of filter objects
 */
export const getFilters=async (sources)=>
{
    //reads all filters for the supplied sources
    try {

        const filters=await query(
            `SELECT DISTINCT ObjectBase.Type,ObjectBase.UID,ObjectBase.Data, Links.UIDTarget AS listUID, Source.Type
            AS SourceType FROM ObjectBase 
            INNER JOIN Links  ON (Links.UID=ObjectBase.UID  AND Links.Type = 'list') 
            INNER JOIN ObjectBase  AS Source ON (Source.UID = ObjectBase.UIDBelongsTo AND Source.Type IN ('group','list','event'))
            WHERE ObjectBase.UIDBelongsTo IN (?) AND ObjectBase.Type IN ('include','exclude','intersect')
            `,
            [sources],
            {
                log:true,
                cast:['json'],
                group: /** @type {function(any, any): any} */ (result, current)=>
                {
                    const index=result.findIndex(/** @param {any} el */ el=>el.UID.equals(current.UID))
                    if(index>=0)
                    {
                        return result
                    }
                        // the row will  be addded to the result set
                    return [...result,current]
                }
            }
        )
        return filters
    
    }
    catch(e)
    {
        errorLoggerUpdate(e)
    }
}


/**
 * Retrieves objects based on the provided ObjectUID(s).
 *
 * This function queries the database to fetch objects and their associated data. 
 * It supports both single ObjectUID and an array of ObjectUIDs. The returned data 
 * includes details such as UID, type, associated member data, extra data, main base data, 
 * title, and validity timestamps.
 *
 * @async
 * @param {Buffer|Buffer[]} ObjectUID - A single UID or an array of UIDs representing the objects to retrieve.
 * @returns {Promise<Object[]|undefined>} A promise that resolves to an array of objects containing:
 * - `Main.UID` (string): The UID of the main object ('person','guest','extern','job').
 * - `ObjectBase.UID` (string): The UID of the base object, this will be the UID of entry, if a list is the data source.
 * - `Main.Type` (string): The type of the main object (e.g., 'person', 'job', 'guest', 'extern').
 * - `Member.Data` (Object): Data associated with the member.
 * - `Main.Data` (Object): Extra data associated with the main object.
 * - `MainBase.Data` (Object): Data from the main base object.
 * - `ObjectBase.UIDBelongsTo` (string): UID of the Member entry, this Object belongs to.
 * - `Main.dindex` (number): Index value associated with the main object.
 * - `ObjectBase.Title` (string): Title of the base object.
 * - `ObjectBase.validFrom` (number): UNIX timestamp indicating the validity start date of the base object.
 *
 * @throws {Error} Logs and throws an error if the query fails.
 */

export const getObjects=async(ObjectUID)=>
{
    try {
        // const asOf= timestamp ? `FOR SYSTEM_TIME AS OF FROM_UNIXTIME(${timestamp})` : ''
        let myObjects
        if(Array.isArray(ObjectUID))
        {
            myObjects=await query( `SELECT DISTINCT Main.UID ,ObjectBase.UID AS UIDbase, Main.Type,  Member.Data, Main.Data AS ExtraData, MainBase.Data AS MainBaseData,
                ObjectBase.UIDBelongsTo, Main.dindex,ObjectBase.Title, UNIX_TIMESTAMP(ObjectBase.validFrom) AS validFrom
                FROM ObjectBase 
                INNER JOIN ObjectBase AS Main ON (Main.UIDBelongsTo=ObjectBase.UIDBelongsTo AND Main.Type IN ('person','job','guest','extern')) 
                INNER JOIN Member ON (Member.UID=Main.UIDBelongsTo)
                INNER JOIN ObjectBase  AS MainBase ON (MainBase.UID=Member.UID)
                WHERE ObjectBase.UID IN (?) AND ObjectBase.Type IN ('person','guest','extern','job','entry')`,
                [ObjectUID],{cast:['json']} )
        }
        else
        {
            myObjects=await query( `SELECT  Main.UID, ObjectBase.UID AS UIDbase, Main.Type,  Member.Data, Main.Data AS ExtraData,
                MainBase.Data AS MainBaseData,
                ObjectBase.UIDBelongsTo, Main.dindex,ObjectBase.Title,  UNIX_TIMESTAMP(ObjectBase.validFrom) AS validFrom
                FROM ObjectBase  
                INNER JOIN ObjectBase AS Main ON (Main.UIDBelongsTo=ObjectBase.UIDBelongsTo AND Main.Type IN ('person','job','guest','extern')) 
                INNER JOIN Member ON (Member.UID=Main.UIDBelongsTo)
                INNER JOIN ObjectBase   AS MainBase ON (MainBase.UID=Member.UID)
                WHERE ObjectBase.UID =? AND ObjectBase.Type IN ('person','guest','extern','job','entry')`,
                [ObjectUID],{cast:['json']} )
        }
        return myObjects
    }
    catch(e)
    {
        errorLoggerUpdate(e)
    }
}

/**
 * Processes the inclusion filter and updates the database with the filtered objects.
 * This function handles the creation of new entries, linking entries to filters, and
 * processing exclude and intersect filters for newly created entries.
 *
 * @async
 * @function processIncludeFilter
 * @param {Object} include - The inclusion filter object containing filter data and source type.
 * @param {Object[]} myObjects - Array of objects to be filtered and processed.
 * @returns {Promise<void>} Resolves when the processing is complete.
 * @throws {Error} Logs and throws an error if any issue occurs during processing.
 *
 * @example
 * const include = {
 *   Data: {  filter criteria here },
 *   SourceType: 'group',
 *   listUID: 'someUID',
 *   UID: 'filterUID'
 * };
 * const myObjects = [/ array of objects as retrieved by getObjects/];
 * await processIncludeFilter(include, myObjects);
 */
/**
 * Processes include filter and creates new entries based on filter criteria
 * @param {any} include - Include filter object containing Data, SourceType, listUID, UID properties
 * @param {any[]} myObjects - Array of objects to filter
 * @param {string} UIDOrga - Organization identifier
 * @returns {Promise<void>}
 */
export const processIncludeFilter= async (include, myObjects, UIDOrga) =>
{
    try 
    {
   
        const filter=include.Data
    
        const myDObjects=filterObjects(myObjects,filter).map(/** @param {any} o */ o=>({...o}))
        const reducedObjects= reduceObjects(myDObjects)

        if(reducedObjects && reducedObjects.length>0)
        {
        
            {
                const toBeCreated=[]
                if(include.SourceType==='group')
                {
                    const already=await query(`SELECT ObjectBase.UID,ObjectBase.UIDBelongsTo,ObjectBase.Data,
                        UNIX_TIMESTAMP(ObjectBase.validUntil) AS validUntil
                        FROM ObjectBase
                        INNER JOIN Links ON (Links.UID=ObjectBase.UID AND Links.Type IN ('member','memberA')) 
                        WHERE ObjectBase.Type='entry' AND Links.UIDTarget=? AND ObjectBase.UIDBelongsTo IN (?) `,
                    [include.listUID,reducedObjects.map(o=>o.UIDBelongsTo)],
                    )
                    for(const fo of reducedObjects)
                    {
                    
                        const entry=already.find(at=>(fo.UIDBelongsTo.equals(at.UIDBelongsTo)))
                        if(entry)
                        {
                            fo.UID= entry.UID
                        
                        }
                        else
                        {
                                                    
                            //fo.UIDBelongsTo=fo.UID
                            const [UID]=await query(`SELECT UIDV1() AS UID`, [])
                            fo.UID=UID.UID
                            toBeCreated.push(fo)
                            myDObjects.filter(/** @param {any} el */ el=>el.UIDBelongsTo.equals(fo.UIDBelongsTo)).forEach(/** @param {any} el */ el=>el.new=true)
                        }
                    }
                }
                else if(include.SourceType==='list')
                {
                    // we have to use the original base UID of the entry object as we want to share entries with the original list
                    //this allows to have the same entry parameters visible/consolidated from difefrent lists in one dlist
                    reducedObjects.forEach(/** @param {any} fo */ fo=>{
                            fo.UID=fo.UIDbase   
                            myDObjects.filter(/** @param {any} el */ el=>el.UIDBelongsTo.equals(fo.UIDBelongsTo)).forEach(/** @param {any} el */ el=>el.new=true)                
                        }
                    )
                }
                if(toBeCreated.length)
                {
                    // generate List entries for objects which have not yet an entry (empty Display, Data, SortName, as they will come from the real entry, when listed )
                    for(const tbc of toBeCreated)
                    {
                        await query(`INSERT IGNORE INTO ObjectBase (UID,Type,UIDBelongsTo,Title,Data) VALUES (?,'entry',?,?,?)`,
                                
                                    [tbc.UID,tbc.UIDBelongsTo,tbc.Title,JSON.stringify({})]
                                
                        )
                    }


                }
        
                // link the entries via dynamik link to the filter
                for(const tbl of reducedObjects)
                {
                    await query(`
                            INSERT IGNORE INTO Links (UID,Type,UIDTarget) VALUES (?,'dynamic',?)`,
                            [tbl.UID,include.UID]
                    )
                    // generate the membership link
                    const memberType=  include.SourceType==='group' ? 'memberA' : 'member'
                    await query(`
                        INSERT IGNORE INTO Links (UID,Type,UIDTarget) VALUES (?,?,?)`,
                        [tbl.UID,memberType,include.listUID]
                    )
                }
            } 
            // publish the add event in matchExcludersList!!!           
        
        }     
        // now process the exclude and intersect filters for the newly created entries
        await matchExcludersList(myDObjects,include.listUID, UIDOrga)
    } 
    catch(e)
    {
        errorLoggerUpdate(e)
    }
}  

/**
 * Matches objects against list filters and updates dynamic lists accordingly
 * @param {Buffer|Buffer[]} ObjectUID - Single object UID or array of object UIDs to process
 * @param {Buffer|Buffer[]} sources - Source list/group UIDs to check filters for
 * @param {string} UIDOrga - Organization UUID for multi-tenant scoping
 * @returns {Promise<void>}
 */
export const matchObjectsLists=async (ObjectUID,sources, UIDOrga)=>
{
    try
    {
        // Type validation
        if (typeof UIDOrga !== 'string') {
            throw new TypeError(`UIDOrga must be a string, got ${typeof UIDOrga}`);
        }
        
        if (!Buffer.isBuffer(ObjectUID) && !Array.isArray(ObjectUID)) {
            throw new TypeError(`ObjectUID must be a Buffer or array, got ${typeof ObjectUID}`);
        }
        
        if (!Buffer.isBuffer(sources) && !Array.isArray(sources)) {
            throw new TypeError(`sources must be a Buffer or array, got ${typeof sources}`);
        }
        
        // check the supplied object(single object ObjectUID)/objects (ObjectUID=Array of object UIDs) against all 
        // include/exclude/intersect filters of the supplied sources
        // updates the relevant dlist's where these filters are pointing to

        // match object or an array of objects against all filters of an array of sources and 
        // get all filters (include, exclude, intersect).  They are seletced by the UIDBelongsTo of the sources
    

        if(!sources || sources.length===0)
            return

        // Ensure sources is an array
        const sourceArray = Array.isArray(sources) ? sources : [sources];
    
        const filters=await getFilters(sourceArray);       
        // return if we do not have filters
        
        if(!filters || !filters.length)
            return
        
        // get Object Data
        const myObjects= await getObjects(ObjectUID)
        
        // Add null safety for myObjects
        if (!myObjects) {
            return;
        }
        
        // go through all filters, which are includers and add the filtered objects, Filters with 0 results will be eliminated
        const includes=[]

        for( const include of filters.filter(/** @param {any} f */ f=>(f.Type==='include')))
        {
            
            await processIncludeFilter(include, myObjects, UIDOrga)

        }
    }
    catch(e)
    {
        errorLoggerUpdate(e)
    }
}