diff --git a/app/controllers/sms_settings_controller.rb b/app/controllers/sms_settings_controller.rb index 96168605..5c6498b8 100644 --- a/app/controllers/sms_settings_controller.rb +++ b/app/controllers/sms_settings_controller.rb @@ -3,14 +3,61 @@ class SmsSettingsController < ApplicationController before_action :load_encrypted_config authorize_resource :encrypted_config, only: :index - authorize_resource :encrypted_config, parent: false, except: :index + authorize_resource :encrypted_config, parent: false, only: %i[create test_message] def index; end + def create + new_value = build_sms_value + + if @encrypted_config.update(value: new_value) + redirect_to settings_sms_path, notice: I18n.t('changes_have_been_saved') + else + render :index, status: :unprocessable_content + end + rescue StandardError => e + flash[:alert] = e.message + render :index, status: :unprocessable_content + end + + def test_message + to = params[:phone].to_s.strip + if to.blank? + flash[:alert] = 'Enter a phone number to test against.' + return redirect_to(settings_sms_path) + end + + Sms.send_message(account: current_account, + to: to, + text: "Test SMS from #{Wabosign.product_name}.") + + redirect_to settings_sms_path, notice: "Test SMS dispatched to #{to}." + rescue Sms::Error => e + redirect_to settings_sms_path, alert: "Test failed: #{e.message}" + rescue StandardError => e + redirect_to settings_sms_path, alert: "Unexpected error: #{e.message}" + end + private def load_encrypted_config @encrypted_config = - EncryptedConfig.find_or_initialize_by(account: current_account, key: 'sms_configs') + EncryptedConfig.find_or_initialize_by(account: current_account, + key: EncryptedConfig::SMS_CONFIGS_KEY) + end + + def build_sms_value + submitted = params.require(:encrypted_config).permit(value: {})[:value].to_h + existing = @encrypted_config.value || {} + + # Preserve the saved Basic Auth token when the field is left blank + # (the form never echoes it back, so an unedited submit would otherwise + # wipe it out). + submitted['basic_auth_token'] = existing['basic_auth_token'] if submitted['basic_auth_token'].to_s.empty? + + submitted['enabled'] = submitted['enabled'].to_s == '1' || submitted['enabled'].to_s == 'true' + submitted['provider'] = (submitted['provider'].presence || 'bulkvs').to_s + + submitted.compact end end diff --git a/app/controllers/submitters_send_sms_controller.rb b/app/controllers/submitters_send_sms_controller.rb new file mode 100644 index 00000000..8a6828de --- /dev/null +++ b/app/controllers/submitters_send_sms_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class SubmittersSendSmsController < ApplicationController + load_and_authorize_resource :submitter + + def create + if @submitter.phone.blank? + return redirect_back(fallback_location: submission_path(@submitter.submission), + alert: I18n.t('submitter_has_no_phone_number', + default: 'Submitter has no phone number.')) + end + + unless Sms.enabled_for?(@submitter.account) + return redirect_back(fallback_location: submission_path(@submitter.submission), + alert: I18n.t('sms_provider_not_configured', + default: 'SMS provider is not configured.')) + end + + if SubmissionEvent.exists?(submitter: @submitter, + event_type: 'send_sms', + created_at: 10.hours.ago..Time.current) + return redirect_back(fallback_location: submission_path(@submitter.submission), + alert: I18n.t('sms_has_been_sent_already', + default: 'SMS has already been sent recently.')) + end + + SendSubmitterInvitationSmsJob.perform_async('submitter_id' => @submitter.id) + + @submitter.sent_at ||= Time.current + @submitter.save! + + redirect_back(fallback_location: submission_path(@submitter.submission), + notice: I18n.t('sms_has_been_sent', default: 'SMS has been sent.')) + end +end diff --git a/app/jobs/send_submitter_invitation_sms_job.rb b/app/jobs/send_submitter_invitation_sms_job.rb new file mode 100644 index 00000000..fcaba188 --- /dev/null +++ b/app/jobs/send_submitter_invitation_sms_job.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class SendSubmitterInvitationSmsJob + include Sidekiq::Job + + sidekiq_options retry: 5 + + def perform(params = {}) + submitter = Submitter.find(params['submitter_id']) + + return if submitter.completed_at? + return if submitter.submission.archived_at? + return if submitter.template&.archived_at? + return if submitter.phone.blank? + return unless Sms.enabled_for?(submitter.account) + + text = build_body(submitter) + + Sms.send_message(account: submitter.account, to: submitter.phone, text: text) + + SubmissionEvent.create!(submitter: submitter, event_type: 'send_sms') + + submitter.sent_at ||= Time.current + submitter.save! + end + + private + + def build_body(submitter) + account_template = AccountConfig.find_by(account_id: submitter.account_id, + key: AccountConfig::SUBMITTER_INVITATION_SMS_KEY) + template = account_template&.value.presence || + I18n.t('submitter_invitation_sms_body_sign', + locale: submitter.account.locale, + default: '{account.name} has invited you to sign a document: {submitter.link}') + + ReplaceEmailVariables.call(template, submitter: submitter, tracking_event_type: 'click_sms') + end +end diff --git a/app/models/account_config.rb b/app/models/account_config.rb index 2db37d70..eb4aabd7 100644 --- a/app/models/account_config.rb +++ b/app/models/account_config.rb @@ -22,6 +22,7 @@ # class AccountConfig < ApplicationRecord SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email' + SUBMITTER_INVITATION_SMS_KEY = 'submitter_invitation_sms' SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email' SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email' SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email' diff --git a/app/models/encrypted_config.rb b/app/models/encrypted_config.rb index 92e2d437..1217e51b 100644 --- a/app/models/encrypted_config.rb +++ b/app/models/encrypted_config.rb @@ -27,7 +27,8 @@ class EncryptedConfig < ApplicationRecord ESIGN_CERTS_KEY = 'esign_certs', TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url', APP_URL_KEY = 'app_url', - GOOGLE_SSO_KEY = 'google_sso_configs' + GOOGLE_SSO_KEY = 'google_sso_configs', + SMS_CONFIGS_KEY = 'sms_configs' ].freeze belongs_to :account diff --git a/app/views/personalization_settings/_signature_request_sms_form.html.erb b/app/views/personalization_settings/_signature_request_sms_form.html.erb new file mode 100644 index 00000000..88c81d8e --- /dev/null +++ b/app/views/personalization_settings/_signature_request_sms_form.html.erb @@ -0,0 +1,31 @@ +
+ +
+
+ Signature request SMS +
+
+
+ <% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_SMS_KEY) %> + <% default_body = I18n.t('submitter_invitation_sms_body_sign', + default: '{account.name} has invited you to sign a document: {submitter.link}') %> + <%= form_for config, url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> + <%= f.hidden_field :key %> +
+ <%= f.label :value, 'Message body', class: 'label' %> + <%= f.text_area :value, + value: f.object.value.is_a?(String) ? f.object.value : default_body, + rows: 4, + class: 'base-input font-mono text-sm', + dir: 'auto' %> + + Available variables: {account.name}, {submitter.link}, {submitter.name}, {submitter.first_name}, {submission.name}, {sender.name}. + Keep it short — carriers split messages over 160 characters into multiple billable segments. + +
+
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %> +
+ <% end %> +
+
diff --git a/app/views/personalization_settings/show.html.erb b/app/views/personalization_settings/show.html.erb index 438da311..f4ba1639 100644 --- a/app/views/personalization_settings/show.html.erb +++ b/app/views/personalization_settings/show.html.erb @@ -8,6 +8,7 @@ <%= render 'signature_request_email_form' %> <%= render 'documents_copy_email_form' %> <%= render 'submitter_completed_email_form' %> + <%= render 'signature_request_sms_form' %>

