Alert from Background Scheduler fires 3 times when should only fire once

I have a SuiteCRM alert being shown 3 times when it is supposed to be shown once after a background task is completed. If you can see something I am doing wrong, please let me know.

Sorry for the long post; I was going to just post edited (shortened) versions of the code, but thought that would cause more confusion than the full code. If that is wrong, I can re-do with shortened code.

SuiteCRm v7.13 on Debian 11

I am sending a job to the background scheduler. The job could (and often does) affect over 1,000 records so I want the user freed up so they can continue working with the system while the job runs.

One the job is finished, I use
BeanFactory::newBean('Alerts');
to let the user know the job is complete.

Problem is, the alert is sent 3 times. It is sent 3 times whether there are 2 or 1,000 records affected.

The job sucessfully runs and the records are properly updated. It is just the multiple alerts being generated that I would like to resolve.

If it helps, this is for a customized version of SuiteCRM v7 used by my Rotary to track donations for our various fund-raising efforts. We have annual fund-raising projects (Golf tournament, evening gala with Lobster dinner and dancing, …) and we want the Accounts and Contacts who have supported us to have a flag set, identifying what annual projects they have supported. I added checkboxes to each Account and Contact record for each project. Yes, I know we can get the information using Reports, but the person we have dong the data entry likes this way and I want to keep her happy.

I am sure I am doing something wrong in my sequence or call to the background scheduler, but I have tried and cannot see the error. I have noticed that when you put javascript in a file and source it from within the custom html (used to create a new menu), the javascript file sometimes gets called more than once. This does not happen if you embed the javascipt in tags in the html but I am not sure of that is the issue here.

If anyone has suggestions, please let me know.

Code shown below

Any suggestions would be appreciated.

The background task is triggered from a custom controller at

custom/modules/FP_events/controller.php

<?php

if(!defined('sugarEntry') || !sugarEntry) {
    die('Not A Valid Entry Point');
}

class CustomFP_eventsController extends SugarController
{
    public function action_getmatchingids() {

        $sourceKey = filter_var( $_COOKIE['source_key'] , FILTER_SANITIZE_SPECIAL_CHARS , FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_FLAG_STRIP_BACKTICK );
        // print "The Source is ". $sourceKey . "<br/>";
        $eventIdList = filter_var( $_REQUEST['uid'] , FILTER_SANITIZE_SPECIAL_CHARS , FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_FLAG_STRIP_BACKTICK );
        if ( !empty( $eventIdList )) {

			// This system will be storing thousands of records
			// and this logic hook will be looping through them one at a time
			// to calculate the totals for all associated records
			// Rather than run the code real-time inside this logic hook
			// which would take time and impact the user interaction
			// this logic hook creates a background job (Scheduled Task)
			// so the process works in the background
			// allowing the user interaction to proceed unaffected
			// while the code executes
			require_once 'include/SugarQueue/SugarJobQueue.php';
			$setSourceForLinkedRecords = new SchedulersJob();

			// Give it a name that will be clear in the Scheduler list what it is for
			$setSourceForLinkedRecords->name = "Set Source for Accounts and Contacts linked to this Event";

			// Jobs need an assigned user in order to run
			// I like to use the id of the person requesting the job
			global $current_user;
			$setSourceForLinkedRecords->assigned_user_id = $current_user->id;

			// Pass the information that our job will need
			$sourceKey = filter_var( $_COOKIE['source_key'] , FILTER_SANITIZE_SPECIAL_CHARS , FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_FLAG_STRIP_BACKTICK );
			$eventIdList = filter_var( $_REQUEST['uid'] , FILTER_SANITIZE_SPECIAL_CHARS , FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_FLAG_STRIP_BACKTICK );
			$setSourceForLinkedRecords->data = json_encode(array(
					'module' => 'FP_events',
					'id' => $eventIdList,
					'sourcekey' => $sourceKey,
				)
			);

			// Tell the scheduler what class to use
			$setSourceForLinkedRecords->target = "class::SetSourceForLinkedRecords";

			// Mark this job for 2 retries
			// This is optional and can be left out if not needed
			// I leave it in
			$setSourceForLinkedRecords->requeue = true;
			$setSourceForLinkedRecords->retry_count = 2;

			$jobQueue = new SugarJobQueue();
			$jobQueue->submitJob($setSourceForLinkedRecords);

        }

        SugarApplication::redirect('index.php?module=FP_events');
        // sugar_die('');
        return true;
    }
}

