From 6f61faa9efdd7d3d032842792828aff9a388381f Mon Sep 17 00:00:00 2001 From: formsdev <136701234+formsdev@users.noreply.github.com> Date: Thu, 28 Mar 2024 22:44:30 +0530 Subject: [PATCH] Notification & Integrations refactoring (#346) * Integrations Refactoring - WIP * integrations list & edit - WIP * Fix integration store binding issue * integrations refactor - WIP * Form integration - WIP * Form integration Edit - WIP * Integration Refactor - Slack - WIP * Integration Refactor - Discord - WIP * Integration Refactor - Webhook - WIP * Integration Refactor - Send Submission Confirmation - WIP * Integration Refactor - Backend handler - WIP * Form Integration Status field * Integration Refactor - Backend SubmissionConfirmation - WIP * IntegrationMigration Command * skip confirmation email test case * Small refactoring * FormIntegration status active/inactive * formIntegrationData to integrationData * Rename file name with Integration suffix for integration realted files * Loader on form integrations * WIP * form integration test case * WIP * Added Integration card - working on refactoring * change location for IntegrationCard and update package file * Form Integration Create/Edit in single Modal * Remove integration extra pages * crisp_help_page_slug for integration json * integration logic as collapse * UI improvements * WIP * Trying to debug vue devtools * WIP for integrations * getIntegrationHandler change namespace name * useForm for integration fields + validation structure * Integration Test case & apply validation rules * Apply useform changes to integration other files * validation rules for FormNotificationsMessageActions fields * Zapier integration as coming soon * Update FormCleaner * set default settings for confirmation integration * WIP * Finish validation for all integrations * Updated purify, added integration formatData * Fix testcase * Ran pint; working on integration errors * Handle integration events * command for Delete Old Integration Events * Display Past Events in Modal * on Integration event create with status error send email to form creator * Polish styling * Minor improvements * Finish badge and integration status * Fix tests and add an integration event test * Lint --------- Co-authored-by: Julien Nahum --- _ide_helper_models.php | 130 +- .../Commands/CleanIntegrationEvents.php | 34 + app/Console/Commands/IntegrationMigration.php | 107 + app/Console/Kernel.php | 3 +- .../Models/FormIntegrationsEventCreated.php | 25 + .../FormIntegrationsController.php | 68 + .../FormIntegrationsEventController.php | 26 + .../Integration/FormIntegrationsRequest.php | 84 + .../FormIntegrationsEventResource.php | 23 + app/Listeners/FailedWebhookListener.php | 1 + .../Forms/FormIntegrationsEventListener.php | 26 + app/Listeners/Forms/NotifyFormSubmission.php | 54 +- .../Forms/SubmissionConfirmation.php | 94 - ...egrationsEventCreationConfirmationMail.php | 46 + app/Mail/Forms/SubmissionConfirmationMail.php | 12 +- app/Models/Integration/FormIntegration.php | 51 + .../Integration/FormIntegrationsEvent.php | 39 + app/Models/Integration/FormZapierWebhook.php | 2 +- .../Forms/FormSubmissionNotification.php | 11 +- app/Providers/EventServiceProvider.php | 10 +- app/Rules/IntegrationLogicRule.php | 186 + app/Service/Forms/FormCleaner.php | 20 +- .../AbstractIntegrationHandler.php | 131 + .../AbstractWebhookHandler.php | 2 +- .../DiscordHandler.php | 2 +- .../Forms/Integrations/DiscordIntegration.php | 91 + .../Forms/Integrations/EmailIntegration.php | 46 + .../SimpleWebhookHandler.php | 2 +- .../SlackHandler.php | 2 +- .../Forms/Integrations/SlackIntegration.php | 101 + .../SubmissionConfirmationIntegration.php | 103 + .../WebhookHandlerProvider.php | 2 +- .../Forms/Integrations/WebhookIntegration.php | 23 + .../ZapierHandler.php | 2 +- client/app.config.ts | 6 + client/components/forms/TextInput.vue | 9 +- .../forms/components/CameraUpload.vue | 4 +- .../components/forms/components/InputHelp.vue | 27 +- .../forms/components/InputLabel.vue | 19 +- .../forms/components/InputWrapper.vue | 85 +- .../components/forms/components/VCheckbox.vue | 62 +- .../components/forms/components/VSelect.vue | 103 +- .../components/forms/components/VSwitch.vue | 24 +- client/components/forms/useFormInput.js | 6 +- client/components/global/Badge.vue | 50 + client/components/global/Modal.vue | 42 +- client/components/global/ProTag.vue | 64 +- client/components/global/ScrollShadow.vue | 2 +- .../FormNotificationsMessageActions.vue | 8 +- .../open/integrations/DiscordIntegration.vue | 33 + .../open/integrations/EmailIntegration.vue | 34 + .../integrations/GoogleSheetsIntegration.vue | 18 + .../open/integrations/SlackIntegration.vue | 32 + .../SubmissionConfirmationIntegration.vue | 58 + .../open/integrations/WebhookIntegration.vue | 17 + .../open/integrations/ZapierIntegration.vue | 18 + .../components/IntegrationCard.vue | 106 + .../components/IntegrationEventsModal.vue | 68 + .../components/IntegrationListOption.vue | 49 + .../components/IntegrationModal.vue | 82 + .../components/IntegrationWrapper.vue | 75 + .../forms/create/CreateFormBaseModal.vue | 68 +- client/data/forms/integrations.json | 54 + client/nuxt.config.ts | 20 +- client/package-lock.json | 3187 ++++++++++++----- client/package.json | 9 +- client/pages/forms/[slug]/index.vue | 1 - client/pages/forms/[slug]/show.vue | 105 +- .../forms/[slug]/show/integrations/index.vue | 91 + .../pages/forms/[slug]/show/submissions.vue | 1 - client/stores/form_integrations.js | 71 + composer.json | 2 +- composer.lock | 1495 ++++---- config/purify.php | 245 +- ..._100134_create_form_integrations_table.php | 45 + ..._create_form_integrations_events_table.php | 41 + resources/data/forms/integrations.json | 54 + ...irmation-submission-notification.blade.php | 10 +- .../form/integrations-event-created.blade.php | 13 + routes/api.php | 29 +- tests/Feature/Forms/ConfirmationEmailTest.php | 34 +- .../Forms/FormIntegrationEventTest.php | 28 + tests/Feature/Forms/FormIntegrationTest.php | 44 + tests/TestHelpers.php | 19 + 84 files changed, 6121 insertions(+), 2205 deletions(-) create mode 100644 app/Console/Commands/CleanIntegrationEvents.php create mode 100644 app/Console/Commands/IntegrationMigration.php create mode 100644 app/Events/Models/FormIntegrationsEventCreated.php create mode 100644 app/Http/Controllers/Forms/Integration/FormIntegrationsController.php create mode 100644 app/Http/Controllers/Forms/Integration/FormIntegrationsEventController.php create mode 100644 app/Http/Requests/Integration/FormIntegrationsRequest.php create mode 100644 app/Http/Resources/FormIntegrationsEventResource.php create mode 100644 app/Listeners/Forms/FormIntegrationsEventListener.php delete mode 100644 app/Listeners/Forms/SubmissionConfirmation.php create mode 100644 app/Mail/Forms/FormIntegrationsEventCreationConfirmationMail.php create mode 100644 app/Models/Integration/FormIntegration.php create mode 100644 app/Models/Integration/FormIntegrationsEvent.php create mode 100644 app/Rules/IntegrationLogicRule.php create mode 100644 app/Service/Forms/Integrations/AbstractIntegrationHandler.php rename app/Service/Forms/{Webhooks => Integrations}/AbstractWebhookHandler.php (97%) rename app/Service/Forms/{Webhooks => Integrations}/DiscordHandler.php (98%) create mode 100644 app/Service/Forms/Integrations/DiscordIntegration.php create mode 100644 app/Service/Forms/Integrations/EmailIntegration.php rename app/Service/Forms/{Webhooks => Integrations}/SimpleWebhookHandler.php (90%) rename app/Service/Forms/{Webhooks => Integrations}/SlackHandler.php (98%) create mode 100644 app/Service/Forms/Integrations/SlackIntegration.php create mode 100644 app/Service/Forms/Integrations/SubmissionConfirmationIntegration.php rename app/Service/Forms/{Webhooks => Integrations}/WebhookHandlerProvider.php (96%) create mode 100644 app/Service/Forms/Integrations/WebhookIntegration.php rename app/Service/Forms/{Webhooks => Integrations}/ZapierHandler.php (92%) create mode 100644 client/app.config.ts create mode 100644 client/components/global/Badge.vue create mode 100644 client/components/open/integrations/DiscordIntegration.vue create mode 100644 client/components/open/integrations/EmailIntegration.vue create mode 100644 client/components/open/integrations/GoogleSheetsIntegration.vue create mode 100644 client/components/open/integrations/SlackIntegration.vue create mode 100644 client/components/open/integrations/SubmissionConfirmationIntegration.vue create mode 100644 client/components/open/integrations/WebhookIntegration.vue create mode 100644 client/components/open/integrations/ZapierIntegration.vue create mode 100644 client/components/open/integrations/components/IntegrationCard.vue create mode 100644 client/components/open/integrations/components/IntegrationEventsModal.vue create mode 100644 client/components/open/integrations/components/IntegrationListOption.vue create mode 100644 client/components/open/integrations/components/IntegrationModal.vue create mode 100644 client/components/open/integrations/components/IntegrationWrapper.vue create mode 100644 client/data/forms/integrations.json create mode 100644 client/pages/forms/[slug]/show/integrations/index.vue create mode 100644 client/stores/form_integrations.js create mode 100644 database/migrations/2024_02_21_100134_create_form_integrations_table.php create mode 100644 database/migrations/2024_02_21_160807_create_form_integrations_events_table.php create mode 100644 resources/data/forms/integrations.json create mode 100644 resources/views/mail/form/integrations-event-created.blade.php create mode 100644 tests/Feature/Forms/FormIntegrationEventTest.php create mode 100644 tests/Feature/Forms/FormIntegrationTest.php diff --git a/_ide_helper_models.php b/_ide_helper_models.php index ca91bea9..e82f42b8 100644 --- a/_ide_helper_models.php +++ b/_ide_helper_models.php @@ -1,6 +1,7 @@ $items + * @property-read int|null $items_count + * @property-read \App\Models\User|null $owner + * @property-read \App\Models\User|null $user + * @method static \Illuminate\Database\Eloquent\Builder|Subscription active() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription canceled() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription cancelled() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription ended() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription expiredTrial() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription incomplete() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription notCanceled() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription notCancelled() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription notOnGracePeriod() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription notOnTrial() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription onGracePeriod() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription onTrial() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription pastDue() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription query() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription recurring() + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereEndsAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereQuantity($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripeId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripePrice($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripeStatus($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereTrialEndsAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereUserId($value) + */ + class Subscription extends \Eloquent {} +} + namespace App\Models\Forms\AI{ /** * App\Models\Forms\AI\AiFormCompletion @@ -77,10 +129,10 @@ namespace App\Models\Forms{ * @property bool $can_be_indexed * @property string|null $password * @property string $notification_sender - * @property array $tags + * @property array|null $tags * @property \Illuminate\Support\Carbon|null $deleted_at * @property int $creator_id - * @property array $removed_properties + * @property-read array|null $removed_properties * @property int|null $max_submissions_count * @property string|null $max_submissions_reached_text * @property string|null $slack_webhook_url @@ -92,12 +144,15 @@ namespace App\Models\Forms{ * @property object $seo_meta * @property object|null $notification_settings * @property bool $auto_save + * @property string|null $custom_domain + * @property bool $show_progress_bar * @property-read \App\Models\User $creator * @property-read mixed $edit_url * @property-read mixed $form_pending_submission_key * @property-read mixed $has_password * @property-read mixed $is_closed * @property-read mixed $is_pro + * @property-read mixed $max_file_size * @property-read mixed $max_number_of_submissions_reached * @property-read mixed $notifies_discord * @property-read mixed $notifies_slack @@ -127,6 +182,7 @@ namespace App\Models\Forms{ * @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatorId($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereCustomCode($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereCustomDomain($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereDarkMode($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereDatabaseFieldsUpdate($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereDeletedAt($value) @@ -155,6 +211,7 @@ namespace App\Models\Forms{ * @method static \Illuminate\Database\Eloquent\Builder|Form whereRemovedProperties($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereSendSubmissionConfirmation($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereSeoMeta($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereShowProgressBar($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereSlackWebhookUrl($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereSlug($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmitButtonText($value) @@ -173,7 +230,7 @@ namespace App\Models\Forms{ * @method static \Illuminate\Database\Eloquent\Builder|Form withTrashed() * @method static \Illuminate\Database\Eloquent\Builder|Form withoutTrashed() */ - class Form extends \Eloquent {} + class Form extends \Eloquent implements \App\Models\Traits\CachableAttributes {} } namespace App\Models\Forms{ @@ -238,6 +295,62 @@ namespace App\Models\Forms{ class FormView extends \Eloquent {} } +namespace App\Models\Integration{ +/** + * App\Models\Integration\FormIntegration + * + * @property int $id + * @property int $form_id + * @property string $status + * @property string $integration_id + * @property object $logic + * @property object $data + * @property string|null $oauth_id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $events + * @property-read int|null $events_count + * @property-read \App\Models\Forms\Form|null $form + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration query() + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereData($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereFormId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereIntegrationId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereLogic($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereOauthId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereStatus($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration whereUpdatedAt($value) + */ + class FormIntegration extends \Eloquent {} +} + +namespace App\Models\Integration{ +/** + * App\Models\Integration\FormIntegrationsEvent + * + * @property int $id + * @property int $integration_id + * @property string $status + * @property object $data + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \App\Models\Integration\FormIntegration|null $integration + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent query() + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereData($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereIntegrationId($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereStatus($value) + * @method static \Illuminate\Database\Eloquent\Builder|FormIntegrationsEvent whereUpdatedAt($value) + */ + class FormIntegrationsEvent extends \Eloquent {} +} + namespace App\Models\Integration{ /** * App\Models\Integration\FormZapierWebhook @@ -277,8 +390,8 @@ namespace App\Models{ * @property array $meta * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at - * @property-read mixed $custom_domain_limit_count - * @property-read mixed $max_file_size + * @property-read int|null $custom_domain_limit_count + * @property-read int $max_file_size * @property-read \App\Models\User|null $user * @method static \Illuminate\Database\Eloquent\Builder|License active() * @method static \Illuminate\Database\Eloquent\Builder|License newModelQuery() @@ -393,6 +506,7 @@ namespace App\Models{ * @property-read mixed $has_forms * @property-read mixed $is_risky * @property-read mixed $is_subscribed + * @property-read mixed $moderator * @property-read string $photo_url * @property-read mixed $template_editor * @property-read \Illuminate\Database\Eloquent\Collection $licenses @@ -401,7 +515,7 @@ namespace App\Models{ * @property-read int|null $notifications_count * @property-read \Illuminate\Database\Eloquent\Collection $oauthProviders * @property-read int|null $oauth_providers_count - * @property-read \Illuminate\Database\Eloquent\Collection $subscriptions + * @property-read \Illuminate\Database\Eloquent\Collection $subscriptions * @property-read int|null $subscriptions_count * @property-read \Illuminate\Database\Eloquent\Collection $workspaces * @property-read int|null $workspaces_count @@ -436,7 +550,7 @@ namespace App\Models{ * @property \Illuminate\Support\Carbon|null $updated_at * @property string $name * @property string|null $icon - * @property mixed|null $custom_domains + * @property array|null $custom_domains * @property-read \Illuminate\Database\Eloquent\Collection $forms * @property-read int|null $forms_count * @property-read mixed $custom_domain_count_limit @@ -457,6 +571,6 @@ namespace App\Models{ * @method static \Illuminate\Database\Eloquent\Builder|Workspace whereName($value) * @method static \Illuminate\Database\Eloquent\Builder|Workspace whereUpdatedAt($value) */ - class Workspace extends \Eloquent {} + class Workspace extends \Eloquent implements \App\Models\Traits\CachableAttributes {} } diff --git a/app/Console/Commands/CleanIntegrationEvents.php b/app/Console/Commands/CleanIntegrationEvents.php new file mode 100644 index 00000000..58b63f06 --- /dev/null +++ b/app/Console/Commands/CleanIntegrationEvents.php @@ -0,0 +1,34 @@ +subDays(14))->delete(); + $this->line($response . ' Events Deleted'); + } +} diff --git a/app/Console/Commands/IntegrationMigration.php b/app/Console/Commands/IntegrationMigration.php new file mode 100644 index 00000000..063368fe --- /dev/null +++ b/app/Console/Commands/IntegrationMigration.php @@ -0,0 +1,107 @@ +line('Process For Form: ' . $form->id . ' - ' . $form->slug); + + // Email + if ($form->notifies && $form->notification_emails) { + $this->createFormIntegration($form, 'email', [ + 'notification_reply_to' => $form->notification_settings->notification_reply_to, + 'notification_emails' => $form->notification_emails + ]); + } + + // Submission Confirmation + if ($form->send_submission_confirmation) { + $this->createFormIntegration($form, 'submission_confirmation', [ + 'confirmation_reply_to' => $form->notification_settings->confirmation_reply_to, + 'notification_sender' => $form->notification_sender, + 'notification_subject' => $form->notification_subject, + 'notification_body' => $form->notification_body, + 'notifications_include_submission' => $form->notifications_include_submission, + ]); + } + + // Slack + if ($form->slack_webhook_url) { + $slackData = $form->notification_settings->slack; + $this->createFormIntegration($form, 'slack', [ + 'slack_webhook_url' => $form->slack_webhook_url, + 'include_submission_data' => $slackData->include_submission_data ?? true, + 'link_open_form' => $slackData->link_open_form ?? true, + 'link_edit_form' => $slackData->link_edit_form ?? true, + 'views_submissions_count' => $slackData->views_submissions_count ?? true, + 'link_edit_submission' => $slackData->link_edit_submission ?? true + ]); + } + + // Discord + if ($form->discord_webhook_url) { + $discordData = $form->notification_settings->discord; + $this->createFormIntegration($form, 'discord', [ + 'discord_webhook_url' => $form->discord_webhook_url, + 'include_submission_data' => $discordData->include_submission_data ?? true, + 'link_open_form' => $discordData->link_open_form ?? true, + 'link_edit_form' => $discordData->link_edit_form ?? true, + 'views_submissions_count' => $discordData->views_submissions_count ?? true, + 'link_edit_submission' => $discordData->link_edit_submission ?? true + ]); + } + + // Webhook + if ($form->webhook_url) { + $this->createFormIntegration($form, 'webhook', [ + 'webhook_url' => $form->webhook_url + ]); + } + } + } + ); + + $this->line('Migration Done'); + } + + private function createFormIntegration(Form $form, $integration_id, $data = []) + { + $this->line('Form Integration Create: ' . $integration_id); + return FormIntegration::create([ + 'form_id' => $form->id, + 'status' => FormIntegration::STATUS_ACTIVE, + 'integration_id' => $integration_id, + 'data' => $data, + 'logic' => [] + ]); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index b6009cd1..a6ba9add 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -24,6 +24,7 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule) { $schedule->command('forms:database-cleanup')->hourly(); + $schedule->command('forms:integration-events-cleanup')->daily(); } /** @@ -33,7 +34,7 @@ class Kernel extends ConsoleKernel */ protected function commands() { - $this->load(__DIR__.'/Commands'); + $this->load(__DIR__ . '/Commands'); require base_path('routes/console.php'); } diff --git a/app/Events/Models/FormIntegrationsEventCreated.php b/app/Events/Models/FormIntegrationsEventCreated.php new file mode 100644 index 00000000..86e3a686 --- /dev/null +++ b/app/Events/Models/FormIntegrationsEventCreated.php @@ -0,0 +1,25 @@ +middleware('auth'); + } + + public function index(string $id) + { + $form = Form::findOrFail((int)$id); + $this->authorize('view', $form); + + return FormIntegration::where('form_id', $form->id)->get(); + } + + public function create(FormIntegrationsRequest $request, string $id) + { + $form = Form::findOrFail((int)$id); + $this->authorize('update', $form); + + $formIntegration = FormIntegration::create( + array_merge([ + 'form_id' => $form->id, + ], $request->toIntegrationData()) + ); + + return $this->success([ + 'message' => 'Form Integration was created.', + 'form_integration' => $formIntegration + ]); + } + + public function update(FormIntegrationsRequest $request, string $id, string $integrationid) + { + $form = Form::findOrFail((int)$id); + $this->authorize('update', $form); + + $formIntegration = FormIntegration::findOrFail((int)$integrationid); + $formIntegration->update($request->toIntegrationData()); + + return $this->success([ + 'message' => 'Form Integration was updated.', + 'form_integration' => $formIntegration + ]); + } + + public function destroy(string $id, string $integrationid) + { + $form = Form::findOrFail((int)$id); + $this->authorize('update', $form); + + $formIntegration = FormIntegration::findOrFail((int)$integrationid); + $formIntegration->delete(); + + return $this->success([ + 'message' => 'Form Integration was deleted.' + ]); + } +} diff --git a/app/Http/Controllers/Forms/Integration/FormIntegrationsEventController.php b/app/Http/Controllers/Forms/Integration/FormIntegrationsEventController.php new file mode 100644 index 00000000..9f06c012 --- /dev/null +++ b/app/Http/Controllers/Forms/Integration/FormIntegrationsEventController.php @@ -0,0 +1,26 @@ +middleware('auth'); + } + + public function index(string $id, string $integrationid) + { + $form = Form::findOrFail((int)$id); + $this->authorize('view', $form); + + return FormIntegrationsEventResource::collection( + FormIntegrationsEvent::where('integration_id', (int)$integrationid)->orderByDesc('created_at')->get() + ); + } +} diff --git a/app/Http/Requests/Integration/FormIntegrationsRequest.php b/app/Http/Requests/Integration/FormIntegrationsRequest.php new file mode 100644 index 00000000..7174837d --- /dev/null +++ b/app/Http/Requests/Integration/FormIntegrationsRequest.php @@ -0,0 +1,84 @@ +integration_id) { + // Load integration class, and get rules + $integration = FormIntegration::getIntegration($request->integration_id); + if ($integration && isset($integration['file_name']) && class_exists( + 'App\Service\Forms\Integrations\\' . $integration['file_name'] + )) { + $this->integrationClassName = 'App\Service\Forms\Integrations\\' . $integration['file_name']; + $this->loadIntegrationRules(); + return; + } + throw new \Exception('Unknown Integration!'); + } + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return array_merge([ + 'integration_id' => ['required', Rule::in(array_keys(FormIntegration::getAllIntegrations()))], + 'settings' => 'present|array', + 'status' => 'required|boolean', + 'logic' => [new IntegrationLogicRule()] + ], $this->integrationRules); + } + + /** + * Give the validated fields a better "human-readable" name + * + * @return array + */ + public function attributes() + { + $fields = []; + foreach ($this->rules() as $key => $value) { + $fields[$key] = Str::of($key) + ->replace('settings.', '') + ->headline(); + } + + return $fields; + } + + private function loadIntegrationRules() + { + foreach ($this->integrationClassName::getValidationRules() as $key => $value) { + $this->integrationRules['settings.' . $key] = $value; + } + } + + public function toIntegrationData(): array + { + return $this->integrationClassName::formatData([ + 'status' => ($this->validated( + 'status' + )) ? FormIntegration::STATUS_ACTIVE : FormIntegration::STATUS_INACTIVE, + 'integration_id' => $this->validated('integration_id'), + 'data' => $this->validated('settings') ?? [], + 'logic' => $this->validated('logic') ?? [] + ]); + } +} diff --git a/app/Http/Resources/FormIntegrationsEventResource.php b/app/Http/Resources/FormIntegrationsEventResource.php new file mode 100644 index 00000000..0d2be4b1 --- /dev/null +++ b/app/Http/Resources/FormIntegrationsEventResource.php @@ -0,0 +1,23 @@ + date('Y-m-d H:i', strtotime($this->created_at)), + 'status' => ucfirst($this->status), + 'data' => $this->data + ]; + } +} diff --git a/app/Listeners/FailedWebhookListener.php b/app/Listeners/FailedWebhookListener.php index 97c4a686..0be93332 100644 --- a/app/Listeners/FailedWebhookListener.php +++ b/app/Listeners/FailedWebhookListener.php @@ -15,6 +15,7 @@ class FailedWebhookListener */ public function handle(WebhookCallFailedEvent $event) { + ray('in faieled', $event); // Notify form owner if ($event->meta['type'] == 'form_submission') { $event->meta['form']->creator->notify(new FailedWebhookNotification($event)); diff --git a/app/Listeners/Forms/FormIntegrationsEventListener.php b/app/Listeners/Forms/FormIntegrationsEventListener.php new file mode 100644 index 00000000..dca72eab --- /dev/null +++ b/app/Listeners/Forms/FormIntegrationsEventListener.php @@ -0,0 +1,26 @@ +formIntegrationsEvent->status === FormIntegrationsEvent::STATUS_ERROR) { + $form = $event->formIntegrationsEvent->integration->form; + Mail::to($form->creator)->send(new FormIntegrationsEventCreationConfirmationMail($event->formIntegrationsEvent)); + } + } +} diff --git a/app/Listeners/Forms/NotifyFormSubmission.php b/app/Listeners/Forms/NotifyFormSubmission.php index 9e2ab24e..ca362a3a 100644 --- a/app/Listeners/Forms/NotifyFormSubmission.php +++ b/app/Listeners/Forms/NotifyFormSubmission.php @@ -3,12 +3,10 @@ namespace App\Listeners\Forms; use App\Events\Forms\FormSubmitted; -use App\Models\Forms\Form; -use App\Notifications\Forms\FormSubmissionNotification; -use App\Service\Forms\Webhooks\WebhookHandlerProvider; +use App\Models\Integration\FormIntegration; +use App\Service\Forms\Integrations\AbstractIntegrationHandler; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Support\Facades\Notification; class NotifyFormSubmission implements ShouldQueue { @@ -22,48 +20,32 @@ class NotifyFormSubmission implements ShouldQueue */ public function handle(FormSubmitted $event) { - $this->sendEmailNotifications($event); + $formIntegrations = FormIntegration::where([['form_id', $event->form->id], ['status', FormIntegration::STATUS_ACTIVE]])->get(); + foreach ($formIntegrations as $formIntegration) { + ray($formIntegration, $formIntegration->integration_id); + $this->getIntegrationHandler( + $event, + $formIntegration + )->run(); + } + /* $this->sendEmailNotifications($event); $this->sendWebhookNotification($event, WebhookHandlerProvider::SIMPLE_WEBHOOK_PROVIDER); $this->sendWebhookNotification($event, WebhookHandlerProvider::SLACK_PROVIDER); $this->sendWebhookNotification($event, WebhookHandlerProvider::DISCORD_PROVIDER); foreach ($event->form->zappierHooks as $hook) { $hook->triggerHook($event->data); } + */ } - private function sendWebhookNotification(FormSubmitted $event, string $provider) + public static function getIntegrationHandler(FormSubmitted $event, FormIntegration $formIntegration): AbstractIntegrationHandler { - WebhookHandlerProvider::getProvider( - $event->form, - $event->data, - $provider - )->handle(); - } - - /** - * Sends an email to each email address in the form's notification_emails field - * - * @return void - */ - private function sendEmailNotifications(FormSubmitted $event) - { - if (! $event->form->is_pro || ! $event->form->notifies) { - return; + $integration = FormIntegration::getIntegration($formIntegration->integration_id); + if ($integration && isset($integration['file_name']) && class_exists('App\Service\Forms\Integrations\\' . $integration['file_name'])) { + $className = 'App\Service\Forms\Integrations\\' . $integration['file_name']; + return new $className($event, $formIntegration, $integration); } - - $subscribers = collect(preg_split("/\r\n|\n|\r/", $event->form->notification_emails))->filter(function ( - $email - ) { - return filter_var($email, FILTER_VALIDATE_EMAIL); - }); - \Log::debug('Sending email notification', [ - 'recipients' => $subscribers->toArray(), - 'form_id' => $event->form->id, - 'form_slug' => $event->form->slug, - ]); - $subscribers->each(function ($subscriber) use ($event) { - Notification::route('mail', $subscriber)->notify(new FormSubmissionNotification($event)); - }); + throw new \Exception('Unknown Integration!'); } } diff --git a/app/Listeners/Forms/SubmissionConfirmation.php b/app/Listeners/Forms/SubmissionConfirmation.php deleted file mode 100644 index ebfcf879..00000000 --- a/app/Listeners/Forms/SubmissionConfirmation.php +++ /dev/null @@ -1,94 +0,0 @@ -form->is_pro || - ! $event->form->send_submission_confirmation || - $this->riskLimitReached($event) // To avoid phishing abuse we limit this feature for risky users - ) { - return; - } - - $email = $this->getRespondentEmail($event); - if (! $email) { - return; - } - - \Log::info('Sending submission confirmation', [ - 'recipient' => $email, - 'form_id' => $event->form->id, - 'form_slug' => $event->form->slug, - ]); - Mail::to($email)->send(new SubmissionConfirmationMail($event)); - } - - private function getRespondentEmail(FormSubmitted $event) - { - // Make sure we only have one email field in the form - $emailFields = collect($event->form->properties)->filter(function ($field) { - $hidden = $field['hidden'] ?? false; - - return ! $hidden && $field['type'] == 'email'; - }); - if ($emailFields->count() != 1) { - return null; - } - - if (isset($event->data[$emailFields->first()['id']])) { - $email = $event->data[$emailFields->first()['id']]; - if ($this->validateEmail($email)) { - return $email; - } - } - - return null; - } - - private function riskLimitReached(FormSubmitted $event): bool - { - // This is a per-workspace limit for risky workspaces - if ($event->form->workspace->is_risky) { - if ($event->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) { - \Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [ - 'form_id' => $event->form->id, - 'workspace_id' => $event->form->workspace->id, - ]); - - return true; - } - } - - return false; - } - - public static function validateEmail($email): bool - { - return (bool) filter_var($email, FILTER_VALIDATE_EMAIL); - } -} diff --git a/app/Mail/Forms/FormIntegrationsEventCreationConfirmationMail.php b/app/Mail/Forms/FormIntegrationsEventCreationConfirmationMail.php new file mode 100644 index 00000000..dd68d87a --- /dev/null +++ b/app/Mail/Forms/FormIntegrationsEventCreationConfirmationMail.php @@ -0,0 +1,46 @@ +formIntegration = $formIntegrationsEvent->integration; + $this->form = $this->formIntegration->form; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $integration = FormIntegration::getIntegration($this->formIntegration->integration_id); + return $this + ->markdown('mail.form.integrations-event-created', [ + 'form' => $this->form, + 'integration_name' => $integration['name'] ?? '', + 'error' => json_encode($this->formIntegrationsEvent->data) + ])->subject("Integration issue with your form: '" . $this->form->title . "'"); + } +} diff --git a/app/Mail/Forms/SubmissionConfirmationMail.php b/app/Mail/Forms/SubmissionConfirmationMail.php index 52ec21f5..9cca8313 100644 --- a/app/Mail/Forms/SubmissionConfirmationMail.php +++ b/app/Mail/Forms/SubmissionConfirmationMail.php @@ -8,7 +8,6 @@ use App\Service\Forms\FormSubmissionFormatter; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Arr; use Illuminate\Support\Str; use Vinkla\Hashids\Facades\Hashids; @@ -22,7 +21,7 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue * * @return void */ - public function __construct(private FormSubmitted $event) + public function __construct(private FormSubmitted $event, private $integrationData) { } @@ -42,11 +41,12 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue return $this ->replyTo($this->getReplyToEmail($form->creator->email)) - ->from($this->getFromEmail(), $form->notification_sender) - ->subject($form->notification_subject) + ->from($this->getFromEmail(), $this->integrationData->notification_sender) + ->subject($this->integrationData->notification_subject) ->markdown('mail.form.confirmation-submission-notification', [ 'fields' => $formatter->getFieldsWithValue(), 'form' => $form, + 'integrationData' => $this->integrationData, 'noBranding' => $form->no_branding, 'submission_id' => (isset($this->event->data['submission_id']) && $this->event->data['submission_id']) ? Hashids::encode($this->event->data['submission_id']) : null, ]); @@ -56,12 +56,12 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue { $originalFromAddress = Str::of(config('mail.from.address'))->explode('@'); - return $originalFromAddress->first().'+'.time().'@'.$originalFromAddress->last(); + return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last(); } private function getReplyToEmail($default) { - $replyTo = Arr::get((array) $this->event->form->notification_settings, 'confirmation_reply_to', null); + $replyTo = $this->integrationData->confirmation_reply_to ?? null; if ($replyTo && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) { return $replyTo; diff --git a/app/Models/Integration/FormIntegration.php b/app/Models/Integration/FormIntegration.php new file mode 100644 index 00000000..37cfdafa --- /dev/null +++ b/app/Models/Integration/FormIntegration.php @@ -0,0 +1,51 @@ + 'object', + 'logic' => 'object' + ]; + + /** + * Relationships + */ + public function form() + { + return $this->belongsTo(Form::class); + } + + public function events() + { + return $this->hasMany(FormIntegrationsEvent::class, 'integration_id'); + } + + public static function getAllIntegrations() + { + return json_decode(file_get_contents(resource_path('data/forms/integrations.json')), true); + } + + public static function getIntegration($key) + { + return self::getAllIntegrations()[$key] ?? null; + } +} diff --git a/app/Models/Integration/FormIntegrationsEvent.php b/app/Models/Integration/FormIntegrationsEvent.php new file mode 100644 index 00000000..f402e791 --- /dev/null +++ b/app/Models/Integration/FormIntegrationsEvent.php @@ -0,0 +1,39 @@ + 'object' + ]; + + /** + * The event map for the model. + * + * @var array + */ + protected $dispatchesEvents = [ + 'created' => FormIntegrationsEventCreated::class, + ]; + + public function integration() + { + return $this->belongsTo(FormIntegration::class, 'integration_id'); + } +} diff --git a/app/Models/Integration/FormZapierWebhook.php b/app/Models/Integration/FormZapierWebhook.php index 77c57906..ec84a8c8 100644 --- a/app/Models/Integration/FormZapierWebhook.php +++ b/app/Models/Integration/FormZapierWebhook.php @@ -3,7 +3,7 @@ namespace App\Models\Integration; use App\Models\Forms\Form; -use App\Service\Forms\Webhooks\WebhookHandlerProvider; +use App\Service\Forms\Integrations\WebhookHandlerProvider; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; diff --git a/app/Notifications/Forms/FormSubmissionNotification.php b/app/Notifications/Forms/FormSubmissionNotification.php index 1e2f956d..0fc2742a 100644 --- a/app/Notifications/Forms/FormSubmissionNotification.php +++ b/app/Notifications/Forms/FormSubmissionNotification.php @@ -8,7 +8,6 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -use Illuminate\Support\Arr; use Illuminate\Support\Str; class FormSubmissionNotification extends Notification implements ShouldQueue @@ -22,7 +21,7 @@ class FormSubmissionNotification extends Notification implements ShouldQueue * * @return void */ - public function __construct(FormSubmitted $event) + public function __construct(FormSubmitted $event, private $integrationData) { $this->event = $event; } @@ -55,7 +54,7 @@ class FormSubmissionNotification extends Notification implements ShouldQueue return (new MailMessage()) ->replyTo($this->getReplyToEmail($notifiable->routes['mail'])) ->from($this->getFromEmail(), config('app.name')) - ->subject('New form submission for "'.$this->event->form->title.'"') + ->subject('New form submission for "' . $this->event->form->title . '"') ->markdown('mail.form.submission-notification', [ 'fields' => $formatter->getFieldsWithValue(), 'form' => $this->event->form, @@ -66,12 +65,12 @@ class FormSubmissionNotification extends Notification implements ShouldQueue { $originalFromAddress = Str::of(config('mail.from.address'))->explode('@'); - return $originalFromAddress->first().'+'.time().'@'.$originalFromAddress->last(); + return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last(); } private function getReplyToEmail($default) { - $replyTo = Arr::get((array) $this->event->form->notification_settings, 'notification_reply_to', null); + $replyTo = $this->integrationData->notification_reply_to ?? null; if ($replyTo && $this->validateEmail($replyTo)) { return $replyTo; } @@ -85,7 +84,7 @@ class FormSubmissionNotification extends Notification implements ShouldQueue $emailFields = collect($this->event->form->properties)->filter(function ($field) { $hidden = $field['hidden'] ?? false; - return ! $hidden && $field['type'] == 'email'; + return !$hidden && $field['type'] == 'email'; }); if ($emailFields->count() != 1) { return null; diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 38e2e28e..c7534816 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,14 +4,14 @@ namespace App\Providers; use App\Events\Forms\FormSubmitted; use App\Events\Models\FormCreated; +use App\Events\Models\FormIntegrationsEventCreated; use App\Listeners\FailedWebhookListener; use App\Listeners\Forms\FormCreationConfirmation; +use App\Listeners\Forms\FormIntegrationsEventListener; use App\Listeners\Forms\NotifyFormSubmission; -use App\Listeners\Forms\SubmissionConfirmation; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; -use Illuminate\Support\Facades\Event; use Spatie\WebhookServer\Events\WebhookCallFailedEvent; class EventServiceProvider extends ServiceProvider @@ -29,12 +29,14 @@ class EventServiceProvider extends ServiceProvider FormCreationConfirmation::class, ], FormSubmitted::class => [ - NotifyFormSubmission::class, - SubmissionConfirmation::class, + NotifyFormSubmission::class ], WebhookCallFailedEvent::class => [ FailedWebhookListener::class, ], + FormIntegrationsEventCreated::class => [ + FormIntegrationsEventListener::class, + ], ]; /** diff --git a/app/Rules/IntegrationLogicRule.php b/app/Rules/IntegrationLogicRule.php new file mode 100644 index 00000000..ffd596ba --- /dev/null +++ b/app/Rules/IntegrationLogicRule.php @@ -0,0 +1,186 @@ +isConditionCorrect = false; + $this->conditionErrors[] = 'missing condition body'; + + return; + } + + if (!isset($condition['value']['property_meta'])) { + $this->isConditionCorrect = false; + $this->conditionErrors[] = 'missing condition property'; + + return; + } + + if (!isset($condition['value']['property_meta']['type'])) { + $this->isConditionCorrect = false; + $this->conditionErrors[] = 'missing condition property type'; + + return; + } + + if (!isset($condition['value']['operator'])) { + $this->isConditionCorrect = false; + $this->conditionErrors[] = 'missing condition operator'; + + return; + } + + if (!isset($condition['value']['value'])) { + $this->isConditionCorrect = false; + $this->conditionErrors[] = 'missing condition value'; + + return; + } + + $typeField = $condition['value']['property_meta']['type']; + $operator = $condition['value']['operator']; + $value = $condition['value']['value']; + + if (!isset(FormPropertyLogicRule::CONDITION_MAPPING[$typeField])) { + $this->isConditionCorrect = false; + $this->conditionErrors[] = 'configuration not found for condition type'; + + return; + } + + if (!isset(FormPropertyLogicRule::CONDITION_MAPPING[$typeField]['comparators'][$operator])) { + $this->isConditionCorrect = false; + $this->conditionErrors[] = 'configuration not found for condition operator'; + + return; + } + + $type = FormPropertyLogicRule::CONDITION_MAPPING[$typeField]['comparators'][$operator]['expected_type']; + + if (is_array($type)) { + $foundCorrectType = false; + foreach ($type as $subtype) { + if ($this->valueHasCorrectType($subtype, $value)) { + $foundCorrectType = true; + } + } + if (!$foundCorrectType) { + $this->isConditionCorrect = false; + } + } else { + if (!$this->valueHasCorrectType($type, $value)) { + $this->isConditionCorrect = false; + $this->conditionErrors[] = 'wrong type of condition value'; + } + } + } + + private function valueHasCorrectType($type, $value) + { + if ( + ($type === 'string' && gettype($value) !== 'string') || + ($type === 'boolean' && !is_bool($value)) || + ($type === 'number' && !is_numeric($value)) || + ($type === 'object' && !is_array($value)) + ) { + return false; + } + + return true; + } + + private function checkConditions($conditions) + { + if (array_key_exists('operatorIdentifier', $conditions)) { + if (($conditions['operatorIdentifier'] !== 'and') && ($conditions['operatorIdentifier'] !== 'or')) { + $this->conditionErrors[] = 'missing operator'; + $this->isConditionCorrect = false; + + return; + } + + if (isset($conditions['operatorIdentifier']['children'])) { + $this->conditionErrors[] = 'extra condition'; + $this->isConditionCorrect = false; + + return; + } + + if (!is_array($conditions['children'])) { + $this->conditionErrors[] = 'wrong sub-condition type'; + $this->isConditionCorrect = false; + + return; + } + + foreach ($conditions['children'] as &$child) { + $this->checkConditions($child); + } + } elseif (isset($conditions['identifier'])) { + $this->checkBaseCondition($conditions); + } + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function passes($attribute, $value) + { + if (isset($value)) { + $this->checkConditions($value); + } + + return $this->isConditionCorrect; + } + + /** + * Get the validation error message. + */ + public function message() + { + $message = null; + if (!$this->isConditionCorrect) { + $message = 'The logic conditions are not complete.'; + } + if (count($this->conditionErrors) > 0) { + return $message . ' Error detail(s): ' . implode(', ', $this->conditionErrors); + } + + return $message; + } + + /** + * Set the data under validation. + * + * @param array $data + * @return $this + */ + public function setData($data) + { + $this->data = $data; + $this->isConditionCorrect = true; + $this->conditionErrors = []; + + return $this; + } +} diff --git a/app/Service/Forms/FormCleaner.php b/app/Service/Forms/FormCleaner.php index d609eaed..ebc2f3e6 100644 --- a/app/Service/Forms/FormCleaner.php +++ b/app/Service/Forms/FormCleaner.php @@ -28,12 +28,8 @@ class FormCleaner private array $customKeys = ['seo_meta']; private array $formDefaults = [ - 'notifies' => false, 'no_branding' => false, - 'webhook_url' => null, 'database_fields_update' => null, - 'slack_webhook_url' => null, - 'discord_webhook_url' => null, 'editable_submissions' => false, 'custom_code' => null, 'seo_meta' => [], @@ -46,12 +42,8 @@ class FormCleaner private array $cleaningMessages = [ // For form - 'notifies' => 'Email notification were disabled.', 'no_branding' => 'OpenForm branding is not hidden.', - 'webhook_url' => 'Webhook disabled.', 'database_fields_update' => 'Form submission will only create new records (no updates).', - 'slack_webhook_url' => 'Slack webhook disabled.', - 'discord_webhook_url' => 'Discord webhook disabled.', 'editable_submissions' => 'Users will not be able to edit their submissions.', 'custom_code' => 'Custom code was disabled', 'seo_meta' => 'Custom SEO was disabled', @@ -126,7 +118,7 @@ class FormCleaner */ public function simulateCleaning(Workspace $workspace): FormCleaner { - if (! $this->isPro($workspace)) { + if (!$this->isPro($workspace)) { $this->data = $this->removeProFeatures($this->data, true); } @@ -141,7 +133,7 @@ class FormCleaner */ public function performCleaning(Workspace $workspace): FormCleaner { - if (! $this->isPro($workspace)) { + if (!$this->isPro($workspace)) { $this->data = $this->removeProFeatures($this->data); } @@ -217,14 +209,14 @@ class FormCleaner $formVal = (($formVal === 0 || $formVal === '0') ? false : $formVal); $formVal = (($formVal === 1 || $formVal === '1') ? true : $formVal); - if (! is_null($formVal) && $formVal !== $value) { - if (! isset($this->cleanings['form'])) { + if (!is_null($formVal) && $formVal !== $value) { + if (!isset($this->cleanings['form'])) { $this->cleanings['form'] = []; } $this->cleanings['form'][] = $key; // If not a simulation, do the cleaning - if (! $simulation) { + if (!$simulation) { Arr::set($data, $key, $value); } } @@ -236,7 +228,7 @@ class FormCleaner foreach ($defaults as $key => $value) { if (isset($data[$key]) && Arr::get($data, $key) !== $value) { $this->cleanings[$data['name']][] = $key; - if (! $simulation) { + if (!$simulation) { Arr::set($data, $key, $value); } } diff --git a/app/Service/Forms/Integrations/AbstractIntegrationHandler.php b/app/Service/Forms/Integrations/AbstractIntegrationHandler.php new file mode 100644 index 00000000..344b63e2 --- /dev/null +++ b/app/Service/Forms/Integrations/AbstractIntegrationHandler.php @@ -0,0 +1,131 @@ +form = $event->form; + $this->submissionData = $event->data; + $this->integrationData = $formIntegration->data; + } + + protected function getProviderName(): string + { + return $this->integration['name'] ?? ''; + } + + protected function logicConditionsMet(): bool + { + if (!$this->formIntegration->logic) { + return true; + } + return FormLogicConditionChecker::conditionsMet( + json_decode(json_encode($this->formIntegration->logic), true), + $this->submissionData + ); + } + + protected function shouldRun(): bool + { + return $this->logicConditionsMet(); + } + + protected function getWebhookUrl(): ?string + { + return ''; + } + + /** + * Default webhook payload. Can be changed in child classes. + */ + protected function getWebhookData(): array + { + $formatter = (new FormSubmissionFormatter($this->form, $this->submissionData)) + ->useSignedUrlForFiles() + ->showHiddenFields(); + + $formattedData = []; + foreach ($formatter->getFieldsWithValue() as $field) { + $formattedData[$field['name']] = $field['value']; + } + + $data = [ + 'form_title' => $this->form->title, + 'form_slug' => $this->form->slug, + 'submission' => $formattedData, + ]; + if ($this->form->is_pro && $this->form->editable_submissions) { + $data['edit_link'] = $this->form->share_url . '?submission_id=' . Hashids::encode( + $this->submissionData['submission_id'] + ); + } + + return $data; + } + + final public function run(): void + { + try { + $this->handle(); + $this->formIntegration->events()->create([ + 'status' => FormIntegrationsEvent::STATUS_SUCCESS, + ]); + } catch (\Exception $e) { + $this->formIntegration->events()->create([ + 'status' => FormIntegrationsEvent::STATUS_ERROR, + 'data' => $this->extractEventDataFromException($e), + ]); + } + } + + /** + * Default handle. Can be changed in child classes. + */ + public function handle(): void + { + if (!$this->shouldRun()) { + return; + } + + Http::throw()->post($this->getWebhookUrl(), $this->getWebhookData()); + } + + abstract public static function getValidationRules(): array; + + public static function formatData(array $data): array + { + return $data; + } + + public function extractEventDataFromException(\Exception $e): array + { + if ($e instanceof RequestException) { + return [ + 'message' => $e->getMessage(), + 'response' => $e->response->json(), + 'status' => $e->response->status(), + ]; + } + return [ + 'message' => $e->getMessage() + ]; + } +} diff --git a/app/Service/Forms/Webhooks/AbstractWebhookHandler.php b/app/Service/Forms/Integrations/AbstractWebhookHandler.php similarity index 97% rename from app/Service/Forms/Webhooks/AbstractWebhookHandler.php rename to app/Service/Forms/Integrations/AbstractWebhookHandler.php index 1e7b565b..bee7f1fd 100644 --- a/app/Service/Forms/Webhooks/AbstractWebhookHandler.php +++ b/app/Service/Forms/Integrations/AbstractWebhookHandler.php @@ -1,6 +1,6 @@ 'required|url|starts_with:https://discord.com/api/webhooks', + 'include_submission_data' => 'boolean', + 'link_open_form' => 'boolean', + 'link_edit_form' => 'boolean', + 'views_submissions_count' => 'boolean', + 'link_edit_submission' => 'boolean' + ]; + } + + protected function getWebhookUrl(): ?string + { + return $this->integrationData->discord_webhook_url; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()) && $this->form->is_pro && parent::shouldRun(); + } + + protected function getWebhookData(): array + { + $settings = (array) $this->integrationData ?? []; + $externalLinks = []; + if (Arr::get($settings, 'link_open_form', true)) { + $externalLinks[] = '[**🔗 Open Form**](' . $this->form->share_url . ')'; + } + if (Arr::get($settings, 'link_edit_form', true)) { + $editFormURL = front_url('forms/' . $this->form->slug . '/show'); + $externalLinks[] = '[**✍️ Edit Form**](' . $editFormURL . ')'; + } + if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) { + $submissionId = Hashids::encode($this->submissionData['submission_id']); + $externalLinks[] = '[**✍️ ' . $this->form->editable_submissions_button_text . '**](' . $this->form->share_url . '?submission_id=' . $submissionId . ')'; + } + + $color = hexdec(str_replace('#', '', $this->form->color)); + $blocks = []; + if (Arr::get($settings, 'include_submission_data', true)) { + $submissionString = ''; + $formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly(); + foreach ($formatter->getFieldsWithValue() as $field) { + $tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value']; + $submissionString .= '**' . ucfirst($field['name']) . '**: ' . $tmpVal . "\n"; + } + $blocks[] = [ + 'type' => 'rich', + 'color' => $color, + 'description' => $submissionString, + ]; + } + + if (Arr::get($settings, 'views_submissions_count', true)) { + $countString = '**👀 Views**: ' . (string) $this->form->views_count . " \n"; + $countString .= '**🖊️ Submissions**: ' . (string) $this->form->submissions_count; + $blocks[] = [ + 'type' => 'rich', + 'color' => $color, + 'description' => $countString, + ]; + } + + if (count($externalLinks) > 0) { + $blocks[] = [ + 'type' => 'rich', + 'color' => $color, + 'description' => implode(' - ', $externalLinks), + ]; + } + + return [ + 'content' => 'New submission for your form **' . $this->form->title . '**', + 'tts' => false, + 'username' => config('app.name'), + 'avatar_url' => asset('img/logo.png'), + 'embeds' => $blocks, + ]; + } +} diff --git a/app/Service/Forms/Integrations/EmailIntegration.php b/app/Service/Forms/Integrations/EmailIntegration.php new file mode 100644 index 00000000..5a4942c6 --- /dev/null +++ b/app/Service/Forms/Integrations/EmailIntegration.php @@ -0,0 +1,46 @@ + ['required', new OneEmailPerLine()], + 'notification_reply_to' => 'email|nullable', + ]; + } + + protected function shouldRun(): bool + { + return !(!$this->form->is_pro || !$this->integrationData->notification_emails) && parent::shouldRun(); + } + + public function handle(): void + { + if (!$this->shouldRun()) { + return; + } + + $subscribers = collect(preg_split("/\r\n|\n|\r/", $this->integrationData->notification_emails)) + ->filter(function ($email) { + return filter_var($email, FILTER_VALIDATE_EMAIL); + }); + Log::debug('Sending email notification', [ + 'recipients' => $subscribers->toArray(), + 'form_id' => $this->form->id, + 'form_slug' => $this->form->slug, + ]); + $subscribers->each(function ($subscriber) { + Notification::route('mail', $subscriber)->notify( + new FormSubmissionNotification($this->event, $this->integrationData) + ); + }); + } +} diff --git a/app/Service/Forms/Webhooks/SimpleWebhookHandler.php b/app/Service/Forms/Integrations/SimpleWebhookHandler.php similarity index 90% rename from app/Service/Forms/Webhooks/SimpleWebhookHandler.php rename to app/Service/Forms/Integrations/SimpleWebhookHandler.php index 3b3ea537..1d2b9cea 100644 --- a/app/Service/Forms/Webhooks/SimpleWebhookHandler.php +++ b/app/Service/Forms/Integrations/SimpleWebhookHandler.php @@ -1,6 +1,6 @@ 'required|url|starts_with:https://hooks.slack.com/', + 'include_submission_data' => 'boolean', + 'link_open_form' => 'boolean', + 'link_edit_form' => 'boolean', + 'views_submissions_count' => 'boolean', + 'link_edit_submission' => 'boolean' + ]; + } + + protected function getWebhookUrl(): ?string + { + return $this->integrationData->slack_webhook_url; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()) && $this->form->is_pro && parent::shouldRun(); + } + + protected function getWebhookData(): array + { + $settings = (array) $this->integrationData ?? []; + $externalLinks = []; + if (Arr::get($settings, 'link_open_form', true)) { + $externalLinks[] = '*<' . $this->form->share_url . '|🔗 Open Form>*'; + } + if (Arr::get($settings, 'link_edit_form', true)) { + $editFormURL = front_url('forms/' . $this->form->slug . '/show'); + $externalLinks[] = '*<' . $editFormURL . '|✍️ Edit Form>*'; + } + if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) { + $submissionId = Hashids::encode($this->submissionData['submission_id']); + $externalLinks[] = '*<' . $this->form->share_url . '?submission_id=' . $submissionId . '|✍️ ' . $this->form->editable_submissions_button_text . '>*'; + } + + $blocks = [ + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => 'New submission for your form *' . $this->form->title . '*', + ], + ], + ]; + + if (Arr::get($settings, 'include_submission_data', true)) { + $submissionString = ''; + $formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly(); + foreach ($formatter->getFieldsWithValue() as $field) { + $tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value']; + $submissionString .= '>*' . ucfirst($field['name']) . '*: ' . $tmpVal . " \n"; + } + $blocks[] = [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => $submissionString, + ], + ]; + } + + if (Arr::get($settings, 'views_submissions_count', true)) { + $countString = '*👀 Views*: ' . (string) $this->form->views_count . " \n"; + $countString .= '*🖊️ Submissions*: ' . (string) $this->form->submissions_count; + $blocks[] = [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => $countString, + ], + ]; + } + + if (count($externalLinks) > 0) { + $blocks[] = [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => implode(' ', $externalLinks), + ], + ]; + } + + return [ + 'blocks' => $blocks, + ]; + } +} diff --git a/app/Service/Forms/Integrations/SubmissionConfirmationIntegration.php b/app/Service/Forms/Integrations/SubmissionConfirmationIntegration.php new file mode 100644 index 00000000..cc84eed1 --- /dev/null +++ b/app/Service/Forms/Integrations/SubmissionConfirmationIntegration.php @@ -0,0 +1,103 @@ + 'email|nullable', + 'notification_sender' => 'required', + 'notification_subject' => 'required', + 'notification_body' => 'required', + 'notifications_include_submission' => 'boolean' + ]; + } + + protected function shouldRun(): bool + { + return !(!$this->form->is_pro) && parent::shouldRun() && !$this->riskLimitReached(); + } + + public function handle(): void + { + if (!$this->shouldRun()) { + return; + } + + $email = $this->getRespondentEmail(); + if (!$email) { + return; + } + + Log::info('Sending submission confirmation', [ + 'recipient' => $email, + 'form_id' => $this->form->id, + 'form_slug' => $this->form->slug, + ]); + Mail::to($email)->send(new SubmissionConfirmationMail($this->event, $this->integrationData)); + } + + private function getRespondentEmail() + { + // Make sure we only have one email field in the form + $emailFields = collect($this->form->properties)->filter(function ($field) { + $hidden = $field['hidden'] ?? false; + + return !$hidden && $field['type'] == 'email'; + }); + if ($emailFields->count() != 1) { + return null; + } + + if (isset($this->submissionData[$emailFields->first()['id']])) { + $email = $this->submissionData[$emailFields->first()['id']]; + if ($this->validateEmail($email)) { + return $email; + } + } + + return null; + } + + // To avoid phishing abuse we limit this feature for risky users + private function riskLimitReached(): bool + { + // This is a per-workspace limit for risky workspaces + if ($this->form->workspace->is_risky) { + if ($this->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) { + Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [ + 'form_id' => $this->form->id, + 'workspace_id' => $this->form->workspace->id, + ]); + + return true; + } + } + + return false; + } + + public static function validateEmail($email): bool + { + return (bool)filter_var($email, FILTER_VALIDATE_EMAIL); + } + + public static function formatData(array $data): array + { + return array_merge(parent::formatData($data), [ + 'notification_body' => Purify::clean($data['notification_body'] ?? ''), + ]); + } +} diff --git a/app/Service/Forms/Webhooks/WebhookHandlerProvider.php b/app/Service/Forms/Integrations/WebhookHandlerProvider.php similarity index 96% rename from app/Service/Forms/Webhooks/WebhookHandlerProvider.php rename to app/Service/Forms/Integrations/WebhookHandlerProvider.php index c4441d53..1c2efd9c 100644 --- a/app/Service/Forms/Webhooks/WebhookHandlerProvider.php +++ b/app/Service/Forms/Integrations/WebhookHandlerProvider.php @@ -1,6 +1,6 @@ 'required|url' + ]; + } + + protected function getWebhookUrl(): ?string + { + return $this->integrationData->webhook_url; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()) && parent::shouldRun(); + } +} diff --git a/app/Service/Forms/Webhooks/ZapierHandler.php b/app/Service/Forms/Integrations/ZapierHandler.php similarity index 92% rename from app/Service/Forms/Webhooks/ZapierHandler.php rename to app/Service/Forms/Integrations/ZapierHandler.php index ab5ff178..4d3c8013 100644 --- a/app/Service/Forms/Webhooks/ZapierHandler.php +++ b/app/Service/Forms/Integrations/ZapierHandler.php @@ -1,6 +1,6 @@ -