<%= t('company_logo') %> diff --git a/app/views/sms_settings/_placeholder.html.erb b/app/views/sms_settings/_placeholder.html.erb deleted file mode 100644 index 863f1c1d..00000000 --- a/app/views/sms_settings/_placeholder.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -

- <%= svg_icon('info_circle', class: 'w-6 h-6') %> -
-

- <%= t('send_signature_requests_via_sms') %> -

-

- SMS signing invitations require an SMS provider integration (e.g. Twilio, AWS SNS) that is not bundled with this open-source edition. Wire up your preferred provider in the submitter mailer/SMS service to enable. -

-
-
diff --git a/app/views/sms_settings/index.html.erb b/app/views/sms_settings/index.html.erb index ce3d516c..f572f851 100644 --- a/app/views/sms_settings/index.html.erb +++ b/app/views/sms_settings/index.html.erb @@ -2,7 +2,87 @@ <%= render 'shared/settings_nav' %>

SMS

- <%= render 'placeholder' %> + + <% value = @encrypted_config.value || {} %> + <% sms_live = Sms.enabled_for?(current_account) %> + + <% if sms_live %> +
+ <%= svg_icon('discount_check_filled', class: 'w-6 h-6') %> +
+

SMS is enabled

+

+ Provider: <%= value['provider'].to_s.upcase %>. + From: <%= value['from_number'] %>. +