The scheduler is setup at

custom/Extension/modules/Schedulers/Ext/ScheduledTasks/SetSourceForLinkedRecords.php

<?php

if (!defined('sugarEntry') || !sugarEntry) {
	die('Not A Valid Entry Point');
}

class SetSourceForLinkedRecords implements RunnableSchedulerJob {

	public function run($queueFromEvent) {

		global $app_list_strings;

		// The only different part of the job code
		// compared to what it would look like in a controller file
		// is this part at the head of the file
		// and the code at the end of this file
		// to Submit the Job and Notify the user the job has finished

		// Grab the bean information using the supplied arguments.
		$jsonData = stripslashes(html_entity_decode($queueFromEvent));
		$eventDataArray = json_decode("$jsonData",1);

		// Unpack the data to identify the Bean
		$moduleName = $eventDataArray['module'];
		$eventIdList = $eventDataArray['id'];
		$sourceKey = $eventDataArray['sourcekey'];
		$sourceName = $app_list_strings['funds_source_names_list'][$sourceKey];

		// After this, with the exception of the code at the end of this class
		// the additional function below
		//    setJob(SchedulersJob $job)
		// and the generation of the alert message
		// the rest of this code is the same as it would have been
		// had the code been directly executed from the logic hook
		// instead of submitted as a job queue

		if ( !empty( $eventIdList )) {
            $recordIds = explode(',',$eventIdList);
            foreach ( $recordIds as $recordId ) {
                $eventBean = BeanFactory::getBean($moduleName, $recordId);
				$eventName = $eventBean->name;
                //print "Record ID {$eventBean->id}, contains <br/>";
                //var_dump($eventBean);
                $eventBean->load_relationship('fp_events_ayu_funds_1');
                $listOfFundRaiseIDs = $eventBean->fp_events_ayu_funds_1->get();
                //print "The list of linked records is <br/>";
                //var_dump($listOfFundRaiseIDs);
                //print "<br/>For Event " . $recordId . "<br/>";
				$numChanged = 0;
				$numTotal = 0;
                foreach( $listOfFundRaiseIDs as $key => $fundRaiseID ) {
					$numTotal = $numTotal + 1;
                    $fundRaiseBean = BeanFactory::getBean('AYU_Funds', $fundRaiseID);
                    if( $fundRaiseBean->deleted == 0 ) {
						$numChanged = $numChanged + 1;
                        //print "<br/><br/>The FundRaise name is " . $fundRaiseBean->name. "<br/>";
                        //print "The full information for this linked record is <br/>";
                        //var_dump($fundRaiseBean);
                        if ( $fundRaiseBean->parent_type == "Accounts" ) {
                            //print"This is an Account"<br/>";
                            $accountBean = BeanFactory::getBean('Accounts', $fundRaiseBean->parent_id);
                            if ( $accountBean->$sourceKey == 0 ) {
                                // print $accountBean->name." was not set<br/>";
                                $accountBean->$sourceKey = 1;
                                $accountBean->save();
                            }
                        } else {
                            //print"This is a Contact"<br/>";
                            $contactBean = BeanFactory::getBean('Contacts', $fundRaiseBean->parent_id);
                            if ( $contactBean->$sourceKey == 0 ) {
                                // print $contactBean->first_name." ".$contactBean->last_name." was not set<br/>";
                                $contactBean->$sourceKey = 1;
                                $contactBean->save();
                            }
                        }
                    }
                }
            }

            if ( $numChanged > 0 ) {
                // Create a message to advise the user the job has run
                global $current_user;
                $alertMessage = "Of ".$numTotal." Accounts and Contacts linked to the ".$eventName." event, ".$numChanged." had to be changed to have Source: ".$sourceName." set in the Accounts and Contacts";
                $receiptAlert = BeanFactory::newBean('Alerts');
                //$receiptAlert->name = "Source set for Accounts and Contacts";
                $receiptAlert->description = $alertMessage;
                $receiptAlert->assigned_user_id = $current_user->id;
                $receiptAlert->is_read = 0;
                $receiptAlert->type = "info";
                $receiptAlert->target_module = "FP_events";
                $receiptAlert->url_redirect = "index.php";
                $receiptAlert->save();
                $numChanged = 0;
            }
		}
    }


