From 8fb842fe8b89241f8c72d60efb3eea257c18a9a0 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Tue, 18 Jul 2023 16:53:41 +0530 Subject: [PATCH 01/16] Enable Pro plan - WIP --- .../Controllers/SubscriptionController.php | 26 +- app/Http/Middleware/Form/ProForm.php | 2 +- app/Http/Requests/AnswerFormRequest.php | 22 +- .../UpdateStripeDetailsRequest.php | 21 + app/Models/Workspace.php | 8 +- app/Service/Forms/FormCleaner.php | 90 +- resources/js/components/Navbar.vue | 12 +- resources/js/components/common/ProTag.vue | 13 +- .../js/components/forms/ToggleSwitchInput.vue | 4 +- .../js/components/open/forms/OpenForm.vue | 6 +- .../forms/components/FormFieldsEditor.vue | 5 - .../open/forms/components/FormStats.vue | 4 +- .../form-components/FormAboutSubmission.vue | 10 +- .../form-components/FormCustomization.vue | 10 +- .../form-components/FormSecurityPrivacy.vue | 1 - .../FormBlockLogicEditor.vue | 1 - .../forms/fields/FormFieldOptionsModal.vue | 5 - .../pages/forms/show/UrlFormPrefill.vue | 2 - .../pages/pricing/CheckoutDetailsModal.vue | 94 +++ .../pages/pricing/MonthlyYearlySelector.vue | 41 + .../components/pages/pricing/PricingTable.vue | 791 ++++++++++++++++++ resources/js/pages/forms/show/index.vue | 2 +- resources/js/pages/pricing.vue | 156 ++++ resources/js/pages/settings/index.vue | 2 +- resources/js/router/routes.js | 1 + resources/views/spa.blade.php | 3 +- routes/api.php | 1 + 27 files changed, 1225 insertions(+), 108 deletions(-) create mode 100644 app/Http/Requests/Subscriptions/UpdateStripeDetailsRequest.php create mode 100644 resources/js/components/pages/pricing/CheckoutDetailsModal.vue create mode 100644 resources/js/components/pages/pricing/MonthlyYearlySelector.vue create mode 100644 resources/js/components/pages/pricing/PricingTable.vue create mode 100644 resources/js/pages/pricing.vue diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 5ffcb2720..f34311979 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -2,13 +2,14 @@ namespace App\Http\Controllers; +use App\Http\Requests\Subscriptions\UpdateStripeDetailsRequest; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; use Laravel\Cashier\Subscription; class SubscriptionController extends Controller { - const SUBSCRIPTION_PLANS = ['monthly_2022', 'yearly_2022']; + const SUBSCRIPTION_PLANS = ['monthly', 'yearly']; const PRO_SUBSCRIPTION_NAME = 'default'; const ENTERPRISE_SUBSCRIPTION_NAME = 'enterprise'; @@ -41,7 +42,7 @@ public function checkout($pricing, $plan, $trial = null) ->allowPromotionCodes(); if ($trial != null) { - $checkoutBuilder->trialDays(3); + $checkoutBuilder->trialUntil(now()->addDays(3)->addHour()); } $checkout = $checkoutBuilder @@ -49,6 +50,11 @@ public function checkout($pricing, $plan, $trial = null) ->checkout([ 'success_url' => url('/subscriptions/success'), 'cancel_url' => url('/subscriptions/error'), + 'billing_address_collection' => 'required', + 'customer_update' => [ + 'address' => 'auto', + 'name' => 'never', + ] ]); return $this->success([ @@ -56,6 +62,22 @@ public function checkout($pricing, $plan, $trial = null) ]); } + public function updateStripeDetails(UpdateStripeDetailsRequest $request) + { + $user = Auth::user(); + if (!$user->hasStripeId()) { + $user->createAsStripeCustomer(); + } + $user->updateStripeCustomer([ + 'email' => $request->email, + 'name' => $request->name, + ]); + + return $this->success([ + 'message' => 'Details saved.', + ]); + } + public function billingPortal() { $this->middleware('auth'); diff --git a/app/Http/Middleware/Form/ProForm.php b/app/Http/Middleware/Form/ProForm.php index c92417346..2f4732279 100644 --- a/app/Http/Middleware/Form/ProForm.php +++ b/app/Http/Middleware/Form/ProForm.php @@ -18,7 +18,7 @@ class ProForm public function handle(Request $request, Closure $next) { if ($request->route('formId') && $form = Form::findOrFail($request->route('formId'))) { - if ($form->is_pro) { + if (true || $form->is_pro) { // For now it's FREE for all $request->merge([ 'form' => $form, ]); diff --git a/app/Http/Requests/AnswerFormRequest.php b/app/Http/Requests/AnswerFormRequest.php index b5bd53f9b..8b59b492c 100644 --- a/app/Http/Requests/AnswerFormRequest.php +++ b/app/Http/Requests/AnswerFormRequest.php @@ -14,8 +14,8 @@ class AnswerFormRequest extends FormRequest { - const MAX_FILE_SIZE_PRO = 5000000; - const MAX_FILE_SIZE_ENTERPRISE = 20000000; + const MAX_FILE_SIZE_FREE = 5000000; // 5 MB + const MAX_FILE_SIZE_PRO = 50000000; // 50 MB public Form $form; @@ -26,10 +26,10 @@ public function __construct(Request $request) { $this->form = $request->form; - $this->maxFileSize = self::MAX_FILE_SIZE_PRO; + $this->maxFileSize = self::MAX_FILE_SIZE_FREE; $workspace = $this->form->workspace; - if ($workspace && $workspace->is_enterprise) { - $this->maxFileSize = self::MAX_FILE_SIZE_ENTERPRISE; + if ($workspace && $workspace->is_pro) { + $this->maxFileSize = self::MAX_FILE_SIZE_PRO; } } @@ -53,9 +53,9 @@ public function rules() foreach ($this->form->properties as $property) { $rules = []; - if (!$this->form->is_pro) { // If not pro then not check logic + /*if (!$this->form->is_pro) { // If not pro then not check logic $property['logic'] = false; - } + }*/ // For get values instead of Id for select/multi select options $data = $this->toArray(); @@ -96,12 +96,12 @@ public function rules() } // Validate hCaptcha - if ($this->form->is_pro && $this->form->use_captcha) { + if ($this->form->use_captcha) { $this->requestRules['h-captcha-response'] = [new ValidHCaptcha()]; } // Validate submission_id for edit mode - if ($this->form->editable_submissions) { + if ($this->form->is_pro && $this->form->editable_submissions) { $this->requestRules['submission_id'] = 'string'; } @@ -160,7 +160,7 @@ private function getPropertyRules($property): array return ['numeric']; case 'select': case 'multi_select': - if ($this->form->is_pro && ($property['allow_creation'] ?? false)) { + if (($property['allow_creation'] ?? false)) { return ['string']; } return [Rule::in($this->getSelectPropertyOptions($property))]; @@ -174,7 +174,7 @@ private function getPropertyRules($property): array return ['url']; case 'files': $allowedFileTypes = []; - if($this->form->is_pro && !empty($property['allowed_file_types'])){ + if(!empty($property['allowed_file_types'])){ $allowedFileTypes = explode(",", $property['allowed_file_types']); } $this->requestRules[$property['id'].'.*'] = [new StorageFile($this->maxFileSize, $allowedFileTypes, $this->form)]; diff --git a/app/Http/Requests/Subscriptions/UpdateStripeDetailsRequest.php b/app/Http/Requests/Subscriptions/UpdateStripeDetailsRequest.php new file mode 100644 index 000000000..1dcbbea59 --- /dev/null +++ b/app/Http/Requests/Subscriptions/UpdateStripeDetailsRequest.php @@ -0,0 +1,21 @@ + + */ + public function rules() + { + return [ + 'name' => 'required|string', + 'email' => 'required|email', + ]; + } +} diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index a27977fe1..2262b3851 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -24,7 +24,9 @@ class Workspace extends Model public function getIsProAttribute() { - return true; // Temporary true for ALL + if(is_null(config('cashier.key'))){ + return true; // If no paid plan so TRUE for ALL + } // Make sure at least one owner is pro foreach ($this->owners as $owner) { @@ -37,7 +39,9 @@ public function getIsProAttribute() public function getIsEnterpriseAttribute() { - return true; // Temporary true for ALL + if(is_null(config('cashier.key'))){ + return true; // If no paid plan so TRUE for ALL + } foreach ($this->owners as $owner) { if ($owner->has_enterprise_subscription) { diff --git a/app/Service/Forms/FormCleaner.php b/app/Service/Forms/FormCleaner.php index b93bc6026..4739f00d6 100644 --- a/app/Service/Forms/FormCleaner.php +++ b/app/Service/Forms/FormCleaner.php @@ -11,7 +11,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Arr; use Stevebauman\Purify\Facades\Purify; -use function App\Service\str_starts_with; use function collect; class FormCleaner @@ -26,76 +25,33 @@ class FormCleaner private array $formDefaults = [ 'notifies' => false, - 'color' => '#3B82F6', - 'hide_title' => false, 'no_branding' => false, - 'transparent_background' => false, - 'uppercase_labels' => true, 'webhook_url' => null, - 'cover_picture' => null, - 'logo_picture' => null, 'database_fields_update' => null, - 'theme' => 'default', - 'use_captcha' => false, - 'password' => null, 'slack_webhook_url' => null, 'discord_webhook_url' => null, + 'editable_submissions' => false, ]; private array $fieldDefaults = [ // 'name' => '' TODO: prevent name changing, use alias for column and keep original name as it is - 'hide_field_name' => false, - 'prefill' => null, - 'placeholder' => null, - 'help' => null, 'file_upload' => false, - 'with_time' => null, - 'width' => 'full', - 'generates_uuid' => false, - 'generates_auto_increment_id' => false, - 'logic' => null, - 'allow_creation' => false ]; private array $cleaningMessages = [ // For form 'notifies' => "Email notification were disabled.", - 'color' => "Form color set to default blue.", - 'hide_title' => "Title is not hidden.", 'no_branding' => "OpenForm branding is not hidden.", - 'transparent_background' => "Transparent background was disabled.", - 'uppercase_labels' => "Labels use uppercase letters", 'webhook_url' => "Webhook disabled.", - 'cover_picture' => 'The cover picture was removed.', - 'logo_picture' => 'The logo was removed.', 'database_fields_update' => 'Form submission will only create new records (no updates).', - 'theme' => 'Default theme was applied.', 'slack_webhook_url' => "Slack webhook disabled.", 'discord_webhook_url' => "Discord webhook disabled.", + 'editable_submissions' => 'Users will not be able to edit their submissions.', // For fields - 'hide_field_name' => 'Hide field name removed.', - 'prefill' => "Field prefill removed.", - 'placeholder' => "Empty text (placeholder) removed", - 'help' => "Help text removed.", 'file_upload' => "Link field is not a file upload.", - 'with_time' => "Time was removed from date input.", 'custom_block' => 'The custom block was removed.', 'files' => 'The file upload file was hidden.', - 'relation' => 'The relation file was hidden.', - 'width' => 'The field width was set to full width', - 'allow_creation' => 'Select option creation was disabled.', - - // Advanced fields - 'generates_uuid' => 'ID generation disabled.', - 'generates_auto_increment_id' => 'ID generation disabled.', - - 'use_captcha' => 'Captcha form protection was disabled.', - - // Security & Privacy - 'password' => 'Password protection was disabled', - - 'logic' => 'Logic disabled for this property' ]; /** @@ -144,7 +100,8 @@ public function processRequest(UserFormRequest $request): FormCleaner /** * Create form cleaner instance from existing form */ - public function processForm(Request $request, Form $form) : FormCleaner { + public function processForm(Request $request, Form $form) : FormCleaner + { $data = (new FormResource($form))->toArray($request); $this->data = $this->commonCleaning($data); @@ -159,10 +116,11 @@ private function isPro(Workspace $workspace) { * Dry run celanings * @param User|null $user */ - public function simulateCleaning(Workspace $workspace): FormCleaner { - if($this->isPro($workspace)) return $this; - - $this->data = $this->removeProFeatures($this->data, true); + public function simulateCleaning(Workspace $workspace): FormCleaner + { + if (!$this->isPro($workspace)) { + $this->data = $this->removeProFeatures($this->data, true); + } return $this; } @@ -174,9 +132,9 @@ public function simulateCleaning(Workspace $workspace): FormCleaner { */ public function performCleaning(Workspace $workspace): FormCleaner { - if($this->isPro($workspace)) return $this; - - $this->data = $this->removeProFeatures($this->data); + if (!$this->isPro($workspace)) { + $this->data = $this->removeProFeatures($this->data); + } return $this; } @@ -212,6 +170,7 @@ private function cleanForm(array &$data, $simulation = false): void private function cleanProperties(array &$data, $simulation = false): void { foreach ($data['properties'] as $key => &$property) { + /* // Remove pro custom blocks if (\Str::of($property['type'])->startsWith('nf-')) { $this->cleanings[$property['name']][] = 'custom_block'; @@ -221,6 +180,15 @@ private function cleanProperties(array &$data, $simulation = false): void continue; } + // Remove logic + if (($property['logic']['conditions'] ?? null) != null || ($property['logic']['actions'] ?? []) != []) { + $this->cleanings[$property['name']][] = 'logic'; + if (!$simulation) { + unset($data['properties'][$key]['logic']); + } + } + */ + // Clean pro field options $this->cleanField($property, $this->fieldDefaults, $simulation); } @@ -229,8 +197,18 @@ private function cleanProperties(array &$data, $simulation = false): void private function clean(array &$data, array $defaults, $simulation = false): void { foreach ($defaults as $key => $value) { - if (Arr::get($data, $key) !== $value) { - if (!isset($this->cleanings['form'])) $this->cleanings['form'] = []; + + // Get value from form + $formVal = Arr::get($data, $key); + + // Transform boolean values + $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'])) { + $this->cleanings['form'] = []; + } $this->cleanings['form'][] = $key; // If not a simulation, do the cleaning diff --git a/resources/js/components/Navbar.vue b/resources/js/components/Navbar.vue index 0d9640281..73df31fd1 100644 --- a/resources/js/components/Navbar.vue +++ b/resources/js/components/Navbar.vue @@ -8,9 +8,7 @@ BETA + {{ appName }} @@ -23,6 +21,11 @@ class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"> Templates + + Upgrade + Pricing + @@ -158,6 +161,9 @@ export default { } return null }, + workspace () { + return this.$store.getters['open/workspaces/getCurrent']() + }, showAuth() { return this.$route.name && !this.$route.name.startsWith('forms.show_public') }, diff --git a/resources/js/components/common/ProTag.vue b/resources/js/components/common/ProTag.vue index c8176f03c..180f5cc3f 100644 --- a/resources/js/components/common/ProTag.vue +++ b/resources/js/components/common/ProTag.vue @@ -33,7 +33,7 @@

- Feel free to contact us if you have any feature request. + Feel free to contact us if you have any feature request.

@@ -66,13 +66,20 @@ export default { currentWorkSpace: 'open/workspaces/getCurrent', }), shouldDisplayProTag() { - return false; //!this.user.is_subscribed && !(this.currentWorkSpace.is_pro || this.currentWorkSpace.is_enterprise); + if(!window.config.paid_plans_enabled) return false + if (!this.user) return true + return !(this.currentWorkSpace().is_pro || this.currentWorkSpace().is_enterprise) }, }, mounted () { }, - methods: {} + methods: { + openChat () { + window.$crisp.push(['do', 'chat:show']) + window.$crisp.push(['do', 'chat:open']) + }, + } } diff --git a/resources/js/components/forms/ToggleSwitchInput.vue b/resources/js/components/forms/ToggleSwitchInput.vue index f1d31442c..073e591ba 100644 --- a/resources/js/components/forms/ToggleSwitchInput.vue +++ b/resources/js/components/forms/ToggleSwitchInput.vue @@ -5,7 +5,9 @@
- {{ label }} * + + {{ label }} * +
diff --git a/resources/js/components/open/forms/OpenForm.vue b/resources/js/components/open/forms/OpenForm.vue index b79c84e68..8b6f10b58 100644 --- a/resources/js/components/open/forms/OpenForm.vue +++ b/resources/js/components/open/forms/OpenForm.vue @@ -362,7 +362,7 @@ export default { const formData = clonedeep(this.dataForm ? this.dataForm.data() : {}) let urlPrefill = null - if (this.isPublicFormPage && this.form.is_pro) { + if (this.isPublicFormPage) { urlPrefill = new URLSearchParams(window.location.search) } @@ -494,8 +494,8 @@ export default { } } else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) { inputProperties.multiple = (field.multiple !== undefined && field.multiple) - inputProperties.mbLimit = 5 - inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : "" + inputProperties.mbLimit = this.form.workspace.is_pro ? 50 : 5 + inputProperties.accept = (field.allowed_file_types) ? field.allowed_file_types : "" } else if (field.type === 'number' && field.is_rating) { inputProperties.numberOfStars = parseInt(field.rating_max_value) } else if (['number', 'phone_number'].includes(field.type)) { diff --git a/resources/js/components/open/forms/components/FormFieldsEditor.vue b/resources/js/components/open/forms/components/FormFieldsEditor.vue index eff4da2dd..d865d2efd 100644 --- a/resources/js/components/open/forms/components/FormFieldsEditor.vue +++ b/resources/js/components/open/forms/components/FormFieldsEditor.vue @@ -52,11 +52,6 @@

- - - - -
diff --git a/resources/js/components/pages/pricing/CheckoutDetailsModal.vue b/resources/js/components/pages/pricing/CheckoutDetailsModal.vue new file mode 100644 index 000000000..08197ac7f --- /dev/null +++ b/resources/js/components/pages/pricing/CheckoutDetailsModal.vue @@ -0,0 +1,94 @@ + + + diff --git a/resources/js/components/pages/pricing/MonthlyYearlySelector.vue b/resources/js/components/pages/pricing/MonthlyYearlySelector.vue new file mode 100644 index 000000000..9bcfc9ef4 --- /dev/null +++ b/resources/js/components/pages/pricing/MonthlyYearlySelector.vue @@ -0,0 +1,41 @@ + + + diff --git a/resources/js/components/pages/pricing/PricingTable.vue b/resources/js/components/pages/pricing/PricingTable.vue new file mode 100644 index 000000000..5068ef3b0 --- /dev/null +++ b/resources/js/components/pages/pricing/PricingTable.vue @@ -0,0 +1,791 @@ + + + diff --git a/resources/js/pages/forms/show/index.vue b/resources/js/pages/forms/show/index.vue index 5d970c84c..1e6da5dc8 100644 --- a/resources/js/pages/forms/show/index.vue +++ b/resources/js/pages/forms/show/index.vue @@ -71,7 +71,7 @@
  • {{tab.name}}
  • diff --git a/resources/js/pages/pricing.vue b/resources/js/pages/pricing.vue new file mode 100644 index 000000000..81d83b0b7 --- /dev/null +++ b/resources/js/pages/pricing.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/resources/js/pages/settings/index.vue b/resources/js/pages/settings/index.vue index b1b924f08..b371a513d 100644 --- a/resources/js/pages/settings/index.vue +++ b/resources/js/pages/settings/index.vue @@ -16,7 +16,7 @@
    • {{tab.name}}
    • diff --git a/resources/js/router/routes.js b/resources/js/router/routes.js index ac52ef387..d31da7565 100644 --- a/resources/js/router/routes.js +++ b/resources/js/router/routes.js @@ -61,6 +61,7 @@ export default [ // Guest Routes { path: '/', name: 'welcome', component: page('welcome.vue') }, + { path: '/pricing', name: 'pricing', component: page('pricing.vue') }, { path: '/ai-form-builder', name: 'aiformbuilder', component: page('ai-form-builder.vue') }, { path: '/integrations', name: 'integrations', component: page('integrations.vue') }, { path: '/forms/:slug', name: 'forms.show_public', component: page('forms/show-public.vue') }, diff --git a/resources/views/spa.blade.php b/resources/views/spa.blade.php index f9dc12d16..719ae000f 100644 --- a/resources/views/spa.blade.php +++ b/resources/views/spa.blade.php @@ -13,7 +13,8 @@ 'google_analytics_code' => config('services.google_analytics_code'), 'amplitude_code' => config('services.amplitude_code'), 'crisp_website_id' => config('services.crisp_website_id'), - 'ai_features_enabled' => !is_null(config('services.openai.api_key')) + 'ai_features_enabled' => !is_null(config('services.openai.api_key')), + 'paid_plans_enabled' => !is_null(config('cashier.key')) ]; @endphp diff --git a/routes/api.php b/routes/api.php index e58856825..7028239d7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -41,6 +41,7 @@ Route::patch('settings/password', [PasswordController::class, 'update']); Route::prefix('subscription')->name('subscription.')->group(function () { + Route::put('/update-customer-details', [SubscriptionController::class, 'updateStripeDetails'])->name('update-stripe-details'); Route::get('/new/{subscription}/{plan}/checkout/{trial?}', [SubscriptionController::class, 'checkout']) ->name('checkout') ->where('subscription', '('.implode('|', SubscriptionController::SUBSCRIPTION_NAMES).')') From 3a26786f45695854d228e0206f79a46b5506d920 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Tue, 18 Jul 2023 17:08:28 +0530 Subject: [PATCH 02/16] no pricing page if have no paid plans --- resources/js/components/Navbar.vue | 5 ++++- resources/js/pages/pricing.vue | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/resources/js/components/Navbar.vue b/resources/js/components/Navbar.vue index 73df31fd1..d781552a9 100644 --- a/resources/js/components/Navbar.vue +++ b/resources/js/components/Navbar.vue @@ -21,7 +21,7 @@ class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"> Templates - Upgrade Pricing @@ -164,6 +164,9 @@ export default { workspace () { return this.$store.getters['open/workspaces/getCurrent']() }, + paidPlansEnabled() { + return window.config.paid_plans_enabled + }, showAuth() { return this.$route.name && !this.$route.name.startsWith('forms.show_public') }, diff --git a/resources/js/pages/pricing.vue b/resources/js/pages/pricing.vue index 81d83b0b7..fd447aa54 100644 --- a/resources/js/pages/pricing.vue +++ b/resources/js/pages/pricing.vue @@ -97,6 +97,14 @@ export default { props: {}, + beforeRouteEnter (to, from, next) { + if (!window.config.paid_plans_enabled) { // If no paid plan so no need this page + window.location.href = '/' + return false + } + next() + }, + data: () => ({ metaTitle: 'Pricing', metaDescription: 'All of our core features are free, and there is no quantity limit. You can also created more advanced and customized forms with OpnForms Pro.', From 7e04f6b8c7c20e0300d0498d8966c8564dd31ed5 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Tue, 18 Jul 2023 17:20:23 +0530 Subject: [PATCH 03/16] Set pricing ids in env --- .env.example | 11 ++++++ .../Controllers/SubscriptionController.php | 2 +- config/pricing.php | 36 ++++--------------- 3 files changed, 18 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index bcd572688..4fa348396 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,17 @@ JWT_SECRET= STRIPE_KEY= STRIPE_SECRET= +STRIPE_PROD_DEFAULT_PRODUCT_ID= +STRIPE_PROD_DEFAULT_PRICING_MONTHLY= +STRIPE_PROD_DEFAULT_PRICING_YEARLY= + +STRIPE_TEST_DEFAULT_PRODUCT_ID= +STRIPE_TEST_DEFAULT_PRICING_MONTHLY= +STRIPE_TEST_DEFAULT_PRICING_YEARLY= + +H_CAPTCHA_SITE_KEY= +H_CAPTCHA_SECRET= + MUX_WORKSPACE_ID= MUX_API_TOKEN= diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index f34311979..e3e3710ab 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -91,7 +91,7 @@ public function billingPortal() ]); } - private function getPricing($product = 'pro') + private function getPricing($product = 'default') { return App::environment() == 'production' ? config('pricing.production.'.$product.'.pricing') : config('pricing.test.'.$product.'.pricing'); } diff --git a/config/pricing.php b/config/pricing.php index b5b17bf35..d96e405a8 100644 --- a/config/pricing.php +++ b/config/pricing.php @@ -4,44 +4,20 @@ 'production' => [ 'default' => [ - 'product_id' => 'prod_JpQMgFHw0PSuzM', + 'product_id' => env('STRIPE_PROD_DEFAULT_PRODUCT_ID'), 'pricing' => [ - 'yearly' => 'price_1JBlWXLQM1kjk4NvEWonKifC', - 'monthly' => 'price_1JBlWELQM1kjk4NvmtrstOpi', - 'yearly_2022' => 'price_1LLmZsLQM1kjk4Nv0oLa6MeZ', - 'monthly_2022' => 'price_1LLmaYLQM1kjk4NvQER36XPA', - ] - ], - - 'enterprise' => [ - 'product_id' => 'prod_KXUeOAd1H42xMM', - 'pricing' => [ - 'yearly' => 'price_1JsPfeLQM1kjk4NvV8MJ53yV', - 'monthly' => 'price_1JsPfeLQM1kjk4NvtSszj1jE', - 'yearly_2022' => 'price_1LLmXALQM1kjk4NvXXz5Rxxv', - 'monthly_2022' => 'price_1LLmXtLQM1kjk4Nv1DShm9zs', + 'monthly' => env('STRIPE_PROD_DEFAULT_PRICING_MONTHLY'), + 'yearly' => env('STRIPE_PROD_DEFAULT_PRICING_YEARLY'), ] ] ], 'test' => [ 'default' => [ - 'product_id' => 'prod_LY0BWzSv0Cl5Db', - 'pricing' => [ - 'yearly' => 'price_1KquBXKTHIweYlJTHYU6UXA1', - 'monthly' => 'price_1KquBXKTHIweYlJTOGEWKr0B', - 'yearly_2022' => 'price_1LKwvpKTHIweYlJT74vdfJcK', - 'monthly_2022' => 'price_1LKwvLKTHIweYlJTOAyghKkJ', - ] - ], - - 'enterprise' => [ - 'product_id' => 'prod_LY0CdM6YtwODqn', + 'product_id' => env('STRIPE_TEST_DEFAULT_PRODUCT_ID'), 'pricing' => [ - 'yearly' => 'price_1KquCYKTHIweYlJTiR3TBMTV', - 'monthly' => 'price_1KquCYKTHIweYlJT4vQVLcQ7', - 'yearly_2022' => 'price_1LKwxCKTHIweYlJTJb71yk4V', - 'monthly_2022' => 'price_1LKwwtKTHIweYlJTqO2JrQv4', + 'monthly' => env('STRIPE_TEST_DEFAULT_PRICING_MONTHLY'), + 'yearly' => env('STRIPE_TEST_DEFAULT_PRICING_YEARLY'), ] ] ] From eee23959ecca8e5489958ba07a21a9f520fa0d41 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Tue, 18 Jul 2023 17:26:50 +0530 Subject: [PATCH 04/16] views & submissions FREE for all --- app/Http/Resources/FormResource.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Resources/FormResource.php b/app/Http/Resources/FormResource.php index 253a9920b..a46ff8a60 100644 --- a/app/Http/Resources/FormResource.php +++ b/app/Http/Resources/FormResource.php @@ -26,8 +26,8 @@ public function toArray($request) $ownerData = $this->userIsFormOwner() ? [ 'creator' => new UserResource($this->creator), - 'views_count' => $this->when($this->workspaceIsPro(), $this->views_count), - 'submissions_count' => $this->when($this->workspaceIsPro(), $this->submissions_count), + 'views_count' => $this->views_count, + 'submissions_count' => $this->submissions_count, 'notifies' => $this->notifies, 'notifies_slack' => $this->notifies_slack, 'notifies_discord' => $this->notifies_discord, From 4061752ff3ccea8f70185c1e51fca91b4523d666 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Tue, 18 Jul 2023 18:11:23 +0530 Subject: [PATCH 05/16] extra param for env --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 4fa348396..f781ac18d 100644 --- a/.env.example +++ b/.env.example @@ -70,4 +70,7 @@ H_CAPTCHA_SECRET= MUX_WORKSPACE_ID= MUX_API_TOKEN= +ADMIN_EMAILS= +TEMPLATE_EDITOR_EMAILS= + OPEN_AI_API_KEY= From 5ceb9d08041c04432de6c6f09b1b8a6d9a6ddb94 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Tue, 18 Jul 2023 18:11:48 +0530 Subject: [PATCH 06/16] form password FREE for all --- app/Http/Middleware/Form/PasswordProtectedForm.php | 2 +- app/Http/Resources/FormResource.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Middleware/Form/PasswordProtectedForm.php b/app/Http/Middleware/Form/PasswordProtectedForm.php index 322d7a402..b4e2e15bd 100644 --- a/app/Http/Middleware/Form/PasswordProtectedForm.php +++ b/app/Http/Middleware/Form/PasswordProtectedForm.php @@ -26,7 +26,7 @@ public function handle(Request $request, Closure $next) 'form' => $form, ]); $userIsFormOwner = Auth::check() && Auth::user()->workspaces()->find($form->workspace_id) !== null; - if (!$userIsFormOwner && $form->is_pro && $form->has_password) { + if (!$userIsFormOwner && $form->has_password) { if($this->hasCorrectPassword($request, $form)){ return $next($request); } diff --git a/app/Http/Resources/FormResource.php b/app/Http/Resources/FormResource.php index a46ff8a60..990958ad8 100644 --- a/app/Http/Resources/FormResource.php +++ b/app/Http/Resources/FormResource.php @@ -95,7 +95,7 @@ public function setCleanings(array $cleanings) private function doesMissPassword(Request $request) { - if (!$this->workspaceIsPro() || !$this->has_password) return false; + if (!$this->has_password) return false; return !PasswordProtectedForm::hasCorrectPassword($request, $this->resource); } From 56cfa9e4d619a520551897105e1d97c04efc77c9 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Tue, 18 Jul 2023 19:04:38 +0530 Subject: [PATCH 07/16] Custom Code is PRO feature --- app/Service/Forms/FormCleaner.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Service/Forms/FormCleaner.php b/app/Service/Forms/FormCleaner.php index 4739f00d6..c9c39e0ce 100644 --- a/app/Service/Forms/FormCleaner.php +++ b/app/Service/Forms/FormCleaner.php @@ -31,6 +31,7 @@ class FormCleaner 'slack_webhook_url' => null, 'discord_webhook_url' => null, 'editable_submissions' => false, + 'custom_code' => null, ]; private array $fieldDefaults = [ @@ -47,11 +48,11 @@ class FormCleaner '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', // For fields 'file_upload' => "Link field is not a file upload.", 'custom_block' => 'The custom block was removed.', - 'files' => 'The file upload file was hidden.', ]; /** From ff945111268bdcd1958455b52d00615c20bf57b5 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Tue, 18 Jul 2023 19:05:33 +0530 Subject: [PATCH 08/16] Replace codeinput prism with codemirror --- package.json | 4 +- resources/js/components/forms/CodeInput.vue | 47 ++++++++++--------- .../form-components/FormCustomCode.vue | 5 +- resources/js/config/form-themes.js | 6 +-- 4 files changed, 30 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index f65c784bc..bc19dbc48 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,15 @@ "tinymotion": "^0.2.0", "vform": "^2.1.1", "vt-notifications": "^0.4.1", - "vue": "^2.6.14", + "vue": "^2.7.14", "vue-chartjs": "^4.1.0", "vue-clickaway": "^2.2.2", + "vue-codemirror": "^4.0.6", "vue-confetti": "^2.3.0", "vue-i18n": "^8.25.0", "vue-meta": "^2.4.0", "vue-notion": "^0.4.0", "vue-plugin-load-script": "^1.3.2", - "vue-prism-editor": "^1.2.2", "vue-router": "^3.5.2", "vue-signature-pad": "^2.0.5", "vue-tailwind": "^2.5.0", diff --git a/resources/js/components/forms/CodeInput.vue b/resources/js/components/forms/CodeInput.vue index 8526df70e..169730e70 100644 --- a/resources/js/components/forms/CodeInput.vue +++ b/resources/js/components/forms/CodeInput.vue @@ -7,13 +7,13 @@ * - + + /> +
@@ -23,31 +23,32 @@ diff --git a/resources/js/components/open/forms/components/form-components/FormCustomCode.vue b/resources/js/components/open/forms/components/form-components/FormCustomCode.vue index b3132f3f2..4c3cf1aa3 100644 --- a/resources/js/components/open/forms/components/form-components/FormCustomCode.vue +++ b/resources/js/components/open/forms/components/form-components/FormCustomCode.vue @@ -12,10 +12,7 @@

- The code will be injected in the head section of your form page. Click - here to get an example CSS code. + The code will be injected in the head section of your form page.

-
-

- You're seeing this because you are an owner of this form.
- All your Pro features are de-activated when sharing this form:
- - -

-
-
- - Close - -
- + 0) { - let message = '' - Object.keys(this.form.cleanings).forEach((key) => { - const fieldName = key.charAt(0).toUpperCase() + key.slice(1) - let fieldInfo = '
' + fieldName + "
    " - this.form.cleanings[key].forEach((msg) => { - fieldInfo = fieldInfo + '
  • ' + msg + '
  • ' - }) - message = message + fieldInfo + '
      ' - }) - - return message - } - return false - }, isPublicFormPage () { return this.$route.name === 'forms.show_public' }, diff --git a/resources/js/components/open/forms/components/form-components/FormEditorPreview.vue b/resources/js/components/open/forms/components/form-components/FormEditorPreview.vue index abd59b7f3..6a9baf18d 100644 --- a/resources/js/components/open/forms/components/form-components/FormEditorPreview.vue +++ b/resources/js/components/open/forms/components/form-components/FormEditorPreview.vue @@ -53,6 +53,7 @@ diff --git a/resources/js/components/pages/forms/show/FormCleanings.vue b/resources/js/components/pages/forms/show/FormCleanings.vue new file mode 100644 index 000000000..3066f4c46 --- /dev/null +++ b/resources/js/components/pages/forms/show/FormCleanings.vue @@ -0,0 +1,107 @@ + + diff --git a/resources/js/mixins/forms/saveUpdateAlert.js b/resources/js/mixins/forms/saveUpdateAlert.js index 9909fda1c..17c6c0f5d 100644 --- a/resources/js/mixins/forms/saveUpdateAlert.js +++ b/resources/js/mixins/forms/saveUpdateAlert.js @@ -1,17 +1,8 @@ export default { methods: { - displayFormModificationAlert(responseData) { - if (responseData.form_cleaning && Object.keys(responseData.form_cleaning).length > 0) { - let message = responseData.message + '
      ' - Object.keys(responseData.form_cleaning).forEach((key) => { - const fieldName = key.charAt(0).toUpperCase() + key.slice(1) - let fieldInfo = "
      " + fieldName + "
        " - responseData.form_cleaning[key].forEach((msg) => { - fieldInfo = fieldInfo + "
      • " + msg +"
      • " - }) - message = message + fieldInfo + "
          " - }) - this.alertWarning(message) + displayFormModificationAlert (responseData) { + if (responseData.form.cleanings && Object.keys(responseData.form.cleanings).length > 0) { + this.alertWarning(responseData.message) } else { this.alertSuccess(responseData.message) } diff --git a/resources/js/pages/forms/show/index.vue b/resources/js/pages/forms/show/index.vue index 1e6da5dc8..482fa45f8 100644 --- a/resources/js/pages/forms/show/index.vue +++ b/resources/js/pages/forms/show/index.vue @@ -67,7 +67,9 @@ This form will stop accepting submissions after {{ form.max_submissions_count }} submissions.

          -
          + + +
          • Date: Wed, 19 Jul 2023 13:15:49 +0530 Subject: [PATCH 10/16] Added risky user email spam protection --- .../Forms/SubmissionConfirmation.php | 25 ++++++++++++++++++- app/Models/User.php | 9 +++++++ app/Models/Workspace.php | 22 ++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app/Listeners/Forms/SubmissionConfirmation.php b/app/Listeners/Forms/SubmissionConfirmation.php index 6e96a4c5d..a64145538 100644 --- a/app/Listeners/Forms/SubmissionConfirmation.php +++ b/app/Listeners/Forms/SubmissionConfirmation.php @@ -18,6 +18,8 @@ class SubmissionConfirmation implements ShouldQueue { use InteractsWithQueue; + const RISKY_USERS_LIMIT = 120; + /** * Handle the event. * @@ -26,7 +28,13 @@ class SubmissionConfirmation implements ShouldQueue */ public function handle(FormSubmitted $event) { - if (!$event->form->send_submission_confirmation) return; + if ( + !$event->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; @@ -56,6 +64,21 @@ private function getRespondentEmail(FormSubmitted $event) 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 (boolean) filter_var($email, FILTER_VALIDATE_EMAIL); } diff --git a/app/Models/User.php b/app/Models/User.php index 5b5dba130..ec963b0dc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -179,6 +179,15 @@ public function getJWTCustomClaims() return []; } + public function getIsRiskyAttribute() + { + return $this->created_at->isAfter(now()->subDays(3)) || // created in last 3 days + $this->subscriptions()->where(function ($q) { + $q->where('stripe_status', 'trialing') + ->orWhere('stripe_status', 'active'); + })->first()?->onTrial(); + } + public static function boot () { parent::boot(); diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 2262b3851..5974a5937 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -51,6 +51,28 @@ public function getIsEnterpriseAttribute() return false; } + public function getIsRiskyAttribute() + { + // A workspace is risky if all of his users are risky + foreach ($this->owners as $owner) { + if (!$owner->is_risky) { + return false; + } + } + + return true; + } + + public function getSubmissionsCountAttribute() + { + $total = 0; + foreach ($this->forms as $form) { + $total += $form->submissions_count; + } + + return $total; + } + /** * Relationships */ From 7efe3eb85f70feba9b51d2e4daed8a7f8cf1ff01 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Thu, 20 Jul 2023 13:26:47 +0530 Subject: [PATCH 11/16] fix form cleaning --- app/Http/Controllers/Forms/FormController.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/Http/Controllers/Forms/FormController.php b/app/Http/Controllers/Forms/FormController.php index 9233e7246..d8fd25a49 100644 --- a/app/Http/Controllers/Forms/FormController.php +++ b/app/Http/Controllers/Forms/FormController.php @@ -123,8 +123,7 @@ public function update(UpdateFormRequest $request, string $id) return $this->success([ 'message' => $this->formCleaner->hasCleaned() ? 'Form successfully updated, but the Pro features you used will be disabled when sharing your form:' : 'Form updated.', - 'form_cleaning' => $this->formCleaner->getPerformedCleanings(), - 'form' => new FormResource($form) + 'form' => (new FormResource($form))->setCleanings($this->formCleaner->getPerformedCleanings()), ]); } From 6e68ba77cb72d1d2771fd43641a8bae9f27cabde Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Thu, 20 Jul 2023 13:28:35 +0530 Subject: [PATCH 12/16] Custom SEO --- app/Http/Requests/UserFormRequest.php | 3 + app/Http/Resources/FormResource.php | 3 +- app/Models/Forms/Form.php | 8 ++- app/Service/Forms/FormCleaner.php | 30 +++++++++ app/Service/SeoMetaResolver.php | 20 ++++-- database/factories/FormFactory.php | 3 +- ...023_07_20_073728_add_seo_meta_to_forms.php | 32 ++++++++++ .../open/forms/components/FormEditor.vue | 5 +- .../form-components/FormCustomSeo.vue | 63 +++++++++++++++++++ resources/js/mixins/form_editor/initForm.js | 5 +- resources/js/mixins/seo-meta.js | 3 +- resources/js/pages/forms/show-public.vue | 16 +++++ 12 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php create mode 100644 resources/js/components/open/forms/components/form-components/FormCustomSeo.vue diff --git a/app/Http/Requests/UserFormRequest.php b/app/Http/Requests/UserFormRequest.php index f822269f5..a0d777c01 100644 --- a/app/Http/Requests/UserFormRequest.php +++ b/app/Http/Requests/UserFormRequest.php @@ -121,6 +121,9 @@ public function rules() // Security & Privacy 'can_be_indexed' => 'boolean', 'password' => 'sometimes|nullable', + + // Custom SEO + 'seo_meta' => 'nullable|array' ]; } diff --git a/app/Http/Resources/FormResource.php b/app/Http/Resources/FormResource.php index 3ffb362ca..49857ad75 100644 --- a/app/Http/Resources/FormResource.php +++ b/app/Http/Resources/FormResource.php @@ -48,7 +48,8 @@ public function toArray($request) 'slack_webhook_url' => $this->slack_webhook_url, 'discord_webhook_url' => $this->discord_webhook_url, 'removed_properties' => $this->removed_properties, - 'last_edited_human' => $this->updated_at?->diffForHumans() + 'last_edited_human' => $this->updated_at?->diffForHumans(), + 'seo_meta' => $this->seo_meta ] : []; $baseData = $this->getFilteredFormData(parent::toArray($request), $this->userIsFormOwner()); diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index a45752f6d..9f061f444 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -83,7 +83,10 @@ class Form extends Model // Security & Privacy 'can_be_indexed', - 'password' + 'password', + + // Custom SEO + 'seo_meta' ]; protected $casts = [ @@ -91,7 +94,8 @@ class Form extends Model 'database_fields_update' => 'array', 'closes_at' => 'datetime', 'tags' => 'array', - 'removed_properties' => 'array' + 'removed_properties' => 'array', + 'seo_meta' => 'object' ]; protected $appends = [ diff --git a/app/Service/Forms/FormCleaner.php b/app/Service/Forms/FormCleaner.php index c9c39e0ce..71fbdcd46 100644 --- a/app/Service/Forms/FormCleaner.php +++ b/app/Service/Forms/FormCleaner.php @@ -23,6 +23,9 @@ class FormCleaner private array $data; + // For remove keys those have empty value + private array $customKeys = ['seo_meta']; + private array $formDefaults = [ 'notifies' => false, 'no_branding' => false, @@ -32,6 +35,7 @@ class FormCleaner 'discord_webhook_url' => null, 'editable_submissions' => false, 'custom_code' => null, + 'seo_meta' => [] ]; private array $fieldDefaults = [ @@ -49,6 +53,7 @@ class FormCleaner '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 code was disabled', // For fields 'file_upload' => "Link field is not a file upload.", @@ -202,6 +207,9 @@ private function clean(array &$data, array $defaults, $simulation = false): void // Get value from form $formVal = Arr::get($data, $key); + // Transform customkeys values + $formVal = $this->cleanCustomKeys($key, $formVal); + // Transform boolean values $formVal = (($formVal === 0 || $formVal === "0") ? false : $formVal); $formVal = (($formVal === 1 || $formVal === "1") ? true : $formVal); @@ -242,4 +250,26 @@ private function cleanField(array &$data, array $defaults, $simulation = false): } } + // Remove keys those have empty value + private function cleanCustomKeys($key, $formVal) + { + if (in_array($key, $this->customKeys) && $formVal !== null) { + $newVal = []; + foreach ($formVal as $k => $val) { + $changed = false; + if ($val) { + $newVal[$k] = $val; + $changed = true; + } + + if ($changed) { + $this->cleanings['form'][] = $key; + } + } + return $newVal; + } + + return $formVal; + } + } diff --git a/app/Service/SeoMetaResolver.php b/app/Service/SeoMetaResolver.php index 5232562ee..c5ec12e58 100644 --- a/app/Service/SeoMetaResolver.php +++ b/app/Service/SeoMetaResolver.php @@ -160,15 +160,25 @@ private function getFormShowMeta(): array { $form = Form::whereSlug($this->patternData['slug'])->firstOrFail(); - $meta = [ - 'title' => $form->title . $this->titleSuffix(), - ]; - if($form->description){ + $meta = []; + if ($form->is_pro && $form->seo_meta->page_title) { + $meta['title'] = $form->seo_meta->page_title; + } else { + $meta['title'] = $form->title . $this->titleSuffix(); + } + + if ($form->is_pro && $form->seo_meta->page_description) { + $meta['description'] = $form->seo_meta->page_description; + } else if ($form->description) { $meta['description'] = Str::of($form->description)->limit(160); } - if($form->cover_picture){ + + if ($form->is_pro && $form->seo_meta->page_thumbnail) { + $meta['image'] = $form->seo_meta->page_thumbnail; + } else if ($form->cover_picture) { $meta['image'] = $form->cover_picture; } + return $meta; } diff --git a/database/factories/FormFactory.php b/database/factories/FormFactory.php index 18a900b2b..c7a831e7f 100644 --- a/database/factories/FormFactory.php +++ b/database/factories/FormFactory.php @@ -85,7 +85,8 @@ public function definition() 'tags' => [], 'slack_webhook_url' => null, 'editable_submissions_button_text' => 'Edit submission', - 'confetti_on_submission' => false + 'confetti_on_submission' => false, + 'seo_meta' => [], ]; } diff --git a/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php b/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php new file mode 100644 index 000000000..e0a67d76b --- /dev/null +++ b/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php @@ -0,0 +1,32 @@ +json('seo_meta')->default('{}'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('seo_meta'); + }); + } +}; diff --git a/resources/js/components/open/forms/components/FormEditor.vue b/resources/js/components/open/forms/components/FormEditor.vue index 3a8f5b289..513c09688 100644 --- a/resources/js/components/open/forms/components/FormEditor.vue +++ b/resources/js/components/open/forms/components/FormEditor.vue @@ -37,6 +37,7 @@ +
          @@ -66,6 +67,7 @@ import FormNotifications from './form-components/FormNotifications.vue' import FormIntegrations from './form-components/FormIntegrations.vue' import FormEditorPreview from './form-components/FormEditorPreview.vue' import FormSecurityPrivacy from './form-components/FormSecurityPrivacy.vue' +import FormCustomSeo from './form-components/FormCustomSeo.vue' import saveUpdateAlert from '../../../../mixins/forms/saveUpdateAlert.js' export default { @@ -80,7 +82,8 @@ export default { FormStructure, FormInformation, FormErrorModal, - FormSecurityPrivacy + FormSecurityPrivacy, + FormCustomSeo }, mixins: [saveUpdateAlert], props: { diff --git a/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue b/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue new file mode 100644 index 000000000..fcec4eae6 --- /dev/null +++ b/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue @@ -0,0 +1,63 @@ + + + diff --git a/resources/js/mixins/form_editor/initForm.js b/resources/js/mixins/form_editor/initForm.js index 47861286f..cc38ba84d 100644 --- a/resources/js/mixins/form_editor/initForm.js +++ b/resources/js/mixins/form_editor/initForm.js @@ -45,7 +45,10 @@ export default { confetti_on_submission: false, // Security & Privacy - can_be_indexed: true + can_be_indexed: true, + + // Custom SEO + seo_meta: {} }) }, } diff --git a/resources/js/mixins/seo-meta.js b/resources/js/mixins/seo-meta.js index 602059613..2d4044722 100644 --- a/resources/js/mixins/seo-meta.js +++ b/resources/js/mixins/seo-meta.js @@ -3,10 +3,11 @@ export default { const title = this.metaTitle ?? 'OpnForm' const description = this.metaDescription ?? "Create beautiful forms for free. Unlimited fields, unlimited submissions. It's free and it takes less than 1 minute to create your first form." const image = this.metaImage ?? this.asset('img/social-preview.jpg') + const metaTemplate = this.metaTemplate ?? '%s ยท OpnForm' return { title: title, - titleTemplate: '%s ยท OpnForm', + titleTemplate: metaTemplate, meta: [ ...(this.metaTags ?? []), { vmid: 'og:title', property: 'og:title', content: title }, diff --git a/resources/js/pages/forms/show-public.vue b/resources/js/pages/forms/show-public.vue index 39b34c3a3..176c5c4f2 100644 --- a/resources/js/pages/forms/show-public.vue +++ b/resources/js/pages/forms/show-public.vue @@ -181,12 +181,28 @@ export default { return window.location !== window.parent.location || window.frameElement }, metaTitle () { + if(this.form && this.form.is_pro && this.form.seo_meta.page_title) { + return this.form.seo_meta.page_title + } return this.form ? this.form.title : 'Create beautiful forms' }, + metaTemplate () { + if (this.form && this.form.is_pro && this.form.seo_meta.page_title) { + // Disable template if custom SEO title + return '%s' + } + return null + }, metaDescription () { + if (this.form && this.form.is_pro && this.form.seo_meta.page_description) { + return this.form.seo_meta.page_description + } return (this.form && this.form.description) ? this.form.description.substring(0, 160) : null }, metaImage () { + if (this.form && this.form.is_pro && this.form.seo_meta.page_thumbnail) { + return this.form.seo_meta.page_thumbnail + } return (this.form && this.form.cover_picture) ? this.form.cover_picture : null }, metaTags () { From 6a63266bf799f267a79b2ab0964fa8d22c39026d Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Thu, 20 Jul 2023 13:56:09 +0530 Subject: [PATCH 13/16] fix custom seo formcleaner --- app/Service/Forms/FormCleaner.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/Service/Forms/FormCleaner.php b/app/Service/Forms/FormCleaner.php index 71fbdcd46..8a76d66ee 100644 --- a/app/Service/Forms/FormCleaner.php +++ b/app/Service/Forms/FormCleaner.php @@ -256,14 +256,8 @@ private function cleanCustomKeys($key, $formVal) if (in_array($key, $this->customKeys) && $formVal !== null) { $newVal = []; foreach ($formVal as $k => $val) { - $changed = false; if ($val) { $newVal[$k] = $val; - $changed = true; - } - - if ($changed) { - $this->cleanings['form'][] = $key; } } return $newVal; From 86135d1ba7fc59bb6f75b49778dffb3a269c1000 Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Thu, 20 Jul 2023 19:04:35 +0530 Subject: [PATCH 14/16] Better webhooks --- app/Listeners/FailedWebhookListener.php | 38 ++++ app/Listeners/Forms/NotifyFormSubmission.php | 185 ++++-------------- app/Listeners/Forms/PostFormDataToWebhook.php | 77 -------- app/Models/Forms/Form.php | 5 + app/Models/Integration/FormZapierWebhook.php | 15 +- .../Forms/FailedWebhookNotification.php | 57 ++++++ app/Providers/EventServiceProvider.php | 7 +- .../Forms/Webhooks/AbstractWebhookHandler.php | 66 +++++++ app/Service/Forms/Webhooks/DiscordHandler.php | 84 ++++++++ .../Forms/Webhooks/SimpleWebhookHandler.php | 24 +++ app/Service/Forms/Webhooks/SlackHandler.php | 75 +++++++ .../Forms/Webhooks/WebhookHandlerProvider.php | 32 +++ app/Service/Forms/Webhooks/ZapierHandler.php | 27 +++ .../views/mail/form/webhook-error.blade.php | 15 ++ 14 files changed, 470 insertions(+), 237 deletions(-) create mode 100644 app/Listeners/FailedWebhookListener.php delete mode 100644 app/Listeners/Forms/PostFormDataToWebhook.php create mode 100644 app/Notifications/Forms/FailedWebhookNotification.php create mode 100644 app/Service/Forms/Webhooks/AbstractWebhookHandler.php create mode 100644 app/Service/Forms/Webhooks/DiscordHandler.php create mode 100644 app/Service/Forms/Webhooks/SimpleWebhookHandler.php create mode 100644 app/Service/Forms/Webhooks/SlackHandler.php create mode 100644 app/Service/Forms/Webhooks/WebhookHandlerProvider.php create mode 100644 app/Service/Forms/Webhooks/ZapierHandler.php create mode 100644 resources/views/mail/form/webhook-error.blade.php diff --git a/app/Listeners/FailedWebhookListener.php b/app/Listeners/FailedWebhookListener.php new file mode 100644 index 000000000..c5c6f2028 --- /dev/null +++ b/app/Listeners/FailedWebhookListener.php @@ -0,0 +1,38 @@ +meta['type'] == 'form_submission') { + $event->meta['form']->creator->notify(new FailedWebhookNotification($event)); + \Log::error('Failed form submission webhook', [ + 'webhook_url' => $event->webhookUrl, + 'exception' => $event->errorType, + 'message' => $event->errorMessage, + 'form_id' => $event->meta['form']->id + ]); + return; + } + + \Log::error('Failed webhook', [ + 'webhook_url' => $event->webhookUrl, + 'exception' => $event->errorType, + 'message' => $event->errorMessage + ]); + } +} diff --git a/app/Listeners/Forms/NotifyFormSubmission.php b/app/Listeners/Forms/NotifyFormSubmission.php index b8e59d73f..a2da167a0 100644 --- a/app/Listeners/Forms/NotifyFormSubmission.php +++ b/app/Listeners/Forms/NotifyFormSubmission.php @@ -10,8 +10,8 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Facades\Notification; use App\Service\Forms\FormSubmissionFormatter; +use App\Service\Forms\Webhooks\WebhookHandlerProvider; use App\Notifications\Forms\FormSubmissionNotification; -use Vinkla\Hashids\Facades\Hashids; class NotifyFormSubmission implements ShouldQueue { @@ -25,165 +25,46 @@ class NotifyFormSubmission implements ShouldQueue */ public function handle(FormSubmitted $event) { - if (!$event->form->is_pro) return; + $this->sendEmailNotifications($event); - if ($event->form->notifies) { - // Send Email Notification - $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)); - }); - } - - if ($event->form->notifies_slack) { - // Send Slack Notification - $this->sendSlackNotification($event); - } - - if ($event->form->notifies_discord) { - // Send Discord Notification - $this->sendDiscordNotification($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 sendSlackNotification(FormSubmitted $event) + private function sendWebhookNotification(FormSubmitted $event, string $provider) { - if($this->validateSlackWebhookUrl($event->form->slack_webhook_url)){ - $submissionString = ""; - $formatter = (new FormSubmissionFormatter($event->form, $event->data))->outputStringsOnly(); - foreach ($formatter->getFieldsWithValue() as $field) { - $tmpVal = is_array($field['value']) ? implode(",", $field['value']) : $field['value']; - $submissionString .= ">*".ucfirst($field['name'])."*: ".$tmpVal." \n"; - } - - $formURL = url("forms/".$event->form->slug); - $editFormURL = url("forms/".$event->form->slug."/show"); - $submissionId = Hashids::encode($event->data['submission_id']); - $externalLinks = [ - '*<'.$formURL.'|๐Ÿ”— Open Form>*', - '*<'.$editFormURL.'|โœ๏ธ Edit Form>*' - ]; - if($event->form->editable_submissions){ - $externalLinks[] = '*<'.$event->form->share_url.'?submission_id='.$submissionId.'|โœ๏ธ '.$event->form->editable_submissions_button_text.'>*'; - } - - $finalSlackPostData = [ - 'blocks' => [ - [ - 'type' => 'section', - 'text' => [ - 'type' => 'mrkdwn', - 'text' => 'New submission for your form *<'.$formURL.'|'.$event->form->title.':>*', - ] - ], - [ - 'type' => 'section', - 'text' => [ - 'type' => 'mrkdwn', - 'text' => $submissionString - ] - ], - [ - 'type' => 'section', - 'text' => [ - 'type' => 'mrkdwn', - 'text' => implode(' ', $externalLinks), - ] - ], - ] - ]; - - WebhookCall::create() - ->url($event->form->slack_webhook_url) - ->doNotSign() - ->payload($finalSlackPostData) - ->dispatch(); - } - } - - private function validateSlackWebhookUrl($url) - { - return ($url) ? str_contains($url, 'https://hooks.slack.com/') : false; + WebhookHandlerProvider::getProvider( + $event->form, + $event->data, + $provider + )->handle(); } - private function sendDiscordNotification(FormSubmitted $event) + /** + * Sends an email to each email address in the form's notification_emails field + * @param FormSubmitted $event + * @return void + */ + private function sendEmailNotifications(FormSubmitted $event) { - if($this->validateDiscordWebhookUrl($event->form->discord_webhook_url)){ - $submissionString = ""; - $formatter = (new FormSubmissionFormatter($event->form, $event->data))->outputStringsOnly(); - - foreach ($formatter->getFieldsWithValue() as $field) { - $tmpVal = is_array($field['value']) ? implode(",", $field['value']) : $field['value']; - $submissionString .= "**".ucfirst($field['name'])."**: `".$tmpVal."`\n"; - } - - $form_name = $event->form->title; - $form = Form::find($event->form->id); - $formURL = url("forms/".$event->form->slug."/show/submissions"); - - $finalDiscordPostData = [ - "content" => "@here We have received a new submission for **$form_name**", - "username" => config('app.name'), - "avatar_url" => asset('img/logo.png'), - "tts" => false, - "embeds" => [ - [ - "title" => "๐Ÿ”— Go to $form_name", - - "type" => "rich", - - "description" => $submissionString, - - "url" => $formURL, - - "color" => hexdec(str_replace('#', '', $event->form->color)), + if (!$event->form->is_pro || !$event->form->notifies) return; - "footer" => [ - "text" => config('app.name'), - "icon_url" => asset('img/logo.png'), - ], - - "author" => [ - "name" => config('app.name'), - "url" => config('app.url'), - ], - - "fields" => [ - [ - "name" => "Views ๐Ÿ‘€", - "value" => "$form->views_count", - "inline" => true - ], - [ - "name" => "Submissions ๐Ÿ–Š๏ธ", - "value" => "$form->submissions_count", - "inline" => true - ] - ] - ] - ] - ]; - - WebhookCall::create() - ->url($event->form->discord_webhook_url) - ->doNotSign() - ->payload($finalDiscordPostData) - ->dispatch(); - } - } - - private function validateDiscordWebhookUrl($url) - { - return ($url) ? str_contains($url, 'https://discord.com/api/webhooks') : false; + $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)); + }); } } diff --git a/app/Listeners/Forms/PostFormDataToWebhook.php b/app/Listeners/Forms/PostFormDataToWebhook.php deleted file mode 100644 index 0ebd39539..000000000 --- a/app/Listeners/Forms/PostFormDataToWebhook.php +++ /dev/null @@ -1,77 +0,0 @@ -form; - if (!$form->is_pro) return; - $data = $this->getWebhookData($event); - - $this->sendSimpleWebhook($form, $data); - $this->sendZappierWebhooks($form, $data); - } - - private function sendSimpleWebhook(Form $form, array $data) { - if ($form->webhook_url) { - \Log::debug('Sending data to webhook URL',[ - 'webhook_url' => $form->webhook_url, - 'form_id' => $form->id, - 'form_slug' => $form->slug, - ]); - WebhookCall::create() - ->url($form->webhook_url) - ->doNotSign() - ->payload($data) - ->dispatch(); - } - } - - private function sendZappierWebhooks(Form $form, array $data) { - foreach ($form->zappierHooks as $hook) { - \Log::debug('Sending data to Zapier webhook',[ - 'form_id' => $form->id, - 'form_slug' => $form->slug, - ]); - $hook->triggerHook($data); - } - } - - private function getWebhookData(FormSubmitted $event): array { - $formatter = (new FormSubmissionFormatter($event->form, $event->data))->showHiddenFields(); - - $formattedData = []; - foreach ($formatter->getFieldsWithValue() as $field) { - $formattedData[$field['name']] = $field['value']; - } - - $submissionId = Hashids::encode($event->data['submission_id']); - $data = [ - 'form_title' => $event->form->title, - 'form_slug' => $event->form->slug, - 'submission' => $formattedData - ]; - if($event->form->editable_submissions){ - $data['edit_link'] = $event->form->share_url.'?submission_id='.$submissionId; - } - - return $data; - } -} diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index 9f061f444..c1458283c 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -140,6 +140,11 @@ public function getShareUrlAttribute() return url('/forms/'.$this->slug); } + public function getEditUrlAttribute() + { + return url('/forms/'.$this->slug.'/show'); + } + public function getSubmissionsCountAttribute() { return $this->submissions()->count(); diff --git a/app/Models/Integration/FormZapierWebhook.php b/app/Models/Integration/FormZapierWebhook.php index d8333e9fb..eb157c47e 100644 --- a/app/Models/Integration/FormZapierWebhook.php +++ b/app/Models/Integration/FormZapierWebhook.php @@ -3,6 +3,7 @@ namespace App\Models\Integration; use App\Models\Forms\Form; +use App\Service\Forms\Webhooks\WebhookHandlerProvider; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -27,11 +28,13 @@ public function form() return $this->belongsTo(Form::class); } - public function triggerHook(array $data) { - WebhookCall::create() - ->url($this->hook_url) - ->doNotSign() - ->payload($data) - ->dispatch(); + public function triggerHook(array $data) + { + WebhookHandlerProvider::getProvider( + $this->form, + $data, + WebhookHandlerProvider::ZAPIER_PROVIDER, + $this->hook_url + )->handle(); } } diff --git a/app/Notifications/Forms/FailedWebhookNotification.php b/app/Notifications/Forms/FailedWebhookNotification.php new file mode 100644 index 000000000..230985a07 --- /dev/null +++ b/app/Notifications/Forms/FailedWebhookNotification.php @@ -0,0 +1,57 @@ +form = $this->event->meta['form']; + $this->provider = $this->event->meta['provider']; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + return (new MailMessage) + ->subject("Notification issue with your NotionForm: '" . $this->form->title . "'") + ->markdown('mail.form.webhook-error', [ + 'provider' => $this->provider, + 'error' => $this->event->errorMessage, + 'form' => $this->form, + ]); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index af12900b0..164266cdb 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,16 +4,17 @@ use App\Events\Forms\FormSubmitted; use App\Events\Models\FormCreated; +use App\Listeners\FailedWebhookListener; use App\Listeners\Auth\RegisteredListener; use App\Listeners\Forms\FormCreationConfirmation; use App\Listeners\Forms\NotifyFormSubmission; -use App\Listeners\Forms\PostFormDataToWebhook; use App\Listeners\Forms\SubmissionConfirmation; use App\Notifications\Forms\FormCreatedNotification; 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 { @@ -31,8 +32,10 @@ class EventServiceProvider extends ServiceProvider ], FormSubmitted::class => [ NotifyFormSubmission::class, - PostFormDataToWebhook::class, SubmissionConfirmation::class, + ], + WebhookCallFailedEvent::class => [ + FailedWebhookListener::class ] ]; diff --git a/app/Service/Forms/Webhooks/AbstractWebhookHandler.php b/app/Service/Forms/Webhooks/AbstractWebhookHandler.php new file mode 100644 index 000000000..a76d84414 --- /dev/null +++ b/app/Service/Forms/Webhooks/AbstractWebhookHandler.php @@ -0,0 +1,66 @@ +form, $this->data))->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->data['submission_id']); + } + + return $data; + } + + abstract protected function shouldRun(): bool; + + public function handle() + { + if (!$this->shouldRun()) return; + + WebhookCall::create() + // Add context on error, used to notify form owner + ->meta([ + 'type' => 'form_submission', + 'data' => $this->data, + 'form' => $this->form, + 'provider' => $this->getProviderName(), + ]) + ->url($this->getWebhookUrl()) + ->doNotSign() + ->payload($this->getWebhookData()) + ->dispatchSync(); + } +} diff --git a/app/Service/Forms/Webhooks/DiscordHandler.php b/app/Service/Forms/Webhooks/DiscordHandler.php new file mode 100644 index 000000000..446f21794 --- /dev/null +++ b/app/Service/Forms/Webhooks/DiscordHandler.php @@ -0,0 +1,84 @@ +form->discord_webhook_url; + } + + protected function getWebhookData(): array + { + $submissionString = ""; + $formatter = (new FormSubmissionFormatter($this->form, $this->data))->outputStringsOnly(); + + foreach ($formatter->getFieldsWithValue() as $field) { + $tmpVal = is_array($field['value']) ? implode(",", $field['value']) : $field['value']; + $submissionString .= "**" . ucfirst($field['name']) . "**: `" . $tmpVal . "`\n"; + } + + $form_name = $this->form->title; + $formURL = url("forms/" . $this->form->slug . "/show/submissions"); + + return [ + "content" => "@here We have received a new submission for **$form_name**", + "username" => config('app.name'), + "avatar_url" => asset('img/logo.png'), + "tts" => false, + "embeds" => [ + [ + "title" => "๐Ÿ”— Go to $form_name", + + "type" => "rich", + + "description" => $submissionString, + + "url" => $formURL, + + "color" => hexdec(str_replace('#', '', $this->form->color)), + + "footer" => [ + "text" => config('app.name'), + "icon_url" => asset('img/logo.png'), + ], + + "author" => [ + "name" => config('app.name'), + "url" => config('app.url'), + ], + + "fields" => [ + [ + "name" => "Views ๐Ÿ‘€", + "value" => (string)$this->form->views_count, + "inline" => true + ], + [ + "name" => "Submissions ๐Ÿ–Š๏ธ", + "value" => (string)$this->form->submissions_count, + "inline" => true + ] + ] + ] + ] + ]; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()) + && str_contains($this->getWebhookUrl(), 'https://discord.com/api/webhooks') + && $this->form->is_pro; + } +} diff --git a/app/Service/Forms/Webhooks/SimpleWebhookHandler.php b/app/Service/Forms/Webhooks/SimpleWebhookHandler.php new file mode 100644 index 000000000..4d3add03b --- /dev/null +++ b/app/Service/Forms/Webhooks/SimpleWebhookHandler.php @@ -0,0 +1,24 @@ +form->webhook_url; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()) && $this->form->is_pro; + } +} diff --git a/app/Service/Forms/Webhooks/SlackHandler.php b/app/Service/Forms/Webhooks/SlackHandler.php new file mode 100644 index 000000000..2ed292db2 --- /dev/null +++ b/app/Service/Forms/Webhooks/SlackHandler.php @@ -0,0 +1,75 @@ +form->slack_webhook_url; + } + + protected function getWebhookData(): array + { + $submissionString = ''; + $formatter = (new FormSubmissionFormatter($this->form, $this->data))->outputStringsOnly(); + foreach ($formatter->getFieldsWithValue() as $field) { + $tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value']; + $submissionString .= '>*' . ucfirst($field['name']) . '*: ' . $tmpVal . " \n"; + } + + $formURL = url('forms/' . $this->form->slug); + $editFormURL = url('forms/' . $this->form->slug . '/show'); + $submissionId = Hashids::encode($this->data['submission_id']); + $externalLinks = [ + '*<' . $formURL . '|๐Ÿ”— Open Form>*', + '*<' . $editFormURL . '|โœ๏ธ Edit Form>*' + ]; + if ($this->form->editable_submissions) { + $externalLinks[] = '*<' . $this->form->share_url . '?submission_id=' . $submissionId . '|โœ๏ธ ' . $this->form->editable_submissions_button_text . '>*'; + } + + return [ + 'blocks' => [ + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => 'New submission for your form *<' . $formURL . '|' . $this->form->title . ':>*', + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => $submissionString, + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => implode(' ', $externalLinks), + ], + ], + ], + ]; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()) + && str_contains($this->getWebhookUrl(), 'https://hooks.slack.com/') + && $this->form->is_pro; + } +} diff --git a/app/Service/Forms/Webhooks/WebhookHandlerProvider.php b/app/Service/Forms/Webhooks/WebhookHandlerProvider.php new file mode 100644 index 000000000..a271aa095 --- /dev/null +++ b/app/Service/Forms/Webhooks/WebhookHandlerProvider.php @@ -0,0 +1,32 @@ +webhookUrl; + } + + protected function shouldRun(): bool + { + return !is_null($this->getWebhookUrl()); + } +} diff --git a/resources/views/mail/form/webhook-error.blade.php b/resources/views/mail/form/webhook-error.blade.php new file mode 100644 index 000000000..4df9a3164 --- /dev/null +++ b/resources/views/mail/form/webhook-error.blade.php @@ -0,0 +1,15 @@ +@component('mail::message') + +Hello, + +We tried to trigger a **{{$provider}}** notification for your form "{{$form->title}}", but it failed. Here is the error that we got: + +@component('mail::panel') +{{$error}} +@endcomponent + +Click [here to edit your form]({{$form->edit_url}}). + +Contact us via the website live chat if you need any help. + +@endcomponent From 69c2f21c59650e9ae919abf7a42e1919c527cf3e Mon Sep 17 00:00:00 2001 From: Forms Dev Date: Wed, 23 Aug 2023 16:37:55 +0530 Subject: [PATCH 15/16] Slack-Discord extra feature --- app/Http/Requests/UserFormRequest.php | 1 + app/Http/Resources/FormResource.php | 1 + app/Models/Forms/Form.php | 4 +- app/Service/Forms/Webhooks/DiscordHandler.php | 99 ++++++++++--------- app/Service/Forms/Webhooks/SlackHandler.php | 96 +++++++++++------- database/factories/FormFactory.php | 2 + ...710_add_notification_settings_to_forms.php | 32 ++++++ .../components/FormNotificationsDiscord.vue | 24 +++-- .../FormNotificationsMessageActions.vue | 64 ++++++++++++ .../components/FormNotificationsSlack.vue | 24 +++-- resources/js/mixins/form_editor/initForm.js | 1 + 11 files changed, 245 insertions(+), 103 deletions(-) create mode 100644 database/migrations/2023_08_23_100710_add_notification_settings_to_forms.php create mode 100644 resources/js/components/open/forms/components/form-components/components/FormNotificationsMessageActions.vue diff --git a/app/Http/Requests/UserFormRequest.php b/app/Http/Requests/UserFormRequest.php index a0d777c01..79d863319 100644 --- a/app/Http/Requests/UserFormRequest.php +++ b/app/Http/Requests/UserFormRequest.php @@ -43,6 +43,7 @@ public function rules() 'use_captcha' => 'boolean', 'slack_webhook_url' => 'url|nullable', 'discord_webhook_url' => 'url|nullable', + 'notification_settings' => 'nullable', // Customization 'theme' => ['required',Rule::in(Form::THEMES)], diff --git a/app/Http/Resources/FormResource.php b/app/Http/Resources/FormResource.php index 49857ad75..9cdc44c0b 100644 --- a/app/Http/Resources/FormResource.php +++ b/app/Http/Resources/FormResource.php @@ -47,6 +47,7 @@ public function toArray($request) 'notification_emails' => $this->notification_emails, 'slack_webhook_url' => $this->slack_webhook_url, 'discord_webhook_url' => $this->discord_webhook_url, + 'notification_settings' => $this->notification_settings, 'removed_properties' => $this->removed_properties, 'last_edited_human' => $this->updated_at?->diffForHumans(), 'seo_meta' => $this->seo_meta diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index c1458283c..7ba3fcc9e 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -41,6 +41,7 @@ class Form extends Model 'notifications_include_submission', 'slack_webhook_url', 'discord_webhook_url', + 'notification_settings', // integrations 'webhook_url', @@ -95,7 +96,8 @@ class Form extends Model 'closes_at' => 'datetime', 'tags' => 'array', 'removed_properties' => 'array', - 'seo_meta' => 'object' + 'seo_meta' => 'object', + 'notification_settings' => 'object' ]; protected $appends = [ diff --git a/app/Service/Forms/Webhooks/DiscordHandler.php b/app/Service/Forms/Webhooks/DiscordHandler.php index 446f21794..55ebf2d8d 100644 --- a/app/Service/Forms/Webhooks/DiscordHandler.php +++ b/app/Service/Forms/Webhooks/DiscordHandler.php @@ -3,7 +3,8 @@ namespace App\Service\Forms\Webhooks; use App\Service\Forms\FormSubmissionFormatter; -use Illuminate\Support\Str; +use Vinkla\Hashids\Facades\Hashids; +use Illuminate\Support\Arr; class DiscordHandler extends AbstractWebhookHandler { @@ -20,58 +21,60 @@ protected function getWebhookUrl(): ?string protected function getWebhookData(): array { - $submissionString = ""; - $formatter = (new FormSubmissionFormatter($this->form, $this->data))->outputStringsOnly(); + $settings = (array) Arr::get((array)$this->form->notification_settings, 'discord', []); + $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 = 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->data['submission_id']); + $externalLinks[] = '[**โœ๏ธ ' . $this->form->editable_submissions_button_text . '**](' . $this->form->share_url . '?submission_id=' . $submissionId . ')'; + } - foreach ($formatter->getFieldsWithValue() as $field) { - $tmpVal = is_array($field['value']) ? implode(",", $field['value']) : $field['value']; - $submissionString .= "**" . ucfirst($field['name']) . "**: `" . $tmpVal . "`\n"; + $color = hexdec(str_replace('#', '', $this->form->color)); + $blocks = []; + if(Arr::get($settings, 'include_submission_data', true)){ + $submissionString = ""; + $formatter = (new FormSubmissionFormatter($this->form, $this->data))->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 + ]; } - $form_name = $this->form->title; - $formURL = url("forms/" . $this->form->slug . "/show/submissions"); + 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" => "@here We have received a new submission for **$form_name**", - "username" => config('app.name'), - "avatar_url" => asset('img/logo.png'), - "tts" => false, - "embeds" => [ - [ - "title" => "๐Ÿ”— Go to $form_name", - - "type" => "rich", - - "description" => $submissionString, - - "url" => $formURL, - - "color" => hexdec(str_replace('#', '', $this->form->color)), - - "footer" => [ - "text" => config('app.name'), - "icon_url" => asset('img/logo.png'), - ], - - "author" => [ - "name" => config('app.name'), - "url" => config('app.url'), - ], - - "fields" => [ - [ - "name" => "Views ๐Ÿ‘€", - "value" => (string)$this->form->views_count, - "inline" => true - ], - [ - "name" => "Submissions ๐Ÿ–Š๏ธ", - "value" => (string)$this->form->submissions_count, - "inline" => true - ] - ] - ] - ] + '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/Webhooks/SlackHandler.php b/app/Service/Forms/Webhooks/SlackHandler.php index 2ed292db2..5b2faf607 100644 --- a/app/Service/Forms/Webhooks/SlackHandler.php +++ b/app/Service/Forms/Webhooks/SlackHandler.php @@ -3,8 +3,8 @@ namespace App\Service\Forms\Webhooks; use App\Service\Forms\FormSubmissionFormatter; -use Illuminate\Support\Str; use Vinkla\Hashids\Facades\Hashids; +use Illuminate\Support\Arr; class SlackHandler extends AbstractWebhookHandler { @@ -21,48 +21,70 @@ protected function getWebhookUrl(): ?string protected function getWebhookData(): array { - $submissionString = ''; - $formatter = (new FormSubmissionFormatter($this->form, $this->data))->outputStringsOnly(); - foreach ($formatter->getFieldsWithValue() as $field) { - $tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value']; - $submissionString .= '>*' . ucfirst($field['name']) . '*: ' . $tmpVal . " \n"; + $settings = (array) Arr::get((array)$this->form->notification_settings, 'slack', []); + $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 = 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->data['submission_id']); + $externalLinks[] = '*<' . $this->form->share_url . '?submission_id=' . $submissionId . '|โœ๏ธ ' . $this->form->editable_submissions_button_text . '>*'; } - $formURL = url('forms/' . $this->form->slug); - $editFormURL = url('forms/' . $this->form->slug . '/show'); - $submissionId = Hashids::encode($this->data['submission_id']); - $externalLinks = [ - '*<' . $formURL . '|๐Ÿ”— Open Form>*', - '*<' . $editFormURL . '|โœ๏ธ Edit Form>*' + $blocks = [ + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => 'New submission for your form *' . $this->form->title . '*', + ] + ] ]; - if ($this->form->editable_submissions) { - $externalLinks[] = '*<' . $this->form->share_url . '?submission_id=' . $submissionId . '|โœ๏ธ ' . $this->form->editable_submissions_button_text . '>*'; + + if(Arr::get($settings, 'include_submission_data', true)){ + $submissionString = ''; + $formatter = (new FormSubmissionFormatter($this->form, $this->data))->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' => [ - [ - 'type' => 'section', - 'text' => [ - 'type' => 'mrkdwn', - 'text' => 'New submission for your form *<' . $formURL . '|' . $this->form->title . ':>*', - ], - ], - [ - 'type' => 'section', - 'text' => [ - 'type' => 'mrkdwn', - 'text' => $submissionString, - ], - ], - [ - 'type' => 'section', - 'text' => [ - 'type' => 'mrkdwn', - 'text' => implode(' ', $externalLinks), - ], - ], - ], + 'blocks' => $blocks ]; } diff --git a/database/factories/FormFactory.php b/database/factories/FormFactory.php index c7a831e7f..dab01d3e7 100644 --- a/database/factories/FormFactory.php +++ b/database/factories/FormFactory.php @@ -84,6 +84,8 @@ public function definition() 'password' => false, 'tags' => [], 'slack_webhook_url' => null, + 'discord_webhook_url' => null, + 'notification_settings' => [], 'editable_submissions_button_text' => 'Edit submission', 'confetti_on_submission' => false, 'seo_meta' => [], diff --git a/database/migrations/2023_08_23_100710_add_notification_settings_to_forms.php b/database/migrations/2023_08_23_100710_add_notification_settings_to_forms.php new file mode 100644 index 000000000..b091f6b7f --- /dev/null +++ b/database/migrations/2023_08_23_100710_add_notification_settings_to_forms.php @@ -0,0 +1,32 @@ +json('notification_settings')->default('{}')->nullable(true); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('notification_settings'); + }); + } +}; diff --git a/resources/js/components/open/forms/components/form-components/components/FormNotificationsDiscord.vue b/resources/js/components/open/forms/components/form-components/components/FormNotificationsDiscord.vue index f8dc0de62..3ebca14b7 100644 --- a/resources/js/components/open/forms/components/form-components/components/FormNotificationsDiscord.vue +++ b/resources/js/components/open/forms/components/form-components/components/FormNotificationsDiscord.vue @@ -28,23 +28,29 @@ - - - +
          diff --git a/resources/js/components/open/forms/components/form-components/components/FormNotificationsSlack.vue b/resources/js/components/open/forms/components/form-components/components/FormNotificationsSlack.vue index 2e7cb7edc..0de50b8be 100644 --- a/resources/js/components/open/forms/components/form-components/components/FormNotificationsSlack.vue +++ b/resources/js/components/open/forms/components/form-components/components/FormNotificationsSlack.vue @@ -28,22 +28,30 @@ - - - +