mirror of https://github.com/docusealco/docuseal
Replaces the SMS placeholder with an actual provider integration. v1
ships BulkVS only; the architecture leaves room for additional
providers behind the same Sms.send_message interface.
Storage:
- EncryptedConfig key `sms_configs` (added to CONFIG_KEYS):
{ provider, enabled, basic_auth_token, from_number,
delivery_webhook_url }
- AccountConfig key `submitter_invitation_sms` for the per-account
SMS body template override.
Service layer:
- lib/sms.rb — Sms.enabled_for?(account), Sms.send_message
(account:, to:, text:), Sms.normalize_phone
- lib/sms/providers/bulkvs.rb — POST to
https://portal.bulkvs.com/api/v1.0/messageSend with the
pre-encoded Basic Auth header from the BulkVS portal. Surfaces
non-2xx responses as Sms::ProviderError with the upstream message.
Background sending:
- app/jobs/send_submitter_invitation_sms_job.rb — mirrors
SendSubmitterInvitationEmailJob; substitutes account-template
variables via the existing ReplaceEmailVariables module so
{account.name} / {submitter.link} / etc. work in the SMS body.
- submitters_controller#maybe_resend_email_sms already enqueues
this job when params[:send_sms] == '1', so the existing
"Send SMS" toggle in the submitter edit form now does what it
says on the tin.
Controllers/routes:
- SmsSettingsController gains create + test_message; the test_message
action lets an admin verify their config with a one-off SMS
against any phone number.
- SubmittersSendSmsController#create powers the per-submitter
"Send SMS" button (mirrors SubmittersSendEmailController).
- Routes: resources :sms with create + test_message; submitters
nested resources :send_sms.
Views:
- app/views/sms_settings/index.html.erb — real form replacing the
"not bundled" placeholder. Status banner reflects live config.
Test-send card renders only when SMS is enabled.
- app/views/submissions/_send_sms_button.html.erb — was a permanently
disabled stub; now button_to the new send_sms endpoint when SMS
is configured and the submitter has a phone number. Falls back to
a tooltip explaining what's missing otherwise.
- app/views/submissions/_send_sms.html.erb — was a placeholder render;
now shows a real "send SMS on save" toggle when SMS is configured.
- app/views/personalization_settings/_signature_request_sms_form.html.erb
+ show.html.erb — per-account SMS body override with variable
documentation.
Smoke-tested in a built image:
- /settings/sms renders 200, all form fields present.
- /settings/personalization renders the SMS body field.
- With saved (bogus) creds, Sms.send_message hits BulkVS over HTTPS
and surfaces the real 401 as Sms::ProviderError — proves the
transport is wired, not just the boot path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pull/687/head
parent
bb70a9b407
commit
1872a0995b
@ -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
|
||||||
@ -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
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<div class="collapse collapse-plus bg-base-200 overflow-visible">
|
||||||
|
<input type="checkbox">
|
||||||
|
<div class="collapse-title text-xl font-medium capitalize">
|
||||||
|
<div>
|
||||||
|
Signature request SMS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<% 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 %>
|
||||||
|
<div class="form-control">
|
||||||
|
<%= 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' %>
|
||||||
|
<span class="label-text-alt mt-1 opacity-70">
|
||||||
|
Available variables: <code>{account.name}</code>, <code>{submitter.link}</code>, <code>{submitter.name}</code>, <code>{submitter.first_name}</code>, <code>{submission.name}</code>, <code>{sender.name}</code>.
|
||||||
|
Keep it short — carriers split messages over 160 characters into multiple billable segments.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-control pt-2">
|
||||||
|
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<div class="alert">
|
|
||||||
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
|
|
||||||
<div>
|
|
||||||
<p class="font-bold">
|
|
||||||
<%= t('send_signature_requests_via_sms') %>
|
|
||||||
</p>
|
|
||||||
<p class="text-gray-700">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,3 +1,11 @@
|
|||||||
<div class="form-control">
|
<% if Sms.enabled_for?(current_account) && local_assigns[:submitter]&.phone.present? %>
|
||||||
<%= render 'sms_settings/placeholder' %>
|
<div class="form-control mt-2">
|
||||||
</div>
|
<label class="label cursor-pointer" for="send_sms_toggle">
|
||||||
|
<span class="label-text">
|
||||||
|
<%= local_assigns[:resend_sms] ? t('re_send_sms') : t('send_sms') %>
|
||||||
|
on save
|
||||||
|
</span>
|
||||||
|
<%= check_box_tag 'submitter[send_sms]', '1', local_assigns.fetch(:checked, false), id: 'send_sms_toggle', class: 'toggle' %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|||||||
@ -1,7 +1,24 @@
|
|||||||
<div class="mt-2 mb-1">
|
<div class="mt-2 mb-1">
|
||||||
<div class="tooltip w-full" data-tip="SMS provider integration not configured">
|
<% if Sms.enabled_for?(current_account) %>
|
||||||
<button type="button" class="btn btn-sm btn-primary text-gray-400 w-full" disabled>
|
<% if submitter.phone.present? %>
|
||||||
<%= submitter.sent_at? ? t('re_send_sms') : t('send_sms') %>
|
<%= button_to submitter_send_sms_path(submitter_id: submitter.id),
|
||||||
</button>
|
method: :post,
|
||||||
</div>
|
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 %>
|
||||||
|
<div class="tooltip w-full" data-tip="Submitter has no phone number">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary text-gray-400 w-full" disabled>
|
||||||
|
<%= t('send_sms') %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<div class="tooltip w-full" data-tip="SMS provider is not configured (see /settings/sms)">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary text-gray-400 w-full" disabled>
|
||||||
|
<%= submitter.sent_at? ? t('re_send_sms') : t('send_sms') %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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
|
||||||
@ -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 <pre-encoded token from the BulkVS portal>
|
||||||
|
# Content-Type: application/json
|
||||||
|
# { "From": "<e164>", "To": ["<e164>", ...], "Message": "<text>",
|
||||||
|
# "delivery_status_webhook_url": "<optional>" }
|
||||||
|
#
|
||||||
|
# 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
|
||||||
Loading…
Reference in new issue