diff --git a/wp-content/civi-extensions/goonjcustom/Civi/EventRegistrationService.php b/wp-content/civi-extensions/goonjcustom/Civi/EventRegistrationService.php new file mode 100644 index 000000000..b1508fc17 --- /dev/null +++ b/wp-content/civi-extensions/goonjcustom/Civi/EventRegistrationService.php @@ -0,0 +1,118 @@ + '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; + } + +} diff --git a/wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.ang.php b/wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.ang.php new file mode 100644 index 000000000..b9e9a5ec2 --- /dev/null +++ b/wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.ang.php @@ -0,0 +1,13 @@ + [ + 'ang/goonjEventRegistration.js', + ], + 'requires' => ['crmUi', 'crmUtil', 'api4'], + 'bundles' => ['bootstrap3'], + 'basePages' => [], +]; diff --git a/wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.js b/wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.js new file mode 100644 index 000000000..9bd153f56 --- /dev/null +++ b/wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.js @@ -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/', + 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 = + '
' + + '
' + + ' ' + + ' ' + + '

' + cfg.filterHelp + '

' + + '
' + + '
'; + // 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.$); diff --git a/wp-content/civi-extensions/goonjcustom/goonjcustom.php b/wp-content/civi-extensions/goonjcustom/goonjcustom.php index 98886dcde..3bb93ab5f 100644 --- a/wp-content/civi-extensions/goonjcustom/goonjcustom.php +++ b/wp-content/civi-extensions/goonjcustom/goonjcustom.php @@ -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 `/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(). * diff --git a/wp-content/civi-extensions/goonjcustom/settings/GoonjEventRegistration.setting.php b/wp-content/civi-extensions/goonjcustom/settings/GoonjEventRegistration.setting.php new file mode 100644 index 000000000..1925de7c1 --- /dev/null +++ b/wp-content/civi-extensions/goonjcustom/settings/GoonjEventRegistration.setting.php @@ -0,0 +1,76 @@ + [ + 'group_name' => 'Goonj Event Registration', + 'group' => 'goonj_event_registration', + 'name' => 'goonj_event_registration_success_url', + 'type' => 'String', + 'html_type' => 'text', + 'default' => '/event-sucess/', + 'add' => '6.0', + 'title' => 'Event Registration - Success redirect URL', + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => 'URL to redirect to after at least one Participant is created.', + 'help_text' => NULL, + ], + 'goonj_event_registration_duplicate_url' => [ + 'group_name' => 'Goonj Event Registration', + 'group' => 'goonj_event_registration', + 'name' => 'goonj_event_registration_duplicate_url', + 'type' => 'String', + 'html_type' => 'text', + 'default' => '/event-duplicate/', + 'add' => '6.0', + 'title' => 'Event Registration - Duplicate redirect URL', + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => 'URL to redirect to when every selected event was already registered.', + 'help_text' => NULL, + ], + 'goonj_event_registration_filter_label' => [ + 'group_name' => 'Goonj Event Registration', + 'group' => 'goonj_event_registration', + 'name' => 'goonj_event_registration_filter_label', + 'type' => 'String', + 'html_type' => 'text', + 'default' => 'Event Type', + 'add' => '6.0', + 'title' => 'Event Registration - Filter label', + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => 'Label for the Event Type filter dropdown.', + 'help_text' => NULL, + ], + 'goonj_event_registration_filter_help' => [ + 'group_name' => 'Goonj Event Registration', + 'group' => 'goonj_event_registration', + 'name' => 'goonj_event_registration_filter_help', + 'type' => 'String', + 'html_type' => 'text', + 'default' => 'Pick a type to filter the Event list below.', + 'add' => '6.0', + 'title' => 'Event Registration - Filter help text', + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => 'Help text shown under the Event Type filter.', + 'help_text' => NULL, + ], + 'goonj_event_registration_all_types_label' => [ + 'group_name' => 'Goonj Event Registration', + 'group' => 'goonj_event_registration', + 'name' => 'goonj_event_registration_all_types_label', + 'type' => 'String', + 'html_type' => 'text', + 'default' => 'All types', + 'add' => '6.0', + 'title' => 'Event Registration - All types label', + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => 'Label for the default option in the Event Type filter.', + 'help_text' => NULL, + ], +];