diff --git a/data/messages/templates/sms_compliance/help.en.sms.liquid b/data/messages/templates/sms_compliance/help.en.sms.liquid new file mode 100644 index 00000000..498f8223 --- /dev/null +++ b/data/messages/templates/sms_compliance/help.en.sms.liquid @@ -0,0 +1 @@ +Thanks! We'll be in touch soon. STOP to unsubscribe. \ No newline at end of file diff --git a/data/messages/templates/sms_compliance/optin.en.sms.liquid b/data/messages/templates/sms_compliance/optin.en.sms.liquid new file mode 100644 index 00000000..a06d5ae2 --- /dev/null +++ b/data/messages/templates/sms_compliance/optin.en.sms.liquid @@ -0,0 +1 @@ +Thanks for signing up for suma! Msg&Data rates may apply. STOP to unsubscribe or HELP for assistance. \ No newline at end of file diff --git a/data/messages/templates/sms_compliance/optout.en.sms.liquid b/data/messages/templates/sms_compliance/optout.en.sms.liquid new file mode 100644 index 00000000..10d87160 --- /dev/null +++ b/data/messages/templates/sms_compliance/optout.en.sms.liquid @@ -0,0 +1 @@ +You have successfully unsubscribed from suma and will no longer receive SMS messages. START to resubscribe. \ No newline at end of file diff --git a/db/migrations/057_front_marketing.rb b/db/migrations/057_front_marketing.rb new file mode 100644 index 00000000..01aae515 --- /dev/null +++ b/db/migrations/057_front_marketing.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +Sequel.migration do + change do + alter_table(:message_preferences) do + rename_column :account_updates_optout, :account_updates_sms_optout + add_column :account_updates_email_optout, :boolean, default: false, null: false + add_column :marketing_sms_optout, :boolean, default: false, null: false + add_column :marketing_email_optout, :boolean, default: false, null: false + end + end +end diff --git a/db/migrations/058_signalwire_unsubscribe.rb b/db/migrations/058_signalwire_unsubscribe.rb new file mode 100644 index 00000000..623b883f --- /dev/null +++ b/db/migrations/058_signalwire_unsubscribe.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + if ENV["RACK_ENV"] == "test" + run <<~SQL + CREATE TABLE signalwire_message_v1_fixture ( + pk bigserial PRIMARY KEY, + signalwire_id text UNIQUE NOT NULL, + date_created timestamptz, + date_sent timestamptz, + date_updated timestamptz, + direction text, + "from" text, + status text, + "to" text, + data jsonb NOT NULL + ); + CREATE INDEX IF NOT EXISTS svi_fixture_date_created_idx ON signalwire_message_v1_fixture (date_created); + CREATE INDEX IF NOT EXISTS svi_fixture_date_sent_idx ON signalwire_message_v1_fixture (date_sent); + CREATE INDEX IF NOT EXISTS svi_fixture_date_updated_idx ON signalwire_message_v1_fixture (date_updated); + CREATE INDEX IF NOT EXISTS svi_fixture_from_idx ON signalwire_message_v1_fixture ("from"); + CREATE INDEX IF NOT EXISTS svi_fixture_to_idx ON signalwire_message_v1_fixture ("to"); + SQL + end + end + + down do + run("DROP TABLE signalwire_message_v1_fixture") if ENV["RACK_ENV"] == "test" + end +end diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 00000000..6f85441f --- /dev/null +++ b/docs/support.md @@ -0,0 +1,60 @@ +# Customer Support and Marketing + +Suma is built with enhanced support to use [Front](https://front.com) for customer support and marketing. +It is possible to not use Front, and manage support and marketing manually, +but if you use Front you will get better features, including: + +- Automatic updating of Front Contacts based on Suma members +- Automatic managing of a Front marketing List (subscribe/unsubscribe users) + +## Configuration + +Set `FRONTAPP_AUTH_TOKEN` to your team's auth token. + +See `Suma::Frontapp` for more configuration options. + +## Automatic Contact updating + +Whenever a `Suma::Member` is created or updated, we create or update the Front Contact. +See `Suma::Async::FrontappUpsertContact` for more info. + +## Automatic marketing List management + +**NOTE: Using Front for marketing requires the use of custom channels. You MUST NOT send marketing messages +directly through Front's email system.** + +Instead, you can hook up a [Front Channel like WebhookDB/Signalwire](https://docs.webhookdb.com/guides/front-channel-signalwire/) +to send messages you compose in Front through Signalwire, and update SMS replies into Front messages. +Ultimately how you send and sync messages is outside the scope of this document, +but just don't use the default Front channel to do it. + +Aside from that, list management requires more configuration, since we have to manage subscribes and unsubscribes. + +Create two lists, one for SMS and another for Email: + +- Create a List in Front, from . + Call it something like "SMS Marketing". +- Then, click on the list. In the URL will be an ID, like `19062177`. +- Copy this ID, and set `FRONTAPP_MARKETING_SMS_LIST_ID` to it, to identify the list to store subscribed contacts. +- Create another list called something like "Email Marketing", copy its ID, and set `FRONTAPP_MARKETING_EMAIL_LIST_ID` to it. + +Whenever fields on `Suma::Message::Preferences` are modified, Suma will update the lists the member's Front Contact is on. + +## Updating Suma Preferences from Email/SMS + +The last part of this is automatically updating a member's message preferences from external actions, +like an email with 'Unsubscribe' or a "STOP" text. + +Here are the ways to keep preferences updated: + +### SMS via Signalwire and WebhookDB + +This requires a [Signalwire Messaging WebhookDB Integration](https://docs.webhookdb.com/integrations/signalwire_message_v1/). + +- Set `SIGNALWIRE_MARKETING_NUMBER` and `WEBHOOKDB_SIGNALWIRE_MESSAGES_TABLE`. +- Whenever a text with a STOP keyword (`SIGNALWIRE_MESSAGE_MARKETING_SMS_UNSUBSCRIBE_KEYWORDS`) is received, opt-out is set. +- Whenever a text with a START keyword (`SIGNALWIRE_MESSAGE_MARKETING_SMS_RESUBSCRIBE_KEYWORDS`) is received, opt-in is set. +- Whenenever a text with a HELP keyword (`SIGNALWIRE_MESSAGE_MARKETING_SMS_HELP_KEYWORDS`) is received, no action is taken. + Instead, the Front channel will see the inbound message and create a Front conversation, + which should be handled by a support agent. + diff --git a/lib/suma/api/webhookdb.rb b/lib/suma/api/webhookdb.rb index 097059cb..ccbcb53e 100644 --- a/lib/suma/api/webhookdb.rb +++ b/lib/suma/api/webhookdb.rb @@ -2,6 +2,7 @@ require "suma/api" +require "suma/async/signalwire_process_optouts" require "suma/async/stripe_refunds_backfiller" class Suma::API::Webhookdb < Suma::API::V1 @@ -15,5 +16,13 @@ class Suma::API::Webhookdb < Suma::API::V1 status 202 present({o: "k"}) end + + post :signalwire_message_v1 do + h = env["HTTP_WHDB_WEBHOOK_SECRET"] + unauthenticated! unless h == Suma::Webhookdb.signalwire_messages_secret + Suma::Async::SignalwireProcessOptouts.perform_async + status 202 + present({o: "k"}) + end end end diff --git a/lib/suma/async.rb b/lib/suma/async.rb index e7f5d340..59b6cbda 100644 --- a/lib/suma/async.rb +++ b/lib/suma/async.rb @@ -27,6 +27,8 @@ module Suma::Async "suma/async/deprecated_jobs", "suma/async/emailer", "suma/async/ensure_default_member_ledgers_on_create", + "suma/async/frontapp_list_sync", + "suma/async/frontapp_upsert_contact", "suma/async/funding_transaction_processor", "suma/async/member_onboarding_verified_dispatch", "suma/async/message_dispatched", @@ -36,10 +38,10 @@ module Suma::Async "suma/async/process_anon_proxy_inbound_webhookdb_relays", "suma/async/reset_code_create_dispatch", "suma/async/reset_code_update_twilio", + "suma/async/signalwire_process_optouts", "suma/async/stripe_refunds_backfiller", "suma/async/sync_lime_free_bike_status_gbfs", "suma/async/sync_lime_geofencing_zones_gbfs", - "suma/async/upsert_frontapp_contact", ].freeze configurable(:async) do diff --git a/lib/suma/async/deprecated_jobs.rb b/lib/suma/async/deprecated_jobs.rb index fddf6155..e50ee70c 100644 --- a/lib/suma/async/deprecated_jobs.rb +++ b/lib/suma/async/deprecated_jobs.rb @@ -12,4 +12,5 @@ # Then it can be deleted later. "Async::AutomationTriggerRunner", "Async::TopicShim", + "Async::UpsertFrontappContact", ) diff --git a/lib/suma/async/frontapp_list_sync.rb b/lib/suma/async/frontapp_list_sync.rb new file mode 100644 index 00000000..f55c48d6 --- /dev/null +++ b/lib/suma/async/frontapp_list_sync.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "amigo/scheduled_job" +require "suma/async" +require "suma/frontapp/list_sync" + +class Suma::Async::FrontappListSync + extend Amigo::ScheduledJob + + sidekiq_options(Suma::Async.cron_job_options) + cron "50 6 * * *" + splay 5 + + def _perform + return unless Suma::Frontapp.configured? + return unless Suma::Frontapp.list_sync_enabled + Suma::Frontapp::ListSync.new(now: Time.now).run + end +end diff --git a/lib/suma/async/upsert_frontapp_contact.rb b/lib/suma/async/frontapp_upsert_contact.rb similarity index 87% rename from lib/suma/async/upsert_frontapp_contact.rb rename to lib/suma/async/frontapp_upsert_contact.rb index 1b694edd..8e6fc0b7 100644 --- a/lib/suma/async/upsert_frontapp_contact.rb +++ b/lib/suma/async/frontapp_upsert_contact.rb @@ -2,7 +2,7 @@ require "amigo/job" -class Suma::Async::UpsertFrontappContact +class Suma::Async::FrontappUpsertContact extend Amigo::Job on(/^suma\.member\.(created|updated)$/) @@ -16,6 +16,4 @@ def _perform(event) end member.frontapp.upsert_contact end - - Amigo.register_job(self) end diff --git a/lib/suma/async/signalwire_process_optouts.rb b/lib/suma/async/signalwire_process_optouts.rb new file mode 100644 index 00000000..de94533b --- /dev/null +++ b/lib/suma/async/signalwire_process_optouts.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "amigo/scheduled_job" +require "suma/async" +require "suma/message/signalwire_webhookdb_optout_processor" + +class Suma::Async::SignalwireProcessOptouts + extend Amigo::ScheduledJob + + sidekiq_options(Suma::Async.cron_job_options) + cron "*/30 * * * *" + splay 60 + + def _perform + Suma::Message::SignalwireWebhookdbOptoutProcessor.new(now: Time.now).run + end +end diff --git a/lib/suma/frontapp.rb b/lib/suma/frontapp.rb index 23dca1c9..85c1112e 100644 --- a/lib/suma/frontapp.rb +++ b/lib/suma/frontapp.rb @@ -15,11 +15,12 @@ class << self # @return [Frontapp::Client] attr_accessor :client - def configured? = self.auth_token != UNCONFIGURED_AUTH_TOKEN + def configured? = self.auth_token != UNCONFIGURED_AUTH_TOKEN && self.auth_token.present? end configurable(:frontapp) do setting :auth_token, UNCONFIGURED_AUTH_TOKEN + setting :list_sync_enabled, false after_configured do self.client = Frontapp::Client.new(auth_token: self.auth_token, user_agent: Suma::Http.user_agent) diff --git a/lib/suma/frontapp/list_sync.rb b/lib/suma/frontapp/list_sync.rb new file mode 100644 index 00000000..4a574394 --- /dev/null +++ b/lib/suma/frontapp/list_sync.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +class Suma::Frontapp::ListSync + RECENTLY_UNVERIFIED_CUTOFF = 2.weeks + + def initialize(now:) + @now = now + end + + def run + specs = self.gather_list_specs + spec_names = specs.to_set(&:full_name) + groups = Suma::Frontapp.client.contact_groups + # The easiest way to bulk-replace all the contacts is to delete and recreate the group + groups_to_replace = groups.select { |g| spec_names.include?(g.fetch("name")) } + groups_to_replace.each do |group| + Suma::Frontapp.client.delete_contact_group!(group.fetch("id")) + end + specs_with_members = specs.reject { |sp| sp.dataset.empty? } + # Since create contact group does not return the ID, we create and then re-fetch + specs_with_members.each do |spec| + Suma::Frontapp.client.create_contact_group!(name: spec.full_name) + end + # Find the group we just created, and add all the contacts do it + groups = Suma::Frontapp.client.contact_groups + specs_with_members.each do |spec| + existing_group = groups.find { |g| g.fetch("name") == spec.full_name } + raise Suma::InvalidPostcondition, "cannot find the group we just created: #{spec.full_name}" if + existing_group.nil? + contact_ids = spec.dataset.select_map(:frontapp_contact_id) + next if contact_ids.empty? + Suma::Frontapp.client.add_contacts_to_contact_group!(existing_group.fetch("id"), {contact_ids:}) + end + end + + def gather_list_specs + result = [] + result.concat( + ListSpec.for_languages( + name: "Marketing", + transport: :sms, + dataset: Suma::Member.where(preferences: Suma::Message::Preferences.where(marketing_sms_optout: false)), + ), + ) + result.concat( + ListSpec.for_languages( + name: "Unverified", + transport: :sms, + dataset: Suma::Member.where { created_at > RECENTLY_UNVERIFIED_CUTOFF.ago }.where(onboarding_verified_at: nil), + ), + ) + Suma::Organization.all.each do |org| + result.concat( + ListSpec.for_languages( + name: org.name, + transport: :sms, + dataset: Suma::Member.where(organization_memberships: org.memberships_dataset), + ), + ) + end + return result + end + + class ListSpec < Suma::TypedStruct + attr_reader :name, :transport, :language, :dataset + + def self.for_languages(**kw) + return Suma::I18n::SUPPORTED_LOCALES.values.map do |locale| + self.new(language: locale.code, **kw) + end + end + + def initialize(**kw) + super + preferences_ds = Suma::Message::Preferences.where( + "#{self.transport}_enabled": true, preferred_language: self.language, + ) + @dataset = self.dataset. + not_soft_deleted. + exclude(frontapp_contact_id: ""). + where(preferences: preferences_ds) + end + + def full_name + lang = Suma::I18n::SUPPORTED_LOCALES.fetch(self.language).language + "#{self.name} - #{self.transport.to_s.upcase} - #{lang}" + end + end +end diff --git a/lib/suma/member/frontapp_attributes.rb b/lib/suma/member/frontapp_attributes.rb index 561e7e9d..a2d971b1 100644 --- a/lib/suma/member/frontapp_attributes.rb +++ b/lib/suma/member/frontapp_attributes.rb @@ -32,12 +32,10 @@ def _update_contact end def _contact_body - # In the future, we would look at things like organizations to add custom fields, - # like the housing partner they are a part of. - custom_fields = {} body = { links: [@member.admin_link], - custom_fields:, + # NOTE: Setting things like customFields or groupNames will REPLACE existing ones, + # so be very careful if we end up using them. } body[:name] = @member.name if @member.name.present? body diff --git a/lib/suma/message/preferences.rb b/lib/suma/message/preferences.rb index c5e13215..f6b113aa 100644 --- a/lib/suma/message/preferences.rb +++ b/lib/suma/message/preferences.rb @@ -4,6 +4,8 @@ require "suma/postgres" require "suma/message" +# TODO: We have some assumptions about SMS right now; when we add in email support into the UI, +# stuff like subscription groups will need per-channel optin/out behavior. class Suma::Message::Preferences < Suma::Postgres::Model(:message_preferences) plugin :timestamps @@ -45,12 +47,18 @@ def subscriptions groups = [] groups << SubscriptionGroup.new( model: self, - optout_field: :account_updates_optout, + optout_field: :account_updates_sms_optout, key: :account_updates, - opted_in: !self.account_updates_optout, + opted_in: !self.account_updates_sms_optout, + editable_state: "on", + ) + groups << SubscriptionGroup.new( + model: self, + optout_field: :marketing_sms_optout, + key: :marketing, + opted_in: !self.marketing_sms_optout, editable_state: "on", ) - groups << SubscriptionGroup.new(key: :marketing, opted_in: false, editable_state: "hidden") groups << SubscriptionGroup.new(key: :security, opted_in: true, editable_state: "off") return groups end @@ -67,6 +75,29 @@ def validate super self.validates_includes Suma::I18n.enabled_locale_codes, :preferred_language end + + # @!attribute sms_enabled + # True if the member has a phone number and is okay receiving SMS. + # Note that if sms_enabled is false, it will override any 'true' settings on individual subscription groups. + # @return [TrueClass,FalseClass] + + # @!attribute email_enabled + # True if the member has an email and can receive emails. + # Note that if email_enabled is false, it will override any 'true' settings on individual subscription groups. + # @return [TrueClass,FalseClass] + + # @!attribute account_updates_sms_optout + # True if the user has opted out of receiving account update messages via SMS. + # @return [TrueClass,FalseClass] + + # @!attribute account_updates_email_optout + # @return [TrueClass,FalseClass] + + # @!attribute marketing_sms_optout + # @return [TrueClass,FalseClass] + + # @!attribute marketing_email_optout + # @return [TrueClass,FalseClass] end # Table: message_preferences diff --git a/lib/suma/message/signalwire_webhookdb_optout_processor.rb b/lib/suma/message/signalwire_webhookdb_optout_processor.rb new file mode 100644 index 00000000..0dcd3adb --- /dev/null +++ b/lib/suma/message/signalwire_webhookdb_optout_processor.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class Suma::Message::SignalwireWebhookdbOptoutProcessor + include Appydays::Loggable + + OPTINOUT = [:optout, :optin].freeze + + def initialize(now:) + @now = now + end + + def run + rows = self.fetch_rows + rows.each do |row| + from = row.fetch(:from) + raise Suma::InvariantViolation, "unexpected signalwire phone: #{from}" unless from.first == "+" + member = Suma::Member[phone: from[1..]] + next if member.nil? + # Transactions are ok for idempotency because there are no 3rd party actions, + # so as long as everything is committed we're ok. + Suma::Idempotency.once_ever.transaction_ok.under_key("sw-whdb-optout-#{row.fetch(:signalwire_id)}") do + msgtype = self.msgtype(row.fetch(:body)) + member.message_preferences!.update(marketing_sms_optout: msgtype == :optout) if OPTINOUT.include?(msgtype) + msg = Suma::Messages::SingleValue.new( + "sms_compliance", + msgtype.to_s, + "", + ) + member.message_preferences!.dispatch(msg) + end + end + end + + def fetch_rows + cutoff = @now - 1.week + ds = Suma::Webhookdb.signalwire_messages_dataset + ds = ds.where { date_created > cutoff } + ds = ds.where( + direction: "inbound", + to: Suma::Signalwire.marketing_number, + ) + keywords = Suma::Signalwire.message_marketing_sms_unsubscribe_keywords + + Suma::Signalwire.message_marketing_sms_resubscribe_keywords + + Suma::Signalwire.message_marketing_sms_help_keywords + ds = ds.where( + Sequel.function(:upper, + Sequel.function(:trim, + Sequel.pg_jsonb(:data).get_text("body"),),) => keywords, + ) + ds = ds.order(:date_created) + ds = ds.select(:signalwire_id, :from, Sequel.pg_json(:data).get_text("body").as(:body)) + return ds.all + end + + def msgtype(body) + b = body.upcase.strip + return :optout if Suma::Signalwire.message_marketing_sms_unsubscribe_keywords.include?(b) + return :optin if Suma::Signalwire.message_marketing_sms_resubscribe_keywords.include?(b) + return :help if Suma::Signalwire.message_marketing_sms_help_keywords.include?(b) + raise "Unhandled body, should not have been selected: #{body}" + end +end diff --git a/lib/suma/signalwire.rb b/lib/suma/signalwire.rb index 04e3751a..4a967f40 100644 --- a/lib/suma/signalwire.rb +++ b/lib/suma/signalwire.rb @@ -13,6 +13,16 @@ module Suma::Signalwire setting :api_token, "sw-test-token" setting :project_id, "sw-test-project" setting :space_url, "sumafaketest.signalwire.com" + setting :marketing_number, "" + setting :message_marketing_sms_unsubscribe_keywords, + ["STOP", "UNSUBSCRIBE", "ALTO"], + convert: ->(s) { s.split.map(&:strip) } + setting :message_marketing_sms_resubscribe_keywords, + ["START", "RESUBSCRIBE", "COMENZAR"], + convert: ->(s) { s.split.map(&:strip) } + setting :message_marketing_sms_help_keywords, + ["HELP", "AYUDA"], + convert: ->(s) { s.split.map(&:strip) } after_configured do @client = Signalwire::REST::Client.new(self.project_id, self.api_token, signalwire_space_url: self.space_url) diff --git a/lib/suma/spec_helpers.rb b/lib/suma/spec_helpers.rb index 361a81aa..7be34d67 100644 --- a/lib/suma/spec_helpers.rb +++ b/lib/suma/spec_helpers.rb @@ -62,6 +62,12 @@ def self.included(context) return {status:, body: respbody, headers:} end + module_function def json_response(body={}, status: 200, headers: {}) + headers["Content-Type"] = "application/json" + body = body.to_json + return {status:, body:, headers:} + end + module_function def money(x, *more) return x if x.is_a?(Money) return Monetize.parse!(x) if x.is_a?(String) diff --git a/lib/suma/webhookdb.rb b/lib/suma/webhookdb.rb index 95684cb0..dbd76ddb 100644 --- a/lib/suma/webhookdb.rb +++ b/lib/suma/webhookdb.rb @@ -8,17 +8,11 @@ module Suma::Webhookdb class << self attr_accessor :connection - def dataset_for_table(table) - return self.connection[Sequel[self.schema][table]] - end - - def postmark_inbound_messages_dataset - return self.dataset_for_table(self.postmark_inbound_messages_table) - end + def dataset_for_table(table) = self.connection[Sequel[self.schema][table]] - def stripe_refunds_dataset - return self.dataset_for_table(self.stripe_refunds_table) - end + def postmark_inbound_messages_dataset = self.dataset_for_table(self.postmark_inbound_messages_table) + def stripe_refunds_dataset = self.dataset_for_table(self.stripe_refunds_table) + def signalwire_messages_dataset = self.dataset_for_table(self.signalwire_messages_table) end configurable(:webhookdb) do @@ -28,6 +22,8 @@ def stripe_refunds_dataset setting :postmark_inbound_messages_secret, "fakesecret-#{SecureRandom.hex(3)}" setting :stripe_refunds_table, :stripe_refund_v1_fixture setting :stripe_refunds_secret, "fakesecret-#{SecureRandom.hex(3)}" + setting :signalwire_messages_table, :signalwire_message_v1_fixture + setting :signalwire_messages_secret, "fakesecret-#{SecureRandom.hex(3)}" after_configured do self.connection = Sequel.connect(self.database_url, extensions: [:pg_json]) diff --git a/spec/data/front/list_groups.json b/spec/data/front/list_groups.json new file mode 100644 index 00000000..6c6a1e34 --- /dev/null +++ b/spec/data/front/list_groups.json @@ -0,0 +1,19 @@ +{ + "_links": { + "self": "https://yourCompany.api.frontapp.com/contact_groups" + }, + "_results": [ + { + "_links": { + "self": "https://yourCompany.api.frontapp.com/contact_groups/grp_3j342", + "related": { + "contacts": "https://yourCompany.api.frontapp.com/contact_groups/grp_3j342/contacts", + "owner": "https://yourCompany.api.frontapp.com/teammates/tea_e35u" + } + }, + "id": "grp_3j342", + "name": "Party Planning Committee", + "is_private": false + } + ] +} diff --git a/spec/suma/api/preferences_spec.rb b/spec/suma/api/preferences_spec.rb index cd1cf280..b1f824cb 100644 --- a/spec/suma/api/preferences_spec.rb +++ b/spec/suma/api/preferences_spec.rb @@ -18,7 +18,7 @@ preferences: include( subscriptions: [ {key: "account_updates", opted_in: true, editable_state: "on"}, - {key: "marketing", opted_in: false, editable_state: "hidden"}, + {key: "marketing", opted_in: true, editable_state: "on"}, {key: "security", opted_in: true, editable_state: "off"}, ], ), @@ -52,7 +52,7 @@ subscriptions: include(hash_including({key: "account_updates", opted_in: false, editable_state: "on"})), ), ) - expect(member.preferences.refresh).to have_attributes(account_updates_optout: true) + expect(member.preferences.refresh).to have_attributes(account_updates_sms_optout: true) end it "401s for an invalid access token" do @@ -97,7 +97,7 @@ subscriptions: include(hash_including({key: "account_updates", opted_in: false, editable_state: "on"})), ), ) - expect(member.preferences.refresh).to have_attributes(account_updates_optout: true) + expect(member.preferences.refresh).to have_attributes(account_updates_sms_optout: true) end it "401s if the user cannot auth" do diff --git a/spec/suma/api/webhookdb_spec.rb b/spec/suma/api/webhookdb_spec.rb index afbcea8b..b9ad100f 100644 --- a/spec/suma/api/webhookdb_spec.rb +++ b/spec/suma/api/webhookdb_spec.rb @@ -23,4 +23,21 @@ expect(last_response).to have_status(401) end end + + describe "POST /v1/webhookdb/signalwire_message_v1" do + it "enqueues the async jobs" do + header "Whdb-Webhook-Secret", Suma::Webhookdb.signalwire_messages_secret + expect(Suma::Async::SignalwireProcessOptouts).to receive(:perform_async) + + post "/v1/webhookdb/signalwire_message_v1", {x: 1} + + expect(last_response).to have_status(202) + end + + it "errors if the webhook header does not match" do + post "/v1/webhookdb/signalwire_message_v1", {x: 1} + + expect(last_response).to have_status(401) + end + end end diff --git a/spec/suma/async/jobs_spec.rb b/spec/suma/async/jobs_spec.rb index 6795ee88..489a2dd5 100644 --- a/spec/suma/async/jobs_spec.rb +++ b/spec/suma/async/jobs_spec.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true require "suma/async" -require "suma/frontapp" -require "suma/lime" require "suma/messages/specs" require "rspec/eventually" @@ -78,6 +76,55 @@ end end + describe "FrontappListSync", reset_configuration: Suma::Frontapp do + before(:each) do + Suma::Frontapp.auth_token = "faketoken" + Suma::Frontapp.list_sync_enabled = true + end + + it "syncs marketing lists" do + get_req = stub_request(:get, "https://api2.frontapp.com/contact_groups"). + to_return( + json_response({}), + json_response({}), + ) + Suma::Async::FrontappListSync.new.perform + expect(get_req).to have_been_made.times(2) + end + + it "noops if sync not enabled" do + Suma::Frontapp.list_sync_enabled = false + expect { Suma::Async::FrontappListSync.new.perform }.to_not raise_error + end + + it "noops if client not configured" do + Suma::Frontapp.auth_token = "" + expect { Suma::Async::FrontappListSync.new.perform }.to_not raise_error + end + end + + describe "FrontappUpsertContact", reset_configuration: Suma::Frontapp do + it "upserts front contacts" do + Suma::Frontapp.auth_token = "fake token" + req = stub_request(:post, "https://api2.frontapp.com/contacts"). + to_return(fixture_response("front/contact")) + + member = nil + expect do + member = Suma::Fixtures.member.create + end.to perform_async_job(Suma::Async::FrontappUpsertContact) + + expect(req).to have_been_made + expect(member.refresh).to have_attributes(frontapp_contact_id: "crd_123") + end + + it "noops if Front is not configured" do + expect do + Suma::Fixtures.member.create + end.to perform_async_job(Suma::Async::FrontappUpsertContact) + end + end + describe "FundingTransactionProcessor" do it "processes all created and collecting funding transactions" do created = Suma::Fixtures.funding_transaction.with_fake_strategy.create @@ -238,6 +285,22 @@ end end + describe "SignalwireProcessOptouts" do + it "syncs refunds" do + member = Suma::Fixtures.member.create + Suma::Webhookdb.signalwire_messages_dataset.insert( + signalwire_id: "msg1", + date_created: 4.days.ago, + direction: "inbound", + from: "+" + member.phone, + to: Suma::Signalwire.marketing_number, + data: {body: "stop"}.to_json, + ) + Suma::Async::SignalwireProcessOptouts.new.perform + expect(member.refresh.preferences!).to have_attributes(marketing_sms_optout: true) + end + end + describe "StripeRefundsBackfiller" do it "syncs refunds" do Suma::Webhookdb.stripe_refunds_dataset.insert( @@ -310,28 +373,6 @@ end end - describe "UpsertFrontappContact", reset_configuration: Suma::Frontapp do - it "upserts front contacts" do - Suma::Frontapp.auth_token = "fake token" - req = stub_request(:post, "https://api2.frontapp.com/contacts"). - to_return(fixture_response("front/contact")) - - member = nil - expect do - member = Suma::Fixtures.member.create - end.to perform_async_job(Suma::Async::UpsertFrontappContact) - - expect(req).to have_been_made - expect(member.refresh).to have_attributes(frontapp_contact_id: "crd_123") - end - - it "noops if Front is not configured" do - expect do - Suma::Fixtures.member.create - end.to perform_async_job(Suma::Async::UpsertFrontappContact) - end - end - describe "OfferingScheduleFulfillment" do it "on create, enqueues a processing job at the fulfillment time" do o = Suma::Fixtures.offering.timed_fulfillment.create diff --git a/spec/suma/frontapp/list_sync_spec.rb b/spec/suma/frontapp/list_sync_spec.rb new file mode 100755 index 00000000..106ecc19 --- /dev/null +++ b/spec/suma/frontapp/list_sync_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require "suma/frontapp/list_sync" + +RSpec.describe Suma::Frontapp::ListSync, :db, reset_configuration: Suma::Frontapp do + let(:now) { Time.now } + + before(:each) do + Suma::Frontapp.auth_token = "abc" + Suma::Frontapp.list_sync_enabled = true + end + + describe "syncing lists" do + it "creates the specified lists in Front" do + get_groups = stub_request(:get, "https://api2.frontapp.com/contact_groups"). + to_return( + json_response({}), + json_response( + { + _results: [ + {id: "grp_en", name: "Marketing - SMS - English"}, + {id: "grp_es", name: "Marketing - SMS - Spanish"}, + ], + }, + ), + ) + create_en_group = stub_request(:post, "https://api2.frontapp.com/contact_groups"). + with(body: "{\"name\":\"Marketing - SMS - English\"}"). + to_return(json_response({})) + add_ids = stub_request(:post, "https://api2.frontapp.com/contact_groups/grp_en/contacts"). + with(body: "{\"contact_ids\":[\"crd_123\"]}"). + to_return(json_response({})) + + m = Suma::Fixtures.member.onboarding_verified.create(frontapp_contact_id: "crd_123") + m.preferences! + + described_class.new(now:).run + + expect(get_groups).to have_been_made.times(2) + expect(create_en_group).to have_been_made + expect(add_ids).to have_been_made + end + + it "first deletes lists with the same name" do + get_groups = stub_request(:get, "https://api2.frontapp.com/contact_groups"). + to_return( + json_response( + { + _results: [ + {id: "grp_en1", name: "Marketing - SMS - English"}, + ], + }, + ), + json_response( + { + _results: [ + {id: "grp_en2", name: "Marketing - SMS - English"}, + ], + }, + ), + ) + delete_en = stub_request(:delete, "https://api2.frontapp.com/contact_groups/grp_en1"). + with(body: "{}"). + to_return(json_response({})) + + create_en_group = stub_request(:post, "https://api2.frontapp.com/contact_groups"). + with(body: "{\"name\":\"Marketing - SMS - English\"}"). + to_return(json_response({})) + add_ids = stub_request(:post, "https://api2.frontapp.com/contact_groups/grp_en2/contacts"). + with(body: "{\"contact_ids\":[\"crd_123\"]}"). + to_return(json_response({})) + + m = Suma::Fixtures.member.onboarding_verified.create(frontapp_contact_id: "crd_123") + m.preferences! + + described_class.new(now:).run + + expect(get_groups).to have_been_made.times(2) + expect(delete_en).to have_been_made + expect(create_en_group).to have_been_made + expect(add_ids).to have_been_made + end + + it "does not create empty lists" do + get_groups = stub_request(:get, "https://api2.frontapp.com/contact_groups"). + to_return( + json_response({}), + json_response({}), + ) + described_class.new(now:).run + + expect(get_groups).to have_been_made.times(2) + end + + it "deletes lists that exist but are now empty" do + get_groups = stub_request(:get, "https://api2.frontapp.com/contact_groups"). + to_return( + json_response( + { + _results: [ + {id: "grp_en1", name: "Marketing - SMS - English"}, + ], + }, + ), + json_response({}), + ) + delete_en = stub_request(:delete, "https://api2.frontapp.com/contact_groups/grp_en1"). + with(body: "{}"). + to_return(json_response({})) + + described_class.new(now:).run + + expect(get_groups).to have_been_made.times(2) + expect(delete_en).to have_been_made + end + + it "does not modify groups that cannot be recognized" do + get_groups = stub_request(:get, "https://api2.frontapp.com/contact_groups"). + to_return( + json_response( + { + _results: [ + {id: "some group", name: "Custom made"}, + ], + }, + ), + json_response({}), + ) + described_class.new(now:).run + + expect(get_groups).to have_been_made.times(2) + end + end + + describe "ListSpec" do + it "dataset include only transport-enabled, undeleted members with a front id" do + m = Suma::Fixtures.member.create(frontapp_contact_id: "crd_1") + Suma::Message::Preferences.create(member: m) + + disabled = Suma::Fixtures.member.create(frontapp_contact_id: "crd_5") + Suma::Message::Preferences.create(member: disabled, sms_enabled: false) + + deleted = Suma::Fixtures.member.create(frontapp_contact_id: "crd_5") + deleted.soft_delete + Suma::Message::Preferences.create(member: deleted) + + no_front_id = Suma::Fixtures.member.create(frontapp_contact_id: "") + Suma::Message::Preferences.create(member: no_front_id) + + es = Suma::Fixtures.member.create(frontapp_contact_id: "crd_6") + Suma::Message::Preferences.create(member: es, preferred_language: "es") + + spec = described_class::ListSpec.new( + name: "myspec", transport: :sms, dataset: Suma::Member.dataset, language: "en", + ) + expect(spec.dataset.all).to have_same_ids_as(m) + end + end + + describe "list segmentation" do + it "includes opted-in marketing lists" do + en_member = Suma::Fixtures.member.create(frontapp_contact_id: "crd_1") + Suma::Message::Preferences.create(member: en_member, preferred_language: "en") + + es_member = Suma::Fixtures.member.create(frontapp_contact_id: "crd_2") + Suma::Message::Preferences.create(member: es_member, preferred_language: "es") + + unsubscribed = Suma::Fixtures.member.create(frontapp_contact_id: "crd_3") + Suma::Message::Preferences.create(member: unsubscribed, preferred_language: "en", marketing_sms_optout: true) + + specs = described_class.new(now:).gather_list_specs + en = specs.find { |s| s.full_name == "Marketing - SMS - English" } + es = specs.find { |s| s.full_name == "Marketing - SMS - Spanish" } + expect(en.dataset.all).to have_same_ids_as(en_member) + expect(es.dataset.all).to have_same_ids_as(es_member) + end + + it "includes recently unverified users" do + en_member = Suma::Fixtures.member.create(frontapp_contact_id: "crd_123") + Suma::Message::Preferences.create(member: en_member, preferred_language: "en") + + es_member = Suma::Fixtures.member.create(frontapp_contact_id: "crd_123") + Suma::Message::Preferences.create(member: es_member, preferred_language: "es") + + verified = Suma::Fixtures.member.onboarding_verified.create(frontapp_contact_id: "crd_123") + Suma::Message::Preferences.create(member: verified) + + old = Suma::Fixtures.member.create(frontapp_contact_id: "crd_123", created_at: 2.months.ago) + Suma::Message::Preferences.create(member: old) + + specs = described_class.new(now:).gather_list_specs + en = specs.find { |s| s.full_name == "Unverified - SMS - English" } + es = specs.find { |s| s.full_name == "Unverified - SMS - Spanish" } + expect(en.dataset.all).to have_same_ids_as(en_member) + expect(es.dataset.all).to have_same_ids_as(es_member) + end + + it "includes per-organization list" do + en_member = Suma::Fixtures.member.create(frontapp_contact_id: "crd_123") + Suma::Message::Preferences.create(member: en_member, preferred_language: "en") + + es_member = Suma::Fixtures.member.create(frontapp_contact_id: "crd_123") + Suma::Message::Preferences.create(member: es_member, preferred_language: "es") + + o1 = Suma::Fixtures.organization.create(name: "Org 1") + o2 = Suma::Fixtures.organization.create(name: "Org 2") + Suma::Fixtures.organization_membership.verified(o1).create(member: en_member) + Suma::Fixtures.organization_membership.verified(o2).create(member: en_member) + Suma::Fixtures.organization_membership.verified(o1).create(member: es_member) + + specs = described_class.new(now:).gather_list_specs + o1_en_spec = specs.find { |s| s.full_name == "Org 1 - SMS - English" } + o1_es_spec = specs.find { |s| s.full_name == "Org 1 - SMS - Spanish" } + o2_en_spec = specs.find { |s| s.full_name == "Org 2 - SMS - English" } + o2_es_spec = specs.find { |s| s.full_name == "Org 2 - SMS - Spanish" } + expect(o1_en_spec.dataset.all).to have_same_ids_as(en_member) + expect(o1_es_spec.dataset.all).to have_same_ids_as(es_member) + expect(o2_en_spec.dataset.all).to have_same_ids_as(en_member) + expect(o2_es_spec.dataset.all).to be_empty + end + end +end diff --git a/spec/suma/member/frontapp_attributes_spec.rb b/spec/suma/member/frontapp_attributes_spec.rb index 858c5df3..b0699c01 100644 --- a/spec/suma/member/frontapp_attributes_spec.rb +++ b/spec/suma/member/frontapp_attributes_spec.rb @@ -13,7 +13,6 @@ {"source" => "phone", "handle" => member.phone}, {"source" => "email", "handle" => member.email}, ], - "custom_fields" => {}, )).to_return(fixture_response("front/contact")) member.frontapp.upsert_contact @@ -27,7 +26,6 @@ with(body: hash_including( "name" => member.name, "links" => [member.admin_link], - "custom_fields" => {}, )).to_return(status: 200) handles_url = "https://api2.frontapp.com/contacts/#{member.frontapp_contact_id}/handles" handle_req = stub_request(:post, handles_url). @@ -51,7 +49,6 @@ with(body: hash_including( "name" => member.name, "links" => [member.admin_link], - "custom_fields" => {}, )).to_return(status: 200) member.frontapp.upsert_contact diff --git a/spec/suma/message/signalwire_webhookdb_optout_processor_spec.rb b/spec/suma/message/signalwire_webhookdb_optout_processor_spec.rb new file mode 100644 index 00000000..fb9760cf --- /dev/null +++ b/spec/suma/message/signalwire_webhookdb_optout_processor_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "suma/message/signalwire_webhookdb_optout_processor" + +RSpec.describe Suma::Message::SignalwireWebhookdbOptoutProcessor, :db, reset_configuration: Suma::Signalwire do + before(:each) do + Suma::Signalwire.marketing_number = "+12225550000" + end + + let(:member_phone) { "14445556666" } + + def messagerow(swid, body: "STOP", **kw) + r = { + signalwire_id: swid, + date_created: 4.days.ago, + direction: "inbound", + from: "+12225551234", + to: Suma::Signalwire.marketing_number, + data: {body:}.to_json, + } + r.merge!(**kw) + return r + end + + it "finds potential unsubscribe rows from the last week" do + old = messagerow("msg2", date_created: 8.days.ago) + wrong_to = messagerow("msg3", to: "+13334445555") + wrong_message = messagerow("msg4", body: "Hello") + + stop = messagerow("msg10") + spaces_and_casing = messagerow("msg11", body: " stop ") + start = messagerow("msg12", body: "Start ") + help = messagerow("msg13", body: " HELP") + + Suma::Webhookdb.signalwire_messages_dataset.multi_insert( + [old, wrong_to, wrong_message, stop, spaces_and_casing, start, help], + ) + result = described_class.new(now: Time.now).fetch_rows + expect(result).to contain_exactly( + include(signalwire_id: "msg10"), + include(signalwire_id: "msg11"), + include(signalwire_id: "msg12"), + include(signalwire_id: "msg13"), + ) + end + + it "updates preferences of members matching the phone number of unsubscribe rows" do + member = Suma::Fixtures.member.create(phone: member_phone) + Suma::Webhookdb.signalwire_messages_dataset.insert(messagerow("msg1", from: "+" + member_phone)) + described_class.new(now: Time.now).run + expect(member.refresh.preferences!).to have_attributes(marketing_sms_optout: true) + end + + it "skips messages from unknown members" do + Suma::Webhookdb.signalwire_messages_dataset.insert(messagerow("msg1")) + expect { described_class.new(now: Time.now).run }.to_not raise_error + end + + it "is idempotent" do + member = Suma::Fixtures.member.create(phone: member_phone) + Suma::Webhookdb.signalwire_messages_dataset.insert(messagerow("msg1", from: "+" + member_phone)) + described_class.new(now: Time.now).run + expect(member.refresh.preferences!).to have_attributes(marketing_sms_optout: true) + + member.refresh.preferences!.update(marketing_sms_optout: false) + + described_class.new(now: Time.now).run + expect(member.refresh.preferences!).to have_attributes(marketing_sms_optout: false) + end + + it "processes texts in order, to handle multiple actions from the same number" do + member = Suma::Fixtures.member.create(phone: member_phone) + + Suma::Webhookdb.signalwire_messages_dataset.insert(messagerow("msg1", from: "+" + member_phone, body: "STOP")) + Suma::Webhookdb.signalwire_messages_dataset.insert(messagerow("msg2", from: "+" + member_phone, body: "START")) + described_class.new(now: Time.now).run + expect(member.refresh.preferences!).to have_attributes(marketing_sms_optout: false) + end + + it "texts the user about subscription changes" do + member = Suma::Fixtures.member.create(phone: member_phone) + Suma::Webhookdb.signalwire_messages_dataset.multi_insert( + [ + messagerow("msg1", from: "+" + member_phone), + messagerow("msg2", from: "+" + member_phone, body: "START"), + messagerow("msg3", from: "+" + member_phone, body: "help"), + ], + ) + described_class.new(now: Time.now).run + expect(Suma::Message::Delivery.all).to contain_exactly( + have_attributes(recipient: be === member, template: "sms_compliance/optout"), + have_attributes(recipient: be === member, template: "sms_compliance/optin"), + have_attributes(recipient: be === member, template: "sms_compliance/help"), + ) + end +end