    // Submit the job
    // which will run in the background, not impeding user interactions
    public function setJob(SchedulersJob $job) {
        $this->job = $job;
    }
}

Required language files have been added.
The system does work; it is just the multiple Alerts that I cannot figure out.
The controller is initiated from a javascript function which is triggered by a custom menu added to the Bulk Action dropdown menu in FP_events.

the controller file is at

custom/modules/FP_events/controller.php

<?php

if(!defined('sugarEntry') || !sugarEntry) {
    die('Not A Valid Entry Point');
}

class CustomFP_eventsController extends SugarController
{
    public function action_getmatchingids() {

        $sourceKey = filter_var( $_COOKIE['source_key'] , FILTER_SANITIZE_SPECIAL_CHARS , FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_FLAG_STRIP_BACKTICK );
        // print "The Source is ". $sourceKey . "<br/>";
        $eventIdList = filter_var( $_REQUEST['uid'] , FILTER_SANITIZE_SPECIAL_CHARS , FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_FLAG_STRIP_BACKTICK );
        if ( !empty( $eventIdList )) {

			// This system will be storing thousands of records
			// and this logic hook will be looping through them one at a time
			// to calculate the totals for all associated records
			// Rather than run the code real-time inside this logic hook
			// which would take time and impact the user interaction
			// this logic hook creates a background job (Scheduled Task)
			// so the process works in the background
			// allowing the user interaction to proceed unaffected
			// while the code executes
			require_once 'include/SugarQueue/SugarJobQueue.php';
			$setSourceForLinkedRecords = new SchedulersJob();

			// Give it a name that will be clear in the Scheduler list what it is for
			$setSourceForLinkedRecords->name = "Set Source for Accounts and Contacts linked to this Event";

			// Jobs need an assigned user in order to run
			// I like to use the id of the person requesting the job
			global $current_user;
			$setSourceForLinkedRecords->assigned_user_id = $current_user->id;

			// Pass the information that our job will need
			$sourceKey = filter_var( $_COOKIE['source_key'] , FILTER_SANITIZE_SPECIAL_CHARS , FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_FLAG_STRIP_BACKTICK );
			$eventIdList = filter_var( $_REQUEST['uid'] , FILTER_SANITIZE_SPECIAL_CHARS , FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_FLAG_STRIP_BACKTICK );
			$setSourceForLinkedRecords->data = json_encode(array(
					'module' => 'FP_events',
					'id' => $eventIdList,
					'sourcekey' => $sourceKey,
				)
			);

			// Tell the scheduler what class to use
			$setSourceForLinkedRecords->target = "class::SetSourceForLinkedRecords";

			// Mark this job for 2 retries
			// This is optional and can be left out if not needed
			// I leave it in
			$setSourceForLinkedRecords->requeue = true;
			$setSourceForLinkedRecords->retry_count = 2;

			$jobQueue = new SugarJobQueue();
			$jobQueue->submitJob($setSourceForLinkedRecords);

        }

        SugarApplication::redirect('index.php?module=FP_events');
        // sugar_die('');
        return true;
    }
}

and the javascript file (called with an onclick in the html used for teh new menu) is at

custom/include/ayu_scripts/ayu_modal.js

/*
 * This script pops op a modal dialog window
 * to confirm that the user actually wants to do
 * what they have selected from the custom Set Source To: action menu choice
 * This extra strep of confirmation is utilized
 * since, unlike most menu selections which just present database contents
 * this menu selection will change the database records
 * Thanks to John Mertic and pgrod for their posts on this functionality
 */
