diff --git a/.github/workflows/codeception.yml b/.github/workflows/codeception.yml index 83554dec..d71436bf 100644 --- a/.github/workflows/codeception.yml +++ b/.github/workflows/codeception.yml @@ -45,6 +45,9 @@ jobs: - pimcore: ~11.2.0 template_tag: v11.0.0 steps: + - uses: nanasess/setup-chromedriver@v2 + with: + chromedriver-version: '127.0.6533.119' - uses: actions/checkout@v4 with: path: lib/test-bundle @@ -98,7 +101,9 @@ jobs: - name: Setup Chromium run: | - nohup $CHROMEWEBDRIVER/chromedriver --url-base=/wd/hub /dev/null 2>&1 & + export DISPLAY=:99 + chromedriver --url-base=/wd/hub & + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & # optional - name: Start Symfony Server run: | diff --git a/README.md b/README.md index ea8abf29..4f738975 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ | Release | Supported Pimcore Versions | Supported Symfony Versions | Release Date | Maintained | Branch | |---------|-----------------------------------|----------------------------|--------------|----------------|----------------------------------------------------------------------------------| -| **5.x** | `11.0` | `6.2` | 18.10.2023 | Feature Branch | master | +| **5.x** | `11.0` | `^6.2` | 18.10.2023 | Feature Branch | master | | **4.x** | `10.5`, `10.6` | `^5.4` | 13.10.2021 | Unsupported | [4.x](https://github.com/dachcom-digital/pimcore-formbuilder/tree/4.x) | | **3.x** | `6.0` - `6.9` | `3.4`, `^4.4` | 17.07.2019 | Unsupported | [3.x](https://github.com/dachcom-digital/pimcore-formbuilder/tree/3.x) | | **2.7** | `5.4`, `5.5`, `5.6`, `5.7`, `5.8` | `3.4` | 27.06.2019 | Unsupported | [2.7](https://github.com/dachcom-digital/pimcore-formbuilder/tree/2.7) | diff --git a/UPGRADE.md b/UPGRADE.md index 868535e8..2a2746f5 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,7 +1,10 @@ # Upgrade Notes -## 5.1.1 +## 5.1.2 - **[IMPROVEMENT]** [File Upload] Add Deeplink Option to file upload field [#475](https://github.com/dachcom-digital/pimcore-formbuilder/issues/475) +- **[BUGFIX]** [Double-Opt-In] Remove unique index, allow admin to define unique data behavior [#477](https://github.com/dachcom-digital/pimcore-formbuilder/issues/477) +- **[IMPROVEMENT]** [Double-Opt-In] Allow to list and manage active sessions [#478](https://github.com/dachcom-digital/pimcore-formbuilder/issues/478) +- **[IMPROVEMENT]** [Double-Opt-In] Pass data to email channel and store double-opt-in main data in mail params [#479](https://github.com/dachcom-digital/pimcore-formbuilder/issues/479) ## 5.1.1 - **[BUGFIX]** Fix Migration and Installer diff --git a/config/doctrine/model/DoubleOptInSession.orm.yml b/config/doctrine/model/DoubleOptInSession.orm.yml index c19c79a5..ab5aff9c 100644 --- a/config/doctrine/model/DoubleOptInSession.orm.yml +++ b/config/doctrine/model/DoubleOptInSession.orm.yml @@ -43,7 +43,4 @@ FormBuilderBundle\Model\DoubleOptInSession: joinColumn: name: form_definition referencedColumnName: id - onDelete: CASCADE - uniqueConstraints: - email_form_definition: - columns: [email, form_definition, applied] \ No newline at end of file + onDelete: CASCADE \ No newline at end of file diff --git a/config/install/sql/install.sql b/config/install/sql/install.sql index e95efd8a..d6eadcd2 100644 --- a/config/install/sql/install.sql +++ b/config/install/sql/install.sql @@ -46,7 +46,6 @@ CREATE TABLE IF NOT EXISTS `formbuilder_double_opt_in_session` ( `dispatch_location` longtext NULL, `applied` tinyint(1) DEFAULT 0 NOT null, `creationDate` datetime NOT NULL, - CONSTRAINT email_form_definition UNIQUE (email, form_definition, applied), CONSTRAINT FK_88815C4F61F7634C FOREIGN KEY (form_definition) REFERENCES formbuilder_forms (id) ON DELETE CASCADE ); diff --git a/config/install/translations/admin.csv b/config/install/translations/admin.csv index 37465472..9185a64f 100644 --- a/config/install/translations/admin.csv +++ b/config/install/translations/admin.csv @@ -137,6 +137,10 @@ "form_builder.mail_editor.widget_provider.form_fields.repeater_block_label","Block Label","Block Label" "form_builder.mail_editor.widget_provider.date.date","Date","Datum" "form_builder.mail_editor.widget_provider.date.date_format","Date Format","Datum-Format" +"form_builder.mail_editor.widget_provider.double_opt_in_session","Double-Opt-In Session","Double-Opt-In Session" +"form_builder.mail_editor.widget_provider.double_opt_in_session.email","Email","E-Mail" +"form_builder.mail_editor.widget_provider.double_opt_in_session.additional_data","Additional Data","Zusätzliche Daten" +"form_builder.mail_editor.widget_provider.double_opt_in_session.additional_data_field","Field","Feld" "form_builder_type_field.date_html5","use HTML5 type", "HTML5-Typ benutzen" "form_builder.output_workflow.channel.email","Email Channel", "E-Mail Channel" "form_builder.output_workflow.channel.object","Object Channel", "Object Channel" diff --git a/config/pimcore/routing.yaml b/config/pimcore/routing.yaml index ccc9992f..b4d81d7e 100644 --- a/config/pimcore/routing.yaml +++ b/config/pimcore/routing.yaml @@ -48,10 +48,24 @@ form_builder.controller.admin.get_preset_description: path: /admin/formbuilder/settings/get-preset-description/{name} defaults: { _controller: FormBuilderBundle\Controller\Admin\SettingsController::getPresetDescriptionAction } -form_builder.controller.admin.getData_injection_store: +form_builder.controller.admin.get_data_injection_store: path: /admin/formbuilder/settings/get-data-injection-store defaults: { _controller: FormBuilderBundle\Controller\Admin\SettingsController::getDataInjectionStoreAction } +# Double-Opt-In +form_builder.controller.admin.get_double_opt_in_session: + path: /admin/formbuilder/settings/double-opt-in/sessions/{formId} + defaults: { _controller: FormBuilderBundle\Controller\Admin\SettingsController::getDoubleOptInSessionsAction } + options: + expose: true + +form_builder.controller.admin.delete_double_opt_in_session: + path: /admin/formbuilder/settings/double-opt-in/delete/{token} + defaults: { _controller: FormBuilderBundle\Controller\Admin\SettingsController::deleteDoubleOptInSessionAction } + methods: [DELETE] + options: + expose: true + # Output Workflows form_builder.controller.admin.output_workflow.get_output_workflow_tree: path: /admin/formbuilder/output-workflow/get-output-workflow-tree/{formId} diff --git a/config/serialization/DoubleOptInSession.yaml b/config/serialization/DoubleOptInSession.yaml new file mode 100644 index 00000000..95728823 --- /dev/null +++ b/config/serialization/DoubleOptInSession.yaml @@ -0,0 +1,16 @@ +FormBuilderBundle\Model\DoubleOptInSession: + attributes: + token: + groups: [ Default, ExtJs ] + formDefinition: + groups: [ Default ] + email: + groups: [ Default, ExtJs ] + additionalData: + groups: [ Default, ExtJs ] + dispatchLocation: + groups: [ Default, ExtJs ] + applied: + groups: [ Default, ExtJs ] + creationDate: + groups: [ Default, ExtJs ] diff --git a/config/services/double_opt_in.yaml b/config/services/double_opt_in.yaml deleted file mode 100644 index cdc89d86..00000000 --- a/config/services/double_opt_in.yaml +++ /dev/null @@ -1,6 +0,0 @@ -services: - - _defaults: - autowire: true - autoconfigure: true - public: true diff --git a/config/services/double_opt_in/services.yaml b/config/services/double_opt_in/services.yaml new file mode 100644 index 00000000..33b9b3c1 --- /dev/null +++ b/config/services/double_opt_in/services.yaml @@ -0,0 +1,14 @@ +services: + + _defaults: + autowire: true + autoconfigure: true + public: true + + FormBuilderBundle\MailEditor\Widget\DoubleOptInSessionEmailWidget: + tags: + - { name: form_builder.mail_editor.widget, type: double_opt_in_session_email } + + FormBuilderBundle\MailEditor\Widget\DoubleOptInSessionAdditionalDataWidget: + tags: + - { name: form_builder.mail_editor.widget, type: double_opt_in_session_additional_data } \ No newline at end of file diff --git a/docs/04_DoubleOptIn.md b/docs/04_DoubleOptIn.md index 52e8d030..2f33e57f 100644 --- a/docs/04_DoubleOptIn.md +++ b/docs/04_DoubleOptIn.md @@ -36,4 +36,27 @@ Additional Info: ## Trash-Mail Protection The `EmailChecker` Validator is automatically appended to the `emailAddress` field. -This validator only triggers, if you've configured at least one email checker service - read more about it [here](./docs/03_SpamProtection.md#email-checker) \ No newline at end of file +This validator only triggers, if you've configured at least one email checker service - read more about it [here](./docs/03_SpamProtection.md#email-checker) + +## Templating +Based on given output workflow, you may want to use the double opt in data in given channel: + +### E-Mail Channel +If DOI is active, the submitted mail object will receive two additional parameters: +- _form_builder_double_opt_in_token +- _form_builder_double_opt_in_session_email + +#### E-Mail Data Template +DOI information can't be rendered by default since the rendering heavily depends on your implementation. +Checkout out this [part](https://github.com/dachcom-digital/pimcore-formbuilder/blob/a9da6dada95274049d07f920999b57dfc0c9b462/templates/email/form_data.html.twig#L57-L74) in `templates/email/form_data.html.twig`, +to show DOI data within your submitted mail data. + +#### E-Mail Editor +![image](https://github.com/user-attachments/assets/30f209a1-231a-4511-bdf9-0c6ccef423d3) +Use the additional fields on the right side to add DOI information to the mail template editor. + +### Object Channel +Currently not implemented + +### API Channel +Currently not implemented diff --git a/public/js/extjs/_form/tab/configPanel.js b/public/js/extjs/_form/tab/configPanel.js index 7ed0124c..504e5fdc 100755 --- a/public/js/extjs/_form/tab/configPanel.js +++ b/public/js/extjs/_form/tab/configPanel.js @@ -725,6 +725,7 @@ Formbuilder.extjs.formPanel.config = Class.create({ fieldLabel: t('form_builder_form.double_opt_in.enable'), inputValue: true, uncheckedValue: false, + labelWidth: 200, value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.enabled : false, listeners: { change: function (cb, value) { @@ -740,6 +741,9 @@ Formbuilder.extjs.formPanel.config = Class.create({ { xtype: 'container', hidden: !this.formConfig.doubleOptIn || this.formConfig.doubleOptIn.enabled === false, + defaults: { + labelWidth: 200 + }, items: [ { fieldLabel: false, @@ -747,6 +751,14 @@ Formbuilder.extjs.formPanel.config = Class.create({ style: 'display:block !important; margin-bottom:15px !important; font-weight: 300;', value: t('form_builder_form.double_opt_in.description') }, + { + xtype: 'checkbox', + name: 'doubleOptIn.allowMultipleUserSessions', + fieldLabel: t('form_builder_form.double_opt_in.allow_multiple_user_sessions'), + inputValue: true, + uncheckedValue: false, + value: this.formConfig.allowMultipleUserSessions ? this.formConfig.doubleOptIn.allowMultipleUserSessions : true, + }, { xtype: 'textfield', name: 'doubleOptIn.instructionNote', @@ -765,7 +777,13 @@ Formbuilder.extjs.formPanel.config = Class.create({ width: '100%', inputAttrTpl: ' data-qwidth="250" data-qalign="br-r?" data-qtrackMouse="false" data-qtip="' + t('form_builder_type_field_base.translatable_field') + '"', }, - doubleOptInLocalizedField.getField() + doubleOptInLocalizedField.getField(), + { + xtype: 'button', + text: t('form_builder_form.double_opt_in.show_sessions'), + iconCls: 'pimcore_icon_export', + handler: this.showFormDoubleOptInData.bind(this) + } ] } ] @@ -924,9 +942,10 @@ Formbuilder.extjs.formPanel.config = Class.create({ return toolbar; }, - /** - * Display info window with current form meta information - */ + showFormDoubleOptInData: function() { + new Formbuilder.extjs.extensions.formDoubleOptInData(this.formId, this.formConfig.doubleOptIn); + }, + showFormMetaInfo: function () { new Formbuilder.extjs.extensions.formMetaData(this.formId, this.formMeta); }, diff --git a/public/js/extjs/extensions/formDoubleOptInData.js b/public/js/extjs/extensions/formDoubleOptInData.js new file mode 100644 index 00000000..568c6b87 --- /dev/null +++ b/public/js/extjs/extensions/formDoubleOptInData.js @@ -0,0 +1,188 @@ +pimcore.registerNS('Formbuilder.extjs.extensions.formDoubleOptInData'); +Formbuilder.extjs.extensions.formDoubleOptInData = Class.create({ + + formId: null, + data: {}, + + detailWindow: null, + + initialize: function (formId, data) { + this.formId = formId; + this.data = data; + this.getInputWindow(); + this.detailWindow.show(); + }, + + getInputWindow: function () { + + if (this.detailWindow !== null) { + return this.detailWindow; + } + + this.detailWindow = new Ext.Window({ + width: 800, + height: 600, + iconCls: 'pimcore_icon_info', + layout: 'fit', + closeAction: 'close', + plain: true, + autoScroll: true, + modal: true, + buttons: [ + { + text: t('close'), + iconCls: 'pimcore_icon_empty', + handler: function () { + this.detailWindow.hide(); + this.detailWindow.destroy(); + }.bind(this) + } + ] + }); + + this.createPanel(); + }, + + createPanel: function () { + + var items = [], + itemsPerPage = pimcore.helpers.grid.getDefaultPageSize(-1), + sessionsStore, sessionsGrid, sessionsPanel; + + sessionsStore = new Ext.data.Store({ + pageSize: itemsPerPage, + proxy: { + type: 'ajax', + url: Routing.generate('form_builder.controller.admin.get_double_opt_in_session', {formId: this.formId}), + reader: { + type: 'json', + rootProperty: 'sessions' + } + }, + autoLoad: false, + fields: ['token', 'email', 'dispatchLocation', 'applied', 'creationDate'] + }); + + sessionsGrid = new Ext.grid.GridPanel({ + store: sessionsStore, + columns: [ + { + text: 'Token', + sortable: false, + dataIndex: 'token', + flex: 1, + hidden: false + }, + { + text: t('form_builder_form.double_opt_in.sessions.email'), + sortable: false, + dataIndex: 'email', + flex: 2, + hidden: false + }, + { + text: t('form_builder_form.double_opt_in.sessions.creation_date'), + sortable: false, + dataIndex: 'creationDate', + flex: 1, + hidden: false, + renderer: function (v) { + + if (!v) { + return '--'; + } + + return Ext.util.Format.date(v, 'd.m.Y H:i'); + } + }, + { + text: t('form_builder_form.double_opt_in.sessions.applied'), + sortable: false, + dataIndex: 'applied', + flex: 1, + hidden: false + }, + { + text: t('form_builder_form.double_opt_in.sessions.dispatch_location'), + sortable: false, + dataIndex: 'dispatchLocation', + flex: 1, + hidden: true, + }, + { + xtype: 'actioncolumn', + width: 30, + items: [{ + tooltip: t('remove'), + icon: '/bundles/toolbox/images/admin/delete.svg', + handler: function (grid, rowIndex) { + + var rec = grid.getStore().getAt(rowIndex); + + Ext.Msg.confirm(t('delete'), t('form_builder_form.double_opt_in.sessions.delete_confirm'), function (btn) { + + if (btn !== 'yes') { + return; + } + + Ext.Ajax.request({ + method: 'DELETE', + url: Routing.generate('form_builder.controller.admin.delete_double_opt_in_session', {token: rec.get('token')}), + success: function (response) { + + var data = Ext.decode(response.responseText); + + if (!data.success) { + Ext.Msg.alert(t('error'), data.message); + + return; + } + + grid.getStore().reload(); + }, + failure: function () { + Ext.Msg.alert(t('error'), t('error')); + } + }); + + }.bind(this)); + }.bind(this) + }] + } + ], + flex: 1, + columnLines: true, + stripeRows: true, + bbar: pimcore.helpers.grid.buildDefaultPagingToolbar(sessionsStore, {pageSize: itemsPerPage}) + }); + + sessionsStore.load(); + + sessionsPanel = new Ext.Panel({ + title: t('form_builder_form.double_opt_in.sessions'), + flex: 1, + layout: { + type: 'vbox', + align: 'stretch' + }, + resizable: false, + split: false, + collapsible: false, + items: [sessionsGrid] + }); + + items.push(sessionsPanel); + + this.detailWindow.add(new Ext.form.FormPanel({ + border: false, + frame: false, + bodyStyle: 'padding:10px', + items: items, + defaults: { + labelWidth: 130 + }, + collapsible: false, + autoScroll: true + })); + } +}); \ No newline at end of file diff --git a/src/Controller/Admin/SettingsController.php b/src/Controller/Admin/SettingsController.php index ab69df69..ed616094 100644 --- a/src/Controller/Admin/SettingsController.php +++ b/src/Controller/Admin/SettingsController.php @@ -2,27 +2,35 @@ namespace FormBuilderBundle\Controller\Admin; +use Doctrine\ORM\Tools\Pagination\Paginator; use FormBuilderBundle\Builder\ExtJsFormBuilder; use FormBuilderBundle\Configuration\Configuration; -use FormBuilderBundle\Form\DataInjector\DataInjectorInterface; +use FormBuilderBundle\Manager\DoubleOptInManager; use FormBuilderBundle\Manager\FormDefinitionManager; use FormBuilderBundle\Manager\PresetManager; +use FormBuilderBundle\Model\DoubleOptInSessionInterface; use FormBuilderBundle\Registry\ChoiceBuilderRegistry; use FormBuilderBundle\Model\FormDefinitionInterface; use FormBuilderBundle\Registry\DataInjectionRegistry; +use FormBuilderBundle\Repository\DoubleOptInSessionRepositoryInterface; use FormBuilderBundle\Tool\FormDependencyLocator; use Pimcore\Bundle\AdminBundle\Controller\AdminAbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; class SettingsController extends AdminAbstractController { public function __construct( protected Configuration $configuration, protected FormDefinitionManager $formDefinitionManager, + protected DoubleOptInManager $doubleOptInManager, protected ExtJsFormBuilder $extJsFormBuilder, protected ChoiceBuilderRegistry $choiceBuilderRegistry, - protected FormDependencyLocator $formDependencyLocator + protected FormDependencyLocator $formDependencyLocator, + protected DoubleOptInSessionRepositoryInterface $doubleOptInSessionRepository, + private readonly SerializerInterface $serializer, ) { } @@ -298,6 +306,53 @@ public function getDataInjectionStoreAction(Request $request, DataInjectionRegis ); } + public function getDoubleOptInSessionsAction(Request $request, int $formId): JsonResponse + { + $offset = (int) $request->get('start', 0); + $limit = (int) $request->get('limit', 25); + + $qb = $this->doubleOptInSessionRepository->getQueryBuilder(); + + $qb->where('s.formDefinition = :formDefinition'); + $qb->setParameter('formDefinition', $formId); + + $qb->setMaxResults($limit); + $qb->setFirstResult($offset); + + $paginator = new Paginator($qb); + + return $this->json( + [ + 'success' => true, + 'total' => $paginator->count(), + 'sessions' => $this->serializer instanceof NormalizerInterface + ? $this->serializer->normalize( + iterator_to_array($paginator->getIterator()), + 'array', + ['groups' => ['ExtJs']] + ) + : [] + ] + ); + } + + public function deleteDoubleOptInSessionAction(string $token): JsonResponse + { + $doubleOptInSession = $this->doubleOptInSessionRepository->find($token); + + if (!$doubleOptInSession instanceof DoubleOptInSessionInterface) { + return $this->json([ + 'success' => false, + 'message' => sprintf('Session %s not found', $token) + ] + ); + } + + $this->doubleOptInManager->deleteDoubleOptInSession($doubleOptInSession); + + return $this->json(['success' => true]); + } + private function getSaveName(string $name): string { return (string) preg_replace('/[^A-Za-z0-9aäüöÜÄÖß \-]/', '', $name); diff --git a/src/DependencyInjection/FormBuilderExtension.php b/src/DependencyInjection/FormBuilderExtension.php index e2eb46ba..158ee26e 100644 --- a/src/DependencyInjection/FormBuilderExtension.php +++ b/src/DependencyInjection/FormBuilderExtension.php @@ -44,6 +44,10 @@ public function load(array $configs, ContainerBuilder $container): void $loader = new YamlFileLoader($container, new FileLocator([__DIR__ . '/../../config'])); $loader->load('services.yaml'); + if ($config['double_opt_in']['enabled'] === true) { + $loader->load('services/double_opt_in/services.yaml'); + } + $conditionalLogicDefinition = $container->getDefinition(ConditionalLogicRegistry::class); foreach ($config['conditional_logic']['action'] as $identifier => $action) { diff --git a/src/Event/SubmissionEvent.php b/src/Event/SubmissionEvent.php index 6b50b70d..5d750344 100644 --- a/src/Event/SubmissionEvent.php +++ b/src/Event/SubmissionEvent.php @@ -2,6 +2,7 @@ namespace FormBuilderBundle\Event; +use FormBuilderBundle\Model\DoubleOptInSessionInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; @@ -12,6 +13,7 @@ class SubmissionEvent extends BaseSubmissionEvent protected ?string $redirectUri = null; protected bool $outputWorkflowFinisherDisabled = false; + protected ?DoubleOptInSessionInterface $doubleOptInSession = null; public function __construct( Request $request, @@ -66,4 +68,19 @@ public function getFunnelRuntimeData(): ?array { return $this->funnelRuntimeData; } + + public function hasDoubleOptInSession(): bool + { + return $this->doubleOptInSession instanceof DoubleOptInSessionInterface; + } + + public function getDoubleOptInSession(): ?DoubleOptInSessionInterface + { + return $this->doubleOptInSession; + } + + public function setDoubleOptInSession(?DoubleOptInSessionInterface $doubleOptInSession): void + { + $this->doubleOptInSession = $doubleOptInSession; + } } diff --git a/src/EventListener/Admin/AssetListener.php b/src/EventListener/Admin/AssetListener.php index 58eb4050..0b0d3cc4 100644 --- a/src/EventListener/Admin/AssetListener.php +++ b/src/EventListener/Admin/AssetListener.php @@ -70,6 +70,7 @@ public function addJsFiles(PathsEvent $event): void '/bundles/formbuilder/js/extjs/_form/data-injection/expression.js', '/bundles/formbuilder/js/extjs/extensions/formMetaData.js', '/bundles/formbuilder/js/extjs/extensions/formMailEditor.js', + '/bundles/formbuilder/js/extjs/extensions/formDoubleOptInData.js', '/bundles/formbuilder/js/extjs/extensions/formApiMappingEditor.js', '/bundles/formbuilder/js/extjs/extensions/formDataMappingEditor/formDataMapper.js', '/bundles/formbuilder/js/extjs/extensions/formObjectMappingEditor.js', diff --git a/src/Exception/DoubleOptInUniqueConstraintViolationException.php b/src/Exception/DoubleOptInUniqueConstraintViolationException.php new file mode 100644 index 00000000..ecbcbab7 --- /dev/null +++ b/src/Exception/DoubleOptInUniqueConstraintViolationException.php @@ -0,0 +1,7 @@ +set('form', $this->form); + $attributes->set('raw_output_data', $this->outputData); return $widget->getValueForOutput($attributes, $this->layoutType); } diff --git a/src/MailEditor/Widget/DoubleOptInSessionAdditionalDataWidget.php b/src/MailEditor/Widget/DoubleOptInSessionAdditionalDataWidget.php new file mode 100644 index 00000000..cec72566 --- /dev/null +++ b/src/MailEditor/Widget/DoubleOptInSessionAdditionalDataWidget.php @@ -0,0 +1,54 @@ + [ + 'type' => 'input', + 'defaultValue' => null, + 'label' => 'form_builder.mail_editor.widget_provider.double_opt_in_session.additional_data_field' + ], + ]; + } + + public function getValueForOutput(AttributeBag $attributeBag, string $layoutType): string + { + $rawOutputData = $attributeBag->get('raw_output_data', []); + + if (!array_key_exists('double_opt_in_session', $rawOutputData)) { + return '[NO VALUE]'; + } + + $doubleOptInSession = $rawOutputData['double_opt_in_session']; + if (!is_array($doubleOptInSession)) { + return '[NO VALUE]'; + } + + $field = $attributeBag->get('field', null); + $additionalData = $doubleOptInSession['additional_data']; + + if (!array_key_exists($field, $additionalData)) { + return '[NO VALUE]'; + } + + return (string) $additionalData[$field]; + } +} diff --git a/src/MailEditor/Widget/DoubleOptInSessionEmailWidget.php b/src/MailEditor/Widget/DoubleOptInSessionEmailWidget.php new file mode 100644 index 00000000..10cc8e68 --- /dev/null +++ b/src/MailEditor/Widget/DoubleOptInSessionEmailWidget.php @@ -0,0 +1,45 @@ +get('raw_output_data', []); + + if (!array_key_exists('double_opt_in_session', $rawOutputData)) { + return '[NO VALUE]'; + } + + $doubleOptInSession = $rawOutputData['double_opt_in_session']; + if (!is_array($doubleOptInSession)) { + return '[NO VALUE]'; + } + + $email = $doubleOptInSession['email'] ?? null; + if ($email === null) { + return '[NO VALUE]'; + } + + return $email; + } +} diff --git a/src/Manager/DoubleOptInManager.php b/src/Manager/DoubleOptInManager.php index 97feb278..400613cb 100644 --- a/src/Manager/DoubleOptInManager.php +++ b/src/Manager/DoubleOptInManager.php @@ -2,11 +2,11 @@ namespace FormBuilderBundle\Manager; -use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use FormBuilderBundle\Configuration\Configuration; use FormBuilderBundle\Event\DoubleOptInSubmissionEvent; use FormBuilderBundle\Exception\DoubleOptInException; +use FormBuilderBundle\Exception\DoubleOptInUniqueConstraintViolationException; use FormBuilderBundle\Form\RuntimeData\Provider\DoubleOptInSessionDataProvider; use FormBuilderBundle\Model\DoubleOptInSession; use FormBuilderBundle\Model\DoubleOptInSessionInterface; @@ -49,33 +49,37 @@ public function requiresDoubleOptInForm(FormDefinitionInterface $formDefinition, return !$this->isValidNonAppliedFormAwareSessionToken($formDefinition, $sessionToken); } - public function redeemDoubleOptInSessionToken(FormDefinitionInterface $formDefinition, array $formRuntimeData): void + public function redeemDoubleOptInSessionToken(DoubleOptInSessionInterface $doubleOptInSession): void + { + $doubleOptInConfig = $this->configuration->getConfig('double_opt_in'); + + if ($doubleOptInConfig['redeem_mode'] === self::REDEEM_MODE_DELETE) { + $this->deleteDoubleOptInSession($doubleOptInSession); + } else { + $this->devalueDoubleOptInSession($doubleOptInSession); + } + } + + public function findDoubleOptInSession(FormDefinitionInterface $formDefinition, array $formRuntimeData): ?DoubleOptInSessionInterface { if ($this->doubleOptInEnabled($formDefinition) === false) { - return; + return null; } if (!array_key_exists(DoubleOptInSessionDataProvider::DOUBLE_OPT_IN_SESSION_RUNTIME_DATA_IDENTIFIER, $formRuntimeData)) { - return; + return null; } if (null === $sessionToken = $formRuntimeData[DoubleOptInSessionDataProvider::DOUBLE_OPT_IN_SESSION_RUNTIME_DATA_IDENTIFIER]) { - return; + return null; } $doubleOptInSession = $this->doubleOptInSessionRepository->findByNonAppliedFormAwareSessionToken($sessionToken, $formDefinition->getId()); - if (!$doubleOptInSession instanceof DoubleOptInSessionInterface) { - throw new DoubleOptInException('invalid double-opt-in session'); + return null; } - $doubleOptInConfig = $this->configuration->getConfig('double_opt_in'); - - if ($doubleOptInConfig['redeem_mode'] === self::REDEEM_MODE_DELETE) { - $this->deleteDoubleOptInSession($doubleOptInSession); - } else { - $this->devalueDoubleOptInSession($doubleOptInSession); - } + return $doubleOptInSession; } public function isValidNonAppliedFormAwareSessionToken(FormDefinitionInterface $formDefinition, ?string $sessionToken): bool @@ -123,7 +127,7 @@ public function processOptInSubmission(DoubleOptInSubmissionEvent $submissionEve $formData, $dispatchLocation ); - } catch (UniqueConstraintViolationException) { + } catch (DoubleOptInUniqueConstraintViolationException) { throw new DoubleOptInException($this->translator->trans('form_builder.form.double_opt_in.duplicate_session')); } @@ -144,6 +148,23 @@ public function create( ?array $additionalData, string $dispatchLocation ): DoubleOptInSessionInterface { + + $doubleOptInConfig = $this->configuration->getConfig('double_opt_in'); + $allowMultipleUserSessions = $doubleOptInConfig['allowMultipleUserSessions'] ?? true; + + if ($allowMultipleUserSessions === false) { + + $doubleOptInSession = $this->doubleOptInSessionRepository->findOneBy([ + 'applied' => false, + 'email' => $email, + 'formDefinition' => $formDefinition + ]); + + if ($doubleOptInSession instanceof DoubleOptInSessionInterface) { + throw new DoubleOptInUniqueConstraintViolationException(); + } + } + $doubleOptInSession = new DoubleOptInSession(); $doubleOptInSession->setFormDefinition($formDefinition); diff --git a/src/Migrations/Version20240916132702.php b/src/Migrations/Version20240916132702.php index 1c14d395..3c036075 100644 --- a/src/Migrations/Version20240916132702.php +++ b/src/Migrations/Version20240916132702.php @@ -23,6 +23,16 @@ public function up(Schema $schema): void { $installer = $this->container->get(Install::class); $installer->updateTranslations(); + + if (!$schema->hasTable('formbuilder_double_opt_in_session')) { + return; + } + + if (!$schema->getTable('formbuilder_double_opt_in_session')->hasIndex('email_form_definition')) { + return; + } + + $this->addSql('DROP INDEX email_form_definition ON formbuilder_double_opt_in_session;'); } public function down(Schema $schema): void diff --git a/src/OutputWorkflow/Channel/Email/EmailOutputChannel.php b/src/OutputWorkflow/Channel/Email/EmailOutputChannel.php index ab3edcb1..ef3aa714 100644 --- a/src/OutputWorkflow/Channel/Email/EmailOutputChannel.php +++ b/src/OutputWorkflow/Channel/Email/EmailOutputChannel.php @@ -37,13 +37,18 @@ public function getUsedFormFieldNames(array $channelConfiguration): array */ public function dispatchOutputProcessing(SubmissionEvent $submissionEvent, string $workflowName, array $channelConfiguration): void { - $locale = $submissionEvent->getRequest()->getLocale(); + $locale = $submissionEvent->getLocale() ?? $submissionEvent->getRequest()->getLocale(); $form = $submissionEvent->getForm(); $formRuntimeData = $submissionEvent->getFormRuntimeData(); $localizedConfig = $this->validateOutputConfig($channelConfiguration, $locale); - $this->channelWorker->process($form, $localizedConfig, $formRuntimeData, $workflowName, $locale); + $context = [ + 'locale' => $locale, + 'doubleOptInSession' => $submissionEvent->getDoubleOptInSession(), + ]; + + $this->channelWorker->process($form, $localizedConfig, $formRuntimeData, $workflowName, $context); } /** diff --git a/src/OutputWorkflow/Channel/Email/EmailOutputChannelWorker.php b/src/OutputWorkflow/Channel/Email/EmailOutputChannelWorker.php index 89751a12..0360058d 100644 --- a/src/OutputWorkflow/Channel/Email/EmailOutputChannelWorker.php +++ b/src/OutputWorkflow/Channel/Email/EmailOutputChannelWorker.php @@ -7,6 +7,7 @@ use FormBuilderBundle\Exception\OutputWorkflow\GuardChannelException; use FormBuilderBundle\Exception\OutputWorkflow\GuardException; use FormBuilderBundle\Exception\OutputWorkflow\GuardOutputWorkflowException; +use FormBuilderBundle\Model\DoubleOptInSessionInterface; use Pimcore\Mail; use Pimcore\Model\Document; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -27,7 +28,7 @@ public function __construct( /** * @throws \Exception */ - public function process(FormInterface $form, array $channelConfiguration, array $formRuntimeData, string $workflowName, string $locale): void + public function process(FormInterface $form, array $channelConfiguration, array $formRuntimeData, string $workflowName, array $context = []): void { /** @var FormDataInterface $formData */ $formData = $form->getData(); @@ -36,6 +37,7 @@ public function process(FormInterface $form, array $channelConfiguration, array $forcePlainText = $channelConfiguration['forcePlainText']; $disableDefaultMailBody = $channelConfiguration['disableDefaultMailBody']; $disableMailLogging = $channelConfiguration['disableMailLogging'] ?? false; + $doubleOptInSession = $context['doubleOptInSession'] ?? null; $mailTemplateId = $mailTemplate['id']; $mailTemplate = is_numeric($mailTemplateId) ? Document\Email::getById($mailTemplateId) : null; @@ -44,13 +46,18 @@ public function process(FormInterface $form, array $channelConfiguration, array throw new \Exception('Invalid Email Document Id: ' . $mailTemplateId); } - $mail = $this->mailParser->create($mailTemplate, $form, $channelConfiguration, $locale); + $mail = $this->mailParser->create($mailTemplate, $form, $channelConfiguration, $context); $forceSubmissionAsPlainText = (bool) $forcePlainText; $mail->setParam('_form_builder_output_workflow_name', $workflowName); $mail->setParam('_form_builder_id', (int) $formData->getFormDefinition()->getId()); $mail->setParam('_form_builder_preset', $formRuntimeData['form_preset'] === 'custom' ? null : $formRuntimeData['form_preset']); + if ($doubleOptInSession instanceof DoubleOptInSessionInterface) { + $mail->setParam('_form_builder_double_opt_in_token', $doubleOptInSession->getTokenAsString()); + $mail->setParam('_form_builder_double_opt_in_session_email', $doubleOptInSession->getEmail()); + } + if ($disableDefaultMailBody === true) { $mail->setParam('_form_builder_disabled_default_mail_body', 1); } diff --git a/src/OutputWorkflow/Channel/Email/Parser/MailParser.php b/src/OutputWorkflow/Channel/Email/Parser/MailParser.php index a554587e..6fa19ac3 100644 --- a/src/OutputWorkflow/Channel/Email/Parser/MailParser.php +++ b/src/OutputWorkflow/Channel/Email/Parser/MailParser.php @@ -2,6 +2,7 @@ namespace FormBuilderBundle\OutputWorkflow\Channel\Email\Parser; +use FormBuilderBundle\Model\DoubleOptInSessionInterface; use FormBuilderBundle\Stream\File; use League\Flysystem\FilesystemOperator; use Pimcore\Mail; @@ -25,10 +26,13 @@ public function __construct( /** * @throws \Exception */ - public function create(Email $mailTemplate, FormInterface $form, array $channelConfiguration, string $locale): Mail + public function create(Email $mailTemplate, FormInterface $form, array $channelConfiguration, array $context): Mail { $mail = new Mail(); + $locale = $context['locale'] ?? null; + $doubleOptInSession = $context['doubleOptInSession'] ?? null; + $allowAttachments = $channelConfiguration['allowAttachments']; $disableDefaultMailBody = $channelConfiguration['disableDefaultMailBody']; $forcePlainText = (bool) $channelConfiguration['forcePlainText']; @@ -43,12 +47,14 @@ public function create(Email $mailTemplate, FormInterface $form, array $channelC $this->parseSubject($mailTemplate, $fieldValues); $this->setMailPlaceholders($mail, $fieldValues); + $mail->setParam('double_opt_in_session', $doubleOptInSession); + /** @var FormDataInterface $formData */ $formData = $form->getData(); if ($disableDefaultMailBody === false) { $mailLayout = $this->getMailLayout($channelConfiguration, $forcePlainText); - $this->setMailBodyPlaceholder($mail, $form, $fieldValues, $mailLayout, $forcePlainText ? 'text' : 'html'); + $this->setMailBodyPlaceholder($mail, $form, $doubleOptInSession, $fieldValues, $mailLayout, $forcePlainText ? 'text' : 'html'); } $attachments = []; @@ -129,14 +135,37 @@ protected function setMailPlaceholders(Mail $mail, array $fieldValues): void } } - protected function setMailBodyPlaceholder(Mail $mail, FormInterface $form, array $fieldValues, ?string $mailLayout, string $layoutType): void - { + protected function setMailBodyPlaceholder( + Mail $mail, + FormInterface $form, + ?DoubleOptInSessionInterface $doubleOptInSession, + array $fieldValues, + ?string $mailLayout, + string $layoutType + ): void { + + $doubleOptInSessionValues = []; + if ($doubleOptInSession instanceof DoubleOptInSessionInterface) { + $doubleOptInSessionValues['email'] = $doubleOptInSession->getEmail(); + $doubleOptInSessionValues['token'] = $doubleOptInSession->getTokenAsString(); + $doubleOptInSessionValues['creation_date'] = $doubleOptInSession->getCreationDate(); + $doubleOptInSessionValues['additional_data'] = $doubleOptInSession->getAdditionalData(); + } + if ($mailLayout === null) { $body = $this->templating->render( '@FormBuilder/email/form_data.html.twig', - ['fields' => $fieldValues] + [ + 'fields' => $fieldValues, + 'double_opt_in_session' => $doubleOptInSessionValues + ] ); } else { + + if ($doubleOptInSession instanceof DoubleOptInSessionInterface) { + $fieldValues['double_opt_in_session'] = $doubleOptInSessionValues; + } + $body = $this->placeholderParser->replacePlaceholderWithOutputData($mailLayout, $form, $fieldValues, $layoutType); } diff --git a/src/OutputWorkflow/FormSubmissionFinisher.php b/src/OutputWorkflow/FormSubmissionFinisher.php index 269aeda5..97edf5ab 100644 --- a/src/OutputWorkflow/FormSubmissionFinisher.php +++ b/src/OutputWorkflow/FormSubmissionFinisher.php @@ -10,6 +10,7 @@ use FormBuilderBundle\Form\Data\FormDataInterface; use FormBuilderBundle\Form\FormErrorsSerializerInterface; use FormBuilderBundle\Manager\DoubleOptInManager; +use FormBuilderBundle\Model\DoubleOptInSessionInterface; use FormBuilderBundle\Model\FormDefinitionInterface; use FormBuilderBundle\Model\OutputWorkflowInterface; use FormBuilderBundle\Session\FlashBagManagerInterface; @@ -54,6 +55,15 @@ public function finishWithSuccess(Request $request, SubmissionEvent $submissionE return $this->buildErrorResponse($request, $submissionEvent, 'No valid output workflow found.'); } + /** @var FormDataInterface $data */ + $data = $submissionEvent->getForm()->getData(); + $formDefinition = $data->getFormDefinition(); + $doubleOptInSession = $this->doubleOptInManager->findDoubleOptInSession($formDefinition, $submissionEvent->getFormRuntimeData()); + + if ($doubleOptInSession instanceof DoubleOptInSessionInterface) { + $submissionEvent->setDoubleOptInSession($doubleOptInSession); + } + try { $this->outputWorkflowDispatcher->dispatch($outputWorkflow, $submissionEvent); } catch (GuardOutputWorkflowException $e) { @@ -110,7 +120,6 @@ protected function buildErrorResponse(Request $request, SubmissionEvent|DoubleOp $data = $submissionEvent->getForm()->getData(); $formDefinition = $data->getFormDefinition(); $redirectUri = $submissionEvent->hasRedirectUri() ? $submissionEvent->getRedirectUri() : null; - } else { $flashBagPrefix = 'formbuilder_double_opt_in'; $formDefinition = $submissionEvent->getFormDefinition(); @@ -154,10 +163,10 @@ protected function buildSuccessResponse(Request $request, SubmissionEvent|Double $responseMessages ]; - if ($submissionEvent instanceof SubmissionEvent) { + if ($submissionEvent instanceof SubmissionEvent && $submissionEvent->hasDoubleOptInSession()) { try { - $this->doubleOptInManager->redeemDoubleOptInSessionToken($formDefinition, $submissionEvent->getFormRuntimeData()); - } catch(\Throwable $e) { + $this->doubleOptInManager->redeemDoubleOptInSessionToken($submissionEvent->getDoubleOptInSession()); + } catch (\Throwable $e) { return $this->buildErrorResponse($request, $submissionEvent, $e->getMessage()); } } diff --git a/src/Repository/DoubleOptInSessionRepository.php b/src/Repository/DoubleOptInSessionRepository.php index f785f06a..1bc7f863 100644 --- a/src/Repository/DoubleOptInSessionRepository.php +++ b/src/Repository/DoubleOptInSessionRepository.php @@ -23,6 +23,16 @@ public function getQueryBuilder(): QueryBuilder return $this->repository->createQueryBuilder('s'); } + public function find(string $token): ?DoubleOptInSessionInterface + { + return $this->repository->find(Uuid::fromString($token)->toBinary()); + } + + public function findOneBy(array $criteria, ?array $orderBy = null): ?DoubleOptInSessionInterface + { + return $this->repository->findOneBy($criteria, $orderBy); + } + public function findByNonAppliedFormAwareSessionToken(string $token, int $formDefinitionId): ?DoubleOptInSessionInterface { if (!Uuid::isValid($token)) { diff --git a/src/Repository/DoubleOptInSessionRepositoryInterface.php b/src/Repository/DoubleOptInSessionRepositoryInterface.php index 27597a87..97cb5f88 100644 --- a/src/Repository/DoubleOptInSessionRepositoryInterface.php +++ b/src/Repository/DoubleOptInSessionRepositoryInterface.php @@ -9,5 +9,9 @@ interface DoubleOptInSessionRepositoryInterface { public function getQueryBuilder(): QueryBuilder; + public function find(string $token): ?DoubleOptInSessionInterface; + + public function findOneBy(array $criteria, ?array $orderBy = null): ?DoubleOptInSessionInterface; + public function findByNonAppliedFormAwareSessionToken(string $token, int $formDefinitionId): ?DoubleOptInSessionInterface; } diff --git a/src/Tool/Install.php b/src/Tool/Install.php index f78cda24..04443928 100644 --- a/src/Tool/Install.php +++ b/src/Tool/Install.php @@ -2,7 +2,7 @@ namespace FormBuilderBundle\Tool; -use FormBuilderBundle\Migrations\Version20240819150642; +use FormBuilderBundle\Migrations\Version20240916132702; use Pimcore\Extension\Bundle\Installer\Exception\InstallationException; use Pimcore\Extension\Bundle\Installer\SettingsStoreAwareInstaller; use Pimcore\Model\Asset; @@ -30,7 +30,7 @@ public function install(): void public function getLastMigrationVersionClassName(): ?string { - return Version20240819150642::class; + return Version20240916132702::class; } public function updateTranslations(): void diff --git a/templates/email/form_data.html.twig b/templates/email/form_data.html.twig index 84f21041..79397571 100644 --- a/templates/email/form_data.html.twig +++ b/templates/email/form_data.html.twig @@ -54,5 +54,23 @@ {% endif %} {% endif %} {% endfor %} + {# + [DOUBLE-OPT-IN] + If you want to show double opt in session values, uncomment this section + + available fields: + - double_opt_in_session.email + - double_opt_in_session.token + - double_opt_in_session.creation_date + - double_opt_in_session.additional_data + #} + {# + {% if double_opt_in_session is defined and double_opt_in_session is not null %} +