+
+
+ <% else %> +
+ <%= svg_icon('info_circle', class: 'w-6 h-6') %> +
+

SMS provider is not configured

+

+ WaboSign currently supports BulkVS. Paste the Basic Auth header value from the BulkVS portal below. +

+
+
+ <% end %> + + <%= form_for @encrypted_config, url: settings_sms_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> + <%= f.fields_for :value do |ff| %> +
+ +
+
+ <%= ff.label :provider, 'Provider', class: 'label' %> + <%= ff.select :provider, [['BulkVS', 'bulkvs']], { selected: value['provider'] || 'bulkvs' }, class: 'base-select' %> +
+
+ <%= ff.label :basic_auth_token, 'BulkVS Basic Auth Token', class: 'label' %> + <%= ff.password_field :basic_auth_token, class: 'base-input', placeholder: value['basic_auth_token'].present? ? '*************' : 'Paste from BulkVS portal' %> + <% if value['basic_auth_token'].present? %> + Leave blank to keep the saved token. + <% else %> + In the BulkVS portal, open the API tab and copy the pre-encoded Basic Auth header value (do not include "Basic "). + <% end %> +
+
+ <%= ff.label :from_number, 'From Number', class: 'label' %> + <%= ff.text_field :from_number, value: value['from_number'], class: 'base-input', placeholder: '15551234567' %> + E.164 format (digits only, country code first; e.g. 15551234567). +
+
+ <%= ff.label :delivery_webhook_url, 'Delivery Status Webhook (optional)', class: 'label' %> + <%= ff.url_field :delivery_webhook_url, value: value['delivery_webhook_url'], class: 'base-input', placeholder: 'https://your-app.example/webhooks/sms' %> + If set, BulkVS will POST delivery-status events here for each message. +
+ <% end %> +
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %> +
+ <% end %> + + <% if sms_live %> +
+
+

Send a test SMS