var sugarListView;
var eventKey;
var eventName;
function displayDialog(args) {
    eventKey = args.eventKey;
    eventName = args.eventName;
    /* console.log('The Event Name is: ' + eventName); */
    let userClicked = confirm("Set " + eventName + " as a Source for all records of this event?");
    if(userClicked) {
        /* console.log("The user clicked OK"); */
        setSource(eventKey);
        sugarListView.get_checks();
        /* Object.keys(this).forEach((prop)=> console.log(prop)); */
        if(sugarListView.get_checks_count() < 1) {
            alert("{$app_strings['LBL_LISTVIEW_NO_SELECTED']}");
            return false;
        }
        document.MassUpdate.action.value='getmatchingids';
        document.MassUpdate.submit();
        return true;
    } else {
        /* console.log("The user clicked Cancel"); */
        return false;
    }
}

/*
 * This enables the javascript to pass data to the php script
 * This could have been done using AJAX but this is simple
 * and it keeps the interaction local
 */
function setSource(sourceKey) {
    document.cookie = 'source_key=' + sourceKey + '; SameSite="None;Secure"' + ' ; path=/';
    /* console.log("Set the source to: " + eventName); */
    return true;
}

The custom menu is added via a custom view.list file at

custom/modules/FP_events/views/view.list.php

<?php

if(!defined('sugarEntry') || !sugarEntry) {
    die('Not A Valid Entry Point');
}

require_once('include/ListView/ListViewSmarty.php');
require_once('include/MVC/View/views/view.list.php');

class CustomFP_EventsViewList extends ViewList
{
    /**
     * @see ViewList::preDisplay()
     */
    public function preDisplay()
    {
        parent::preDisplay();
        $this->lv = new ListViewSmarty();
        if( ACLController::checkAccess('FP_events', 'edit', true) ) {
            // Add a menu item
            $this->lv->actionsMenuExtraItems[] = $this->buildMyMenuItem();
        }
    }

    /**
     * @return string HTML
     */
    protected function buildMyMenuItem()
    {

        global $mod_strings;
        global $app_list_strings;

        // Be VERY careful in the use of nested quotes
        $htmlContent = <<<MENUCODE1
            <div
                id='confirm-set-source'
                onmouseover='document.getElementById("sourceSubMenu").style.display = "block";'
                onmouseout='document.getElementById("sourceSubMenu").style.display = "none";'>
                <a  class='menuItem'
                    style='width: 150px;'
                    href='#'
                    onmouseover='hiliteItem(this,"yes");'
                    onmouseout='unhiliteItem(this);'
                    >{$mod_strings['LBL_RC_NEWMENU']}
                </a>
                <ul
                    id='sourceSubMenu'
                    style='display: none; text-align: right;'>
        MENUCODE1;
        foreach($app_list_strings['funds_source_names_list'] as $sourceKey => $sourceName){
            // The first entry is blank to enable no selection, so skip it
            if( $sourceName != "" ){
                $htmlContent .= "    <li style='color: black;'\n";
                $htmlContent .= "        onmouseover='style=\"color: white;\";'\n";
                $htmlContent .= "        onmouseout='style=\"color: black;\";'\n";
                $htmlContent .= "        var eventKeyName = {'eventKey': \"{$sourceKey}\", 'eventName': \"{$sourceName}\"};\n";
                $htmlContent .= "        onclick='displayDialog( {\"eventKey\": \"{$sourceKey}\", \"eventName\": \"{$sourceName}\"} )'\n";
                $htmlContent .= "        >{$sourceName}&nbsp;&nbsp;&nbsp;&nbsp;\n";
                $htmlContent .= "    </li>\n";
            }
        }
        $htmlContent .= <<<MENUCODE2
        </ul>
        </div>
        <script src="custom/include/ayu_scripts/ayu_modal.js"></script>
        MENUCODE2;

        return $htmlContent;
    }
}

Any ideas?