-
Notifications
You must be signed in to change notification settings - Fork 1
multi event #1657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tarunnjoshi
wants to merge
1
commit into
dev
Choose a base branch
from
Multi-Event
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
multi event #1657
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
118 changes: 118 additions & 0 deletions
118
wp-content/civi-extensions/goonjcustom/Civi/EventRegistrationService.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| } |
13 changes: 13 additions & 0 deletions
13
wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.ang.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
177
wp-content/civi-extensions/goonjcustom/ang/goonjEventRegistration.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/', | ||
| 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.$); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
📝 Committable suggestion
🤖 Prompt for AI Agents