+ <%= form_with url: test_message_settings_sms_path, method: :post, html: { autocomplete: 'off', class: 'space-y-3' } do |f| %> +
+ + + A short test message is sent to this number using your saved config. +
+
+ +
+ <% end %> +
+
+ <% end %>
diff --git a/app/views/submissions/_send_sms.html.erb b/app/views/submissions/_send_sms.html.erb index 4637e3ac..f1622a4a 100644 --- a/app/views/submissions/_send_sms.html.erb +++ b/app/views/submissions/_send_sms.html.erb @@ -1,3 +1,11 @@ -
- <%= render 'sms_settings/placeholder' %> -
+<% if Sms.enabled_for?(current_account) && local_assigns[:submitter]&.phone.present? %> +
+ +
+<% end %> diff --git a/app/views/submissions/_send_sms_button.html.erb b/app/views/submissions/_send_sms_button.html.erb index 178ad7cf..7017b1d0 100644 --- a/app/views/submissions/_send_sms_button.html.erb +++ b/app/views/submissions/_send_sms_button.html.erb @@ -1,7 +1,24 @@
-
- -
+ <% if Sms.enabled_for?(current_account) %> + <% if submitter.phone.present? %> + <%= button_to submitter_send_sms_path(submitter_id: submitter.id), + method: :post, + class: 'btn btn-sm btn-primary w-full', + data: { turbo_confirm: submitter.sent_at? ? t('are_you_sure_') : nil } do %> + <%= submitter.sent_at? ? t('re_send_sms') : t('send_sms') %> + <% end %> + <% else %> +
+ +
+ <% end %> + <% else %> +
+ +
+ <% end %>
diff --git a/config/routes.rb b/config/routes.rb index ff676a3e..5bdad31d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -178,13 +178,18 @@ Rails.application.routes.draw do resources :download, only: %i[index], controller: 'submitters_download', constraints: { submitter_id: /\d+/ } resources :download, only: %i[index], controller: 'submit_form_completed_download' resources :send_email, only: %i[create], controller: 'submitters_send_email' + resources :send_sms, only: %i[create], controller: 'submitters_send_sms' end scope '/settings', as: :settings do unless Wabosign.multitenant? resources :storage, only: %i[index create], controller: 'storage_settings' resources :search_entries_reindex, only: %i[create] - resources :sms, only: %i[index], controller: 'sms_settings' + resources :sms, only: %i[index create], controller: 'sms_settings' do + collection do + post :test_message + end + end resources :mcp, only: %i[index new create destroy], controller: 'mcp_settings' end if Wabosign.demo? || !Wabosign.multitenant? diff --git a/lib/sms.rb b/lib/sms.rb new file mode 100644 index 00000000..d8f07533 --- /dev/null +++ b/lib/sms.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Sms + class Error < StandardError; end + class NotConfiguredError < Error; end + class ProviderError < Error; end + class InvalidNumberError < Error; end + + SUPPORTED_PROVIDERS = %w[bulkvs].freeze + + module_function + + # Returns the SMS configuration hash for an account, with the same keys the + # form posts: { provider, enabled, basic_auth_token, from_number, + # delivery_webhook_url }. Returns nil if no record exists. + def configuration_for(account) + return nil if account.nil? + + record = EncryptedConfig.find_by(account_id: account.id, key: EncryptedConfig::SMS_CONFIGS_KEY) + record&.value + end + + def enabled_for?(account) + config = configuration_for(account) + config.is_a?(Hash) && + config['enabled'] && + SUPPORTED_PROVIDERS.include?(config['provider'].to_s) && + config['basic_auth_token'].to_s.present? && + config['from_number'].to_s.present? + end + + # Send an SMS via the account's configured provider. + # + # account: a WaboSign Account record + # to: the recipient phone number (E.164 string, leading + tolerated) + # text: the message body (already variable-substituted) + # webhook: optional override of the per-message delivery_status_webhook_url + # + # Returns the provider's parsed JSON response on success. Raises + # NotConfiguredError or ProviderError on failure. + def send_message(account:, to:, text:, webhook: nil) + config = configuration_for(account) + raise NotConfiguredError, 'SMS provider is not configured' unless enabled_for?(account) + + provider = config['provider'].to_s + case provider + when 'bulkvs' + Sms::Providers::Bulkvs.new(config).deliver(to: to, text: text, webhook: webhook) + else + raise NotConfiguredError, "Unsupported SMS provider: #{provider.inspect}" + end + end + + # Normalize a phone number to E.164 (digits-only, no '+'). BulkVS expects + # eleven-digit US numbers like 15551234567; international numbers are passed + # through as-is once stripped of formatting characters. + def normalize_phone(raw) + digits = raw.to_s.gsub(/[^\d]/, '') + raise InvalidNumberError, "Invalid phone number: #{raw.inspect}" if digits.length < 8 + + digits + end +end diff --git a/lib/sms/providers/bulkvs.rb b/lib/sms/providers/bulkvs.rb new file mode 100644 index 00000000..71ca5e60 --- /dev/null +++ b/lib/sms/providers/bulkvs.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Sms + module Providers + # Thin wrapper around the BulkVS messageSend API. + # + # Docs: https://portal.bulkvs.com/api/v1.0/documentation + # + # Request shape: + # POST /api/v1.0/messageSend + # Authorization: Basic + # Content-Type: application/json + # { "From": "", "To": ["", ...], "Message": "", + # "delivery_status_webhook_url": "" } + # + # Response shape (success): JSON with at least { "Status": "...", ... } + # Response shape (error): non-2xx + JSON body with error details + class Bulkvs + ENDPOINT = 'https://portal.bulkvs.com/api/v1.0/messageSend' + TIMEOUT_SECONDS = 15 + + def initialize(config) + @token = config['basic_auth_token'].to_s.strip + @from = Sms.normalize_phone(config['from_number']) + @config_webhook = config['delivery_webhook_url'].to_s.strip + end + + def deliver(to:, text:, webhook: nil) + body = { + 'From' => @from, + 'To' => Array(to).map { |n| Sms.normalize_phone(n) }, + 'Message' => text.to_s + } + effective_webhook = webhook.presence || @config_webhook.presence + body['delivery_status_webhook_url'] = effective_webhook if effective_webhook + + response = http_post(body) + parse_response!(response, body) + end + + private + + def http_post(body) + uri = URI(ENDPOINT) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.read_timeout = TIMEOUT_SECONDS + http.open_timeout = TIMEOUT_SECONDS + + request = Net::HTTP::Post.new(uri.request_uri) + request['Authorization'] = "Basic #{@token}" + request['Content-Type'] = 'application/json' + request['Accept'] = 'application/json' + request.body = JSON.generate(body) + + http.request(request) + end + + def parse_response!(response, request_body) + body = begin + response.body.to_s.empty? ? {} : JSON.parse(response.body) + rescue JSON::ParserError + { 'raw' => response.body.to_s } + end + + return body if response.is_a?(Net::HTTPSuccess) + + message = body['Description'] || body['Status'] || body['error'] || + body['raw'] || "HTTP #{response.code}" + raise Sms::ProviderError, + "BulkVS rejected request (HTTP #{response.code}): #{message}. " \ + "Request body: #{JSON.generate(request_body.except('Message'))}." + end + end + end +end