Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

namespace Civi;

use Civi\Api4\Email;
use Civi\Api4\Participant;
use Civi\Core\Service\AutoSubscriber;

/**
* Behavior for the Goonj Event Registration Afform
* (afformGoonjEventRegistration).
*
* Two responsibilities:
*
* 1. Contact matching — Afform's `dedupe-rules` attribute only drives prefill
* lookup, not save-time matching. Without this service every submission
* would create a fresh Contact row and the duplicate-Participant check
* below would never fire. We match by primary email and set the id so
* Afform updates the existing Contact instead of creating a new one.
*
* 2. Duplicate-Participant skip — CiviCRM has no UNIQUE(contact_id, event_id)
* constraint. The hook_civicrm_pre here throws on a duplicate (contact,
* event) pair; Afform's Submit catches the exception per-entity and
* silently drops that Participant from the results. The client-side
* directive inspects the response: if every Participant was dropped it
* redirects to the duplicate page; otherwise the Afform's own redirect
* (success page) fires.
*/
class EventRegistrationService extends AutoSubscriber {

const AFFORM_NAME = 'afformGoonjEventRegistration';

/**
* Tracks (contact_id:event_id) pairs seen within a single request so we
* also catch in-batch duplicates (user clicks "Add another event" and
* picks the same event twice).
*/
private static array $seenPairsThisRequest = [];

public static function getSubscribedEvents() {
return [
'&hook_civicrm_pre' => 'onParticipantPre',
'civi.afform.submit' => ['onAfformSubmit', 5],
];
}

/**
* Match existing Contact by primary email before save and reset the
* in-request dedupe tracker for each fresh submission.
*/
public static function onAfformSubmit(\Civi\Afform\Event\AfformSubmitEvent $event): void {
$afform = $event->getAfform();
if (($afform['name'] ?? NULL) !== self::AFFORM_NAME) {
return;
}
if ($event->getEntityName() !== 'Individual1') {
return;
}

self::$seenPairsThisRequest = [];
$records =& $event->records;
foreach ($records as &$record) {
$email = trim($record['fields']['email_primary.email'] ?? '');
if (empty($record['fields']['id']) && $email) {
$match = Email::get(FALSE)
->addWhere('email', '=', $email)
->addWhere('is_primary', '=', TRUE)
->addWhere('contact_id.is_deleted', '=', FALSE)
->addOrderBy('contact_id', 'ASC')
->addSelect('contact_id')
->setLimit(1)
->execute()
->first();
if (!empty($match['contact_id'])) {
$record['fields']['id'] = $match['contact_id'];
}
}
}
}

/**
* Block duplicate Participant inserts. Applies to every Participant create
* path (Afform, admin UI, API) — defense in depth beyond the Afform-only
* logic above. Afform's Submit catches the exception and drops just that
* Participant; non-Afform paths will see the exception as usual.
*/
public static function onParticipantPre($op, $objectName, $id, &$params): void {
if ($objectName !== 'Participant' || $op !== 'create') {
return;
}
$contactId = (int) ($params['contact_id'] ?? 0);
$eventId = (int) ($params['event_id'] ?? 0);
if (!$contactId || !$eventId) {
return;
}

$pairKey = $contactId . ':' . $eventId;
if (isset(self::$seenPairsThisRequest[$pairKey])) {
throw new \CRM_Core_Exception('duplicate_participant_in_batch');
}

$existing = Participant::get(FALSE)
->addWhere('contact_id', '=', $contactId)
->addWhere('event_id', '=', $eventId)
->addWhere('status_id:name', 'NOT IN', ['Cancelled', 'Rejected'])
->addSelect('id')
->setLimit(1)
->execute()
->first();

if ($existing) {
throw new \CRM_Core_Exception('duplicate_participant_exists');
}

self::$seenPairsThisRequest[$pairKey] = TRUE;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php
// Small Angular module that provides the Event-Type cascading filter used by
// the Afform `afformGoonjEventRegistration`. The Afform itself (layout +
// SavedSearch + SearchDisplay) lives in the admin/database layer and is edited
// via FormBuilder. This module is the minimum that FormBuilder cannot express.
return [
'js' => [
'ang/goonjEventRegistration.js',
],
'requires' => ['crmUi', 'crmUtil', 'api4'],
'bundles' => ['bootstrap3'],
'basePages' => [],
];
177 changes: 177 additions & 0 deletions wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
(function(angular, $) {
'use strict';

angular.module('goonjEventRegistration', CRM.angRequires('goonjEventRegistration'));

// Afform this module targets. Used for the success/duplicate redirect check.
var AFFORM_NAME = 'afformGoonjEventRegistration';
// Participant fieldset we inject the Event Type filter above. Matched by
// af-fieldset name so the attach logic survives any FormBuilder edit that
// keeps the fieldset (rename, reorder, add more af-fields, etc).
var FIELDSET_SELECTOR = 'fieldset[af-fieldset="Participant1"]';

// Tiny settings wrapper so admins can override URLs + labels from CRM
// Settings without touching code. Defaults come from the vars bundle
// (crmBundle('afform')) populated by our PHP hook; fallbacks keep the form
// working even if settings are unset.
function getConfig() {
var cfg = (CRM && CRM.goonjEventRegistration) || {};
return {
successUrl: cfg.successUrl || '/event-sucess/',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Same typo in fallback URL.

The fallback '/event-sucess/' should be '/event-success/' to match the corrected PHP default. Both locations need the fix.

📝 Proposed fix
-      successUrl: cfg.successUrl || '/event-sucess/',
+      successUrl: cfg.successUrl || '/event-success/',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
successUrl: cfg.successUrl || '/event-sucess/',
successUrl: cfg.successUrl || '/event-success/',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.js` at line
20, The fallback URL string for the successUrl is misspelled; in the config
assignment where successUrl is set (using cfg.successUrl || '/event-sucess/'),
change the hard-coded fallback to '/event-success/' so it matches the corrected
PHP default; update this exact occurrence and any other similar occurrences that
use the same fallback pattern (look for successUrl or cfg.successUrl usages) to
ensure consistency.

duplicateUrl: cfg.duplicateUrl || '/event-duplicate/',
filterLabel: cfg.filterLabel || 'Event Type',
filterHelp: cfg.filterHelp || 'Pick a type to filter the Event list below.',
allTypesLabel: cfg.allTypesLabel || 'All types'
};
}

// Inject the Event Type filter above the Participant1 fieldset and wire it
// into each event_id autocomplete. Pulled out of the old attribute directive
// so the run() block below can call it without any markup trigger — admins
// cannot accidentally delete a filter that has no representation in the
// Afform layout.
function attachFilter(scope, fieldsetEl, $timeout, $compile, crmApi4) {
var cfg = getConfig();
var elem = angular.element(fieldsetEl);

scope.gcFilterLabel = cfg.filterLabel;
scope.gcFilterHelp = cfg.filterHelp;
scope.gcEventTypeFilter = '';
scope.gcEventTypes = [{value: '', label: cfg.allTypesLabel}];

// Fetch Event Type options live; no hardcoded values. The Event
// pseudoconstant resolves to the in-DB option_group — whatever admins
// configure there shows up here on next page load.
crmApi4('OptionValue', 'get', {
select: ['value', 'label'],
where: [['option_group_id:name', '=', 'event_type'], ['is_active', '=', true]],
orderBy: {weight: 'ASC'}
}).then(function(rows) {
var fetched = (rows || []).map(function(r) {
return {value: String(r.value), label: r.label};
});
scope.gcEventTypes = [{value: '', label: cfg.allTypesLabel}].concat(fetched);
});

var filterHtml =
'<fieldset class="af-container gc-event-type-filter-injected" af-title="' + cfg.filterLabel + '">' +
' <div class="crm-af-field">' +
' <label>' + cfg.filterLabel + '</label>' +
' <select class="crm-form-select form-control" ng-model="gcEventTypeFilter" ' +
' ng-options="o.value as o.label for o in gcEventTypes"></select>' +
' <p class="help-block">' + cfg.filterHelp + '</p>' +
' </div>' +
'</fieldset>';
// Compile against the fieldset's scope so ng-model / ng-options bind,
// otherwise the injected HTML is inert (Angular only compiles nodes
// present at link time).
var compiled = $compile(filterHtml)(scope);
elem.before(compiled);

// Patch each event_id af-field controller's getAutocompleteParams so
// the Event Type filter flows into the server request as a filter.
var patched = new WeakSet();
function patchAfFields() {
angular.forEach(fieldsetEl.querySelectorAll('af-field'), function(afFieldEl) {
var afFieldScope = angular.element(afFieldEl).isolateScope() || angular.element(afFieldEl).scope();
if (!afFieldScope || !afFieldScope.$ctrl || patched.has(afFieldScope.$ctrl)) {
return;
}
var ctrl = afFieldScope.$ctrl;
if (ctrl.fieldName !== 'event_id' || typeof ctrl.getAutocompleteParams !== 'function') {
return;
}
var original = ctrl.getAutocompleteParams.bind(ctrl);
ctrl.getAutocompleteParams = function() {
var params = original() || {};
if (scope.gcEventTypeFilter) {
params.filters = angular.extend({}, params.filters || {}, {
event_type_id: parseInt(scope.gcEventTypeFilter, 10)
});
}
return params;
};
patched.add(ctrl);
});
}

$timeout(patchAfFields, 300);
scope.$watch(function() {
return fieldsetEl.querySelectorAll('af-field').length;
}, function() {
$timeout(patchAfFields, 50);
});

// Clear selected events whenever the filter changes so users never
// submit with a stale event that doesn't match the newly picked type.
scope.$watch('gcEventTypeFilter', function(newVal, oldVal) {
if (newVal === oldVal) {
return;
}
var afCtrl = angular.element(document.querySelector('af-form')).controller('afForm');
if (afCtrl && afCtrl.data && afCtrl.data.Participant1) {
afCtrl.data.Participant1.forEach(function(p) {
if (p && p.fields) {
p.fields.event_id = null;
}
});
}
$(fieldsetEl).find('input.crm-ajax-select').each(function() {
if ($(this).data('select2')) {
$(this).select2('data', null);
}
});
});
}

// Auto-attach on page load. The module is only loaded on Afforms that list
// goonjEventRegistration in their `requires`, so finding a Participant1
// fieldset is sufficient — no markup attribute to accidentally delete.
// Retries briefly while Afform finishes its own compile pass.
angular.module('goonjEventRegistration').run(function($timeout, $compile, crmApi4) {
function tryAttach(attempt) {
attempt = attempt || 0;
if (attempt > 40) {
return;
}
var fieldset = document.querySelector(FIELDSET_SELECTOR);
if (!fieldset) {
return $timeout(function() { tryAttach(attempt + 1); }, 250);
}
if (fieldset.__gcFilterAttached) {
return;
}
var scope = angular.element(fieldset).scope();
if (!scope) {
return $timeout(function() { tryAttach(attempt + 1); }, 250);
}
fieldset.__gcFilterAttached = true;
attachFilter(scope, fieldset, $timeout, $compile, crmApi4);
}
angular.element(document).ready(function() {
$timeout(function() { tryAttach(0); }, 100);
});
});

// Hook into Afform's crmFormSuccess event — fires after server returns.
// If the server returned an empty Participant1 array it means every
// selected event was a duplicate (the Participant hook silently skipped
// them). In that case we override Afform's own redirect target.
//
// Why mutate data.afform.redirect instead of setting window.location.href:
// Afform's postProcess() fires this event and THEN does its own
// `window.location.href = metaData.redirect` a few lines later, which
// would clobber our assignment (both are synchronous). `data.afform` is
// the same object reference as metaData, so rewriting its redirect field
// here makes Afform's own logic navigate to our duplicate URL instead.
$(document).on('crmFormSuccess.goonjEventReg', function(evt, data) {
if (!data || !data.afform || data.afform.name !== AFFORM_NAME) {
return;
}
var resp = (data.submissionResponse && data.submissionResponse[0]) || {};
var created = (resp.Participant1 || []).length;
if (created === 0) {
data.afform.redirect = getConfig().duplicateUrl;
}
});
})(angular, CRM.$);
62 changes: 62 additions & 0 deletions wp-content/civi-extensions/goonjcustom/goonjcustom.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,68 @@ function goonjcustom_civicrm_install(): void {
_goonjcustom_civix_civicrm_install();
}

/**
* Implements hook_civicrm_alterSettingsFolders().
*
* Registers this extension's settings folder so the files under settings/*.setting.php
* are discovered by CiviCRM's metadata loader. Admins can then change redirect URLs
* and filter labels for the Event Registration Afform without touching code.
*/
function goonjcustom_civicrm_alterSettingsFolders(&$metaDataFolders = NULL): void {
$folder = __DIR__ . '/settings';
if (is_dir($folder) && !in_array($folder, (array) $metaDataFolders, TRUE)) {
$metaDataFolders[] = $folder;
}
}

/**
* Implements hook_civicrm_alterBundle().
*
* Exposes Event Registration config (success/duplicate URLs, labels) to the
* client via CRM.goonjEventRegistration, so the Angular directive reads live
* values without a second round-trip. If settings are unset, we rely on the
* directive's hardcoded fallbacks (kept only as a safety net).
*/
function goonjcustom_civicrm_alterBundle(\CRM_Core_Resources_Bundle $bundle): void {
if (($bundle->name ?? '') !== 'coreResources') {
return;
}
try {
$settings = Civi::settings();
$bundle->addVars('goonjEventRegistration', [
'successUrl' => $settings->get('goonj_event_registration_success_url'),
'duplicateUrl' => $settings->get('goonj_event_registration_duplicate_url'),
'filterLabel' => $settings->get('goonj_event_registration_filter_label'),
'filterHelp' => $settings->get('goonj_event_registration_filter_help'),
'allTypesLabel' => $settings->get('goonj_event_registration_all_types_label'),
]);
}
catch (\Throwable $e) {
// Don't break the page if settings aren't available yet (e.g. during
// upgrade). The directive has safe fallbacks.
}
}

/**
* Implements hook_civicrm_angularModules().
*
* Auto-discovers Angular modules shipped as `<extension>/ang/*.ang.php` so
* civix-generated extensions can use them without hand-registering each file.
*/
function goonjcustom_civicrm_angularModules(&$angularModules): void {
$dir = __DIR__ . '/ang';
if (!is_dir($dir)) {
return;
}
foreach (glob($dir . '/*.ang.php') as $file) {
$moduleName = basename($file, '.ang.php');
$angularModules[$moduleName] = include $file;
$angularModules[$moduleName]['ext'] = 'goonjcustom';
}
}



/**
* Implements hook_civicrm_enable().
*
Expand Down
Loading