From 781eaa06f808c63b759b54dbeff12aa2f1c926d8 Mon Sep 17 00:00:00 2001 From: Denilson Velasquez <66847768+DeeTheDev@users.noreply.github.com> Date: Thu, 18 Jan 2024 16:09:38 -0500 Subject: [PATCH 1/8] Create preferences pages mockup --- webapp/public/locale/en/strings.json | 20 +++++- webapp/public/locale/es/strings.json | 21 ++++++- webapp/src/App.jsx | 25 ++++++++ webapp/src/api.js | 2 + webapp/src/components/PreferenceSettings.jsx | 64 ++++++++++++++++++++ webapp/src/pages/MessagingPreferences.jsx | 23 +++++++ webapp/src/pages/Preferences.jsx | 10 +++ 7 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 webapp/src/components/PreferenceSettings.jsx create mode 100644 webapp/src/pages/MessagingPreferences.jsx create mode 100644 webapp/src/pages/Preferences.jsx diff --git a/webapp/public/locale/en/strings.json b/webapp/public/locale/en/strings.json index b0bd45c6..ff8f7789 100644 --- a/webapp/public/locale/en/strings.json +++ b/webapp/public/locale/en/strings.json @@ -4,7 +4,6 @@ "sign_up_agreement": "I accept and agree to suma’s [Terms of Use](/terms-of-use) and [Privacy Policy](/privacy-policy). I also agree to receive periodic SMS and/or MMS messages from suma. Message and data rates may apply. Message frequency may vary depending on your activity and settings. Text HELP for more information. Text STOP to stop receiving messages." }, "common": { - "add_money_to_account": "Press here to add money to your account", "add_to_homescreen": "Add to homescreen", "add_to_homescreen_intro": "Install suma to your homescreen for an optimized experience.", "app": "App", @@ -230,6 +229,7 @@ "phone": "Phone", "routing_caption": "9-digit number for your bank. You can get these from your online banking portal, or check out $t(strings:forms:paper_check_link).", "routing_number": "Routing Number", + "save": "Save", "savings": "Savings", "state": "State", "submit": "Submit", @@ -323,6 +323,22 @@ "unlink_account_question": "Are you sure you want to unlink this account? You can always re-add it later.", "unlink_account_question_subtitle": "Any in-progress transactions will not be canceled." }, + "preferences": { + "account_updates": { + "helper_text": "Order updates, receipts, and other messages based on actions you take in suma.", + "title": "Account Updates" + }, + "marketing": { + "helper_text": "Communications from the suma team. You can unsubscribe from these by replying STOP to any marketing message.", + "title": "Marketing" + }, + "security": { + "helper_text": "For signing in, and other security-based messages. All suma members receive these messages.", + "title": "Security" + }, + "title": "Subscriptions", + "unavailable": "Subscriptions are unavailable" + }, "private_accounts": { "auth_error": "Sorry, something went wrong. You can try again, or email apphelp@mysuma.org.", "create_account": "Create account", @@ -342,6 +358,7 @@ "funding": "Funding", "home": "Home", "ledgers_overview": "Ledgers Overview", + "messaging_preferences": "Messaging Preferences", "mobility": "Mobility", "onboarding": "Onboarding", "onboarding_finish": "Onboarding Finish", @@ -349,6 +366,7 @@ "order": "Order", "order_history": "Order History", "otp": "One Time Password", + "preferences": "Preferences", "private_accounts": "Private Accounts", "start": "Get Started", "suma_app": "Suma App", diff --git a/webapp/public/locale/es/strings.json b/webapp/public/locale/es/strings.json index 1e945fb9..02901932 100644 --- a/webapp/public/locale/es/strings.json +++ b/webapp/public/locale/es/strings.json @@ -229,9 +229,10 @@ "phone": "Teléfono", "routing_caption": "Ingrese el número de ruta de 9 dígitos de su cuenta bancaria. Puede encontrar esta información en el portal de banco en línea, o consultando $t(strings:forms:paper_check_link).", "routing_number": "Código de Identificación", + "save": "Salvar", "savings": "Ahorros", "state": "Estado", - "submit": "Enviar", + "submit": "Entregar", "zip": "Código Postal" }, "ledgerusage": { @@ -322,6 +323,22 @@ "unlink_account_question": "¿Estás seguro de que quieres desvincular esta cuenta? Siempre lo puedes volver a añadir.", "unlink_account_question_subtitle": "No se cancelarán las transaciones en curso." }, + "preferences": { + "account_updates": { + "helper_text": "Actualizaciones de pedidos, recibos y otros mensajes basados en las acciones que realiza en suma.", + "title": "Actualizaciones de cuenta" + }, + "marketing": { + "helper_text": "Comunicaciones del equipo suma. Puede cancelar las suscripciónes respondiendo STOP a cualquier mensaje de mercadotecnia.", + "title": "Mercadotecnia" + }, + "security": { + "helper_text": "Para iniciar sesión y otros mensajes basados en seguridad. Todos los miembros de suma reciben estos mensajes.", + "title": "Seguridad" + }, + "title": "Suscripciones", + "unavailable": "Las suscripciones no están disponibles" + }, "private_accounts": { "auth_error": "Perdón, algo salió mal. Puede intentarlo nuevamente o enviar un correo electrónico a apphelp@mysuma.org.", "create_account": "Crear una cuenta", @@ -341,6 +358,7 @@ "funding": "Financiamiento", "home": "Inicio", "ledgers_overview": "Estado de Cuentas", + "messaging_preferences": "Preferencias de Mensajería", "mobility": "Movilidad", "onboarding": "Integración", "onboarding_finish": "Finalizar la integración", @@ -348,6 +366,7 @@ "order": "Pedido", "order_history": "Historial De Pedidos", "otp": "Contraseña de un solo uso", + "preferences": "Preferencias", "private_accounts": "Cuentas Privadas", "start": "Comenzar", "suma_app": "App de Suma", diff --git a/webapp/src/App.jsx b/webapp/src/App.jsx index abfe2590..a49a7215 100644 --- a/webapp/src/App.jsx +++ b/webapp/src/App.jsx @@ -28,6 +28,7 @@ import FundingLinkBankAccount from "./pages/FundingLinkBankAccount"; import Home from "./pages/Home"; import LedgersOverview from "./pages/LedgersOverview"; import MarkdownContent from "./pages/MarkdownContent"; +import MessagingPreferences from "./pages/MessagingPreferences"; import Mobility from "./pages/Mobility"; import Onboarding from "./pages/Onboarding"; import OnboardingFinish from "./pages/OnboardingFinish"; @@ -35,6 +36,7 @@ import OnboardingSignup from "./pages/OnboardingSignup"; import OneTimePassword from "./pages/OneTimePassword"; import OrderHistoryDetail from "./pages/OrderHistoryDetail"; import OrderHistoryList from "./pages/OrderHistoryList"; +import Preferences from "./pages/Preferences"; import PrivacyPolicy from "./pages/PrivacyPolicy"; import PrivateAccountsList from "./pages/PrivateAccountsList"; import Start from "./pages/Start"; @@ -457,6 +459,29 @@ function AppRoutes() { PrivateAccountsList )} /> + + get("/api/v1/messaging/subscriptions", data), }; diff --git a/webapp/src/components/PreferenceSettings.jsx b/webapp/src/components/PreferenceSettings.jsx new file mode 100644 index 00000000..436c2d3b --- /dev/null +++ b/webapp/src/components/PreferenceSettings.jsx @@ -0,0 +1,64 @@ +import { t } from "../localization"; +import FormButtons from "./FormButtons"; +import humps from "humps"; +import isEmpty from "lodash/isEmpty"; +import merge from "lodash/merge"; +import React from "react"; +import Form from "react-bootstrap/Form"; + +export default function PreferenceSettings({ subscriptions }) { + const [subscriptionState, setSubscriptionState] = React.useState(subscriptions); + const [changes, setChanges] = React.useState({}); + const handleSubscriptionChange = (idx, changedSubscriptionObj) => { + const newSubscriptions = subscriptionState; + newSubscriptions[idx] = changedSubscriptionObj; + setChanges( + merge({}, changes, { [changedSubscriptionObj.key]: changedSubscriptionObj.checked }) + ); + setSubscriptionState(newSubscriptions); + }; + // TODO: Add subscription changes API call and error handling + // Probably need to call from higher component + if (isEmpty(subscriptionState)) { + return ( + <> +

{t("preferences:title")}

+

{t("preferences:unavailable")}

+ + ); + } + return ( +
+

{t("preferences:title")}

+ {subscriptionState.map((sub, idx) => ( + + ))} + + + ); +} + +function Checkbox({ index, subscription, onSubscriptionChange }) { + let { key, checked, editableState } = subscription; + const decamalizedKey = humps.decamelize(key); + return ( + + { + subscription.checked = e.target.checked; + onSubscriptionChange(index, subscription); + }} + /> + {t(`preferences:${decamalizedKey}:helper_text`)} + + ); +} diff --git a/webapp/src/pages/MessagingPreferences.jsx b/webapp/src/pages/MessagingPreferences.jsx new file mode 100644 index 00000000..33d33203 --- /dev/null +++ b/webapp/src/pages/MessagingPreferences.jsx @@ -0,0 +1,23 @@ +import api from "../api"; +import PreferenceSettings from "../components/PreferenceSettings"; +import useAsyncFetch from "../shared/react/useAsyncFetch"; +import useErrorToast from "../state/useErrorToast"; +import React from "react"; +import { useSearchParams } from "react-router-dom"; + +export default function MessagingPreferences() { + const [searchParams] = useSearchParams(); + const { enqueueErrorToast } = useErrorToast(); + + const getPreferences = React.useCallback(() => { + return api + .getPreferences({ authtoken: searchParams.get("authtoken") }) + .catch((e) => enqueueErrorToast(e)); + }, [searchParams, enqueueErrorToast]); + const { + state: subscriptions, + loading, + error, + } = useAsyncFetch(getPreferences, { pickData: true }); + return ; +} diff --git a/webapp/src/pages/Preferences.jsx b/webapp/src/pages/Preferences.jsx new file mode 100644 index 00000000..841b8838 --- /dev/null +++ b/webapp/src/pages/Preferences.jsx @@ -0,0 +1,10 @@ +import PreferenceSettings from "../components/PreferenceSettings"; +import useUser from "../state/useUser"; +import React from "react"; + +export default function Preferences() { + const { user } = useUser(); + return ( + + ); +} From 19af8a77f5bf945ac064b24ed27144873d43c167 Mon Sep 17 00:00:00 2001 From: Denilson Velasquez <66847768+DeeTheDev@users.noreply.github.com> Date: Thu, 18 Jan 2024 19:24:59 -0500 Subject: [PATCH 2/8] Add update API call and success and error handling --- webapp/public/locale/en/strings.json | 1 + webapp/public/locale/es/strings.json | 1 + webapp/src/api.js | 1 + webapp/src/components/PreferenceSettings.jsx | 55 ++++++++++++++++---- webapp/src/pages/MessagingPreferences.jsx | 27 +++++++++- webapp/src/pages/Preferences.jsx | 8 ++- 6 files changed, 81 insertions(+), 12 deletions(-) diff --git a/webapp/public/locale/en/strings.json b/webapp/public/locale/en/strings.json index ff8f7789..85d99e8d 100644 --- a/webapp/public/locale/en/strings.json +++ b/webapp/public/locale/en/strings.json @@ -336,6 +336,7 @@ "helper_text": "For signing in, and other security-based messages. All suma members receive these messages.", "title": "Security" }, + "success": "Subscription preferences were succesfully changed.", "title": "Subscriptions", "unavailable": "Subscriptions are unavailable" }, diff --git a/webapp/public/locale/es/strings.json b/webapp/public/locale/es/strings.json index 02901932..a5b92094 100644 --- a/webapp/public/locale/es/strings.json +++ b/webapp/public/locale/es/strings.json @@ -336,6 +336,7 @@ "helper_text": "Para iniciar sesión y otros mensajes basados en seguridad. Todos los miembros de suma reciben estos mensajes.", "title": "Seguridad" }, + "success": "Preferencias de suscripción se cambiaron exitosamente.", "title": "Suscripciones", "unavailable": "Las suscripciones no están disponibles" }, diff --git a/webapp/src/api.js b/webapp/src/api.js index 5a7dcc5c..7d46a388 100644 --- a/webapp/src/api.js +++ b/webapp/src/api.js @@ -108,4 +108,5 @@ export default { ), getPreferences: (data) => get("/api/v1/messaging/subscriptions", data), + updatePreferences: (data) => post("/v1/messaging/subscriptions", data), }; diff --git a/webapp/src/components/PreferenceSettings.jsx b/webapp/src/components/PreferenceSettings.jsx index 436c2d3b..62105545 100644 --- a/webapp/src/components/PreferenceSettings.jsx +++ b/webapp/src/components/PreferenceSettings.jsx @@ -1,25 +1,54 @@ +import api from "../api"; import { t } from "../localization"; +import useErrorToast from "../state/useErrorToast"; +import useScreenLoader from "../state/useScreenLoader"; +import useUser from "../state/useUser"; import FormButtons from "./FormButtons"; +import FormSuccess from "./FormSuccess"; import humps from "humps"; import isEmpty from "lodash/isEmpty"; import merge from "lodash/merge"; import React from "react"; +import Alert from "react-bootstrap/Alert"; import Form from "react-bootstrap/Form"; -export default function PreferenceSettings({ subscriptions }) { - const [subscriptionState, setSubscriptionState] = React.useState(subscriptions); +export default function PreferenceSettings({ + subscriptions, + successKey, + onSubscriptionsSaved, +}) { + const { showErrorToast } = useErrorToast(); + const { handleUpdateCurrentMember } = useUser(); + const screenLoader = useScreenLoader(); + const [subscriptionsState, setSubscriptionsState] = React.useState(subscriptions); const [changes, setChanges] = React.useState({}); const handleSubscriptionChange = (idx, changedSubscriptionObj) => { - const newSubscriptions = subscriptionState; + const newSubscriptions = subscriptionsState; newSubscriptions[idx] = changedSubscriptionObj; setChanges( merge({}, changes, { [changedSubscriptionObj.key]: changedSubscriptionObj.checked }) ); - setSubscriptionState(newSubscriptions); + setSubscriptionsState(newSubscriptions); }; - // TODO: Add subscription changes API call and error handling - // Probably need to call from higher component - if (isEmpty(subscriptionState)) { + + function handleSubmit(e) { + e.preventDefault(); + if (isEmpty(changes)) { + return; + } + screenLoader.turnOn(); + api + .updatePreferences(changes) + .tap(handleUpdateCurrentMember) + .then(() => onSubscriptionsSaved()) + .catch((e) => showErrorToast(e, { extract: true })) + .finally(() => { + setChanges({}); + screenLoader.turnOff(); + }); + } + + if (isEmpty(subscriptionsState)) { return ( <>

{t("preferences:title")}

@@ -28,9 +57,9 @@ export default function PreferenceSettings({ subscriptions }) { ); } return ( -
+

{t("preferences:title")}

- {subscriptionState.map((sub, idx) => ( + {subscriptionsState.map((sub, idx) => ( ))} + {successKey && ( + + + + )} ); @@ -47,8 +81,9 @@ function Checkbox({ index, subscription, onSubscriptionChange }) { let { key, checked, editableState } = subscription; const decamalizedKey = humps.decamelize(key); return ( - + { return api @@ -19,5 +25,24 @@ export default function MessagingPreferences() { loading, error, } = useAsyncFetch(getPreferences, { pickData: true }); - return ; + + if (loading) { + return ; + } + if (error) { + return ( + + + + ); + } + if (successView.isOn) { + return ; + } + return ( + successView.turnOn()} + /> + ); } diff --git a/webapp/src/pages/Preferences.jsx b/webapp/src/pages/Preferences.jsx index 841b8838..efc603ca 100644 --- a/webapp/src/pages/Preferences.jsx +++ b/webapp/src/pages/Preferences.jsx @@ -4,7 +4,13 @@ import React from "react"; export default function Preferences() { const { user } = useUser(); + const [successKey, setSuccessKey] = React.useState(); + return ( - + setSuccessKey("preferences:success")} + /> ); } From 357adcff94cf42355dd322066181cde7ad56d718 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 21 Jan 2024 09:54:28 -0800 Subject: [PATCH 3/8] Add preference api, model changes, and field masking --- db/migrations/040_prefs_page.rb | 22 ++++++ lib/suma.rb | 2 + lib/suma/api/entities.rb | 11 +++ lib/suma/api/preferences.rb | 74 ++++++++++++++++++++ lib/suma/member.rb | 25 ++++++- lib/suma/message/preferences.rb | 26 +++++++ spec/suma/api/preferences_spec.rb | 111 ++++++++++++++++++++++++++++++ spec/suma/member_spec.rb | 35 ++++++++++ 8 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 db/migrations/040_prefs_page.rb create mode 100644 lib/suma/api/preferences.rb create mode 100644 spec/suma/api/preferences_spec.rb diff --git a/db/migrations/040_prefs_page.rb b/db/migrations/040_prefs_page.rb new file mode 100644 index 00000000..58acd31f --- /dev/null +++ b/db/migrations/040_prefs_page.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + alter_table(:message_preferences) do + add_column :access_token, :text, null: true, unique: true + add_column :account_updates_optout, :boolean, null: false, default: false + end + from(:message_preferences).each do |row| + from(:message_preferences).where(id: row.fetch(:id)).update(access_token: SecureRandom.uuid) + end + alter_table(:message_preferences) do + set_column_not_null :access_token + end + end + down do + alter_table(:message_preferences) do + drop_column :access_token + drop_column :account_updates_optout + end + end +end diff --git a/lib/suma.rb b/lib/suma.rb index f285cdf9..6e89cdc3 100644 --- a/lib/suma.rb +++ b/lib/suma.rb @@ -182,6 +182,8 @@ def self.set_request_user_and_admin(user, admin, &block) Thread.current[:suma_request_admin] = nil end end + + def self.bool?(v) = [true, false].include?(v) end require "suma/aggregate_result" diff --git a/lib/suma/api/entities.rb b/lib/suma/api/entities.rb index bfb94f13..39362843 100644 --- a/lib/suma/api/entities.rb +++ b/lib/suma/api/entities.rb @@ -76,6 +76,16 @@ class MobilityTripEntity < BaseEntity expose :discount_amount, with: MoneyEntity, &self.delegate_to(:charge, :discount_amount, safe: true) end + class PreferencesSubscriptionEntity < BaseEntity + expose :key + expose :opted_in + expose :editable_state + end + + class MemberPreferencesEntity < BaseEntity + expose :subscriptions, with: PreferencesSubscriptionEntity + end + class CurrentMemberEntity < Suma::Service::Entities::CurrentMember expose :unclaimed_orders_count, &self.delegate_to(:orders_dataset, :available_to_claim, :count) expose :ongoing_trip @@ -88,6 +98,7 @@ class CurrentMemberEntity < Suma::Service::Entities::CurrentMember expose :show_private_accounts do |m| !Suma::AnonProxy::VendorAccount.for(m).empty? end + expose :preferences!, as: :preferences, with: MemberPreferencesEntity end class LedgerLineUsageDetailsEntity < Grape::Entity diff --git a/lib/suma/api/preferences.rb b/lib/suma/api/preferences.rb new file mode 100644 index 00000000..7cb0325c --- /dev/null +++ b/lib/suma/api/preferences.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "grape" + +require "suma/api" + +class Suma::API::Preferences < Suma::API::V1 + include Suma::API::Entities + + resource :preferences do + helpers do + def update_preferences(member) + params[:subscriptions].each do |k, optin| + k = k.to_sym + invalid!("subscription value #{k} must be a bool") unless Suma.bool?(optin) + subscr = member.preferences!.subscriptions.find { |g| g.key == k && g.editable? } + invalid!("subscription #{k} is invalid") if subscr.nil? + subscr.set_from_opted_in(optin) + end + member.preferences.save_changes + end + end + + resource :public do + helpers do + def member! + prefs = Suma::Message::Preferences[access_token: params[:access_token]] + unauthenticated! if prefs.nil? + unauthenticated! if prefs.member.soft_deleted? + return prefs.member + end + end + params do + requires :access_token, type: String + end + get do + member = member! + present member, with: PublicPrefsMemberEntity + end + + params do + requires :access_token, type: String + requires :subscriptions, type: Hash + end + post do + member = member! + update_preferences(member) + status 200 + present member, with: PublicPrefsMemberEntity + end + end + + params do + requires :subscriptions, type: Hash + end + post do + member = current_member + update_preferences(member) + status 200 + present member, with: CurrentMemberEntity + end + end + + class PublicPrefsEntity < BaseEntity + expose :subscriptions, with: Suma::API::Entities::PreferencesSubscriptionEntity + end + + class PublicPrefsMemberEntity < BaseEntity + expose :masked_email, as: :email + expose :masked_name, as: :name + expose :masked_phone, as: :phone + expose :preferences!, as: :preferences, with: PublicPrefsEntity + end +end diff --git a/lib/suma/member.rb b/lib/suma/member.rb index ee848f09..283798dc 100644 --- a/lib/suma/member.rb +++ b/lib/suma/member.rb @@ -66,7 +66,7 @@ class ReadOnlyMode < RuntimeError; end one_to_many :charges, class: "Suma::Charge", order: Sequel.desc([:id]) many_to_one :legal_entity, class: "Suma::LegalEntity" one_to_many :message_deliveries, key: :recipient_id, class: "Suma::Message::Delivery" - one_to_one :message_preferences, class: "Suma::Message::Preferences" + one_to_one :preferences, class: "Suma::Message::Preferences" one_to_one :ongoing_trip, class: "Suma::Mobility::Trip", conditions: {ended_at: nil} many_through_many :orders, [ @@ -233,8 +233,27 @@ def frontapp return @frontapp ||= Suma::Member::FrontappAttributes.new(self) end - def message_preferences! - return self.message_preferences ||= Suma::Message::Preferences.find_or_create_or_find(member: self) + def preferences! + return self.preferences ||= Suma::Message::Preferences.find_or_create_or_find(member: self) + end + alias message_preferences preferences + alias message_preferences! preferences! + + # + # :section: Masking + # + + def masked_name = _mask(self.name, 2, 2) + def masked_email = _mask(self.email, 3, 6) + def masked_phone = _mask((self.phone || "")[1..], 1, 2) + + private def _mask(s, prefix, suffix) + # If the actual value is too short, always entirely hide it + minimum_maskable_len = (prefix + suffix) * 1.5 + return "***" if s.blank? || s.length < minimum_maskable_len + pre = s[...prefix] + suf = s[-suffix..] + return "#{pre}***#{suf}" end # diff --git a/lib/suma/message/preferences.rb b/lib/suma/message/preferences.rb index f4be181e..120a5ce3 100644 --- a/lib/suma/message/preferences.rb +++ b/lib/suma/message/preferences.rb @@ -9,11 +9,22 @@ class Suma::Message::Preferences < Suma::Postgres::Model(:message_preferences) many_to_one :member, class: Suma::Member + class SubscriptionGroup < Suma::TypedStruct + attr_accessor :model, :optout_field, :key, :opted_in, :editable_state + + def editable? = self.editable_state == "on" + + def set_from_opted_in(optin) + self.model.set(self.optout_field => !optin) + end + end + def initialize(*) super self[:sms_enabled] = true if self[:sms_enabled].nil? self[:email_enabled] = false if self[:email_enabled].nil? self[:preferred_language] = "en" if self[:preferred_language].nil? + self[:access_token] ||= SecureRandom.uuid end def sms_enabled? = self.sms_enabled @@ -28,6 +39,21 @@ def dispatch(message) return sent end + # @return [Array] + def subscriptions + groups = [] + groups << SubscriptionGroup.new( + model: self, + optout_field: :account_updates_optout, + key: :account_updates, + opted_in: !self.account_updates_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 + def validate super self.validates_includes Suma::I18n.enabled_locale_codes, :preferred_language diff --git a/spec/suma/api/preferences_spec.rb b/spec/suma/api/preferences_spec.rb new file mode 100644 index 00000000..cd1cf280 --- /dev/null +++ b/spec/suma/api/preferences_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "suma/api/preferences" + +RSpec.describe Suma::API::Preferences, :db do + include Rack::Test::Methods + + let(:app) { described_class.build_app } + let(:member) { Suma::Fixtures.member.create(name: "Pedro Pascal") } + + describe "GET /v1/preferences/public" do + it "returns prefs if the prefs access token is given" do + get "/v1/preferences/public", access_token: member.preferences!.access_token + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes( + name: "Pe***al", + preferences: include( + subscriptions: [ + {key: "account_updates", opted_in: true, editable_state: "on"}, + {key: "marketing", opted_in: false, editable_state: "hidden"}, + {key: "security", opted_in: true, editable_state: "off"}, + ], + ), + ) + end + + it "401s for an invalid access token" do + get "/v1/preferences/public", access_token: "abcd" + + expect(last_response).to have_status(401) + end + + it "401s for a deleted member" do + member.soft_delete + get "/v1/preferences/public", access_token: member.preferences!.access_token + + expect(last_response).to have_status(401) + end + end + + describe "POST /v1/preferences/public" do + it "updates prefs of the user with the access token" do + post "/v1/preferences/public", + access_token: member.preferences!.access_token, + subscriptions: {account_updates: false} + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes( + name: "Pe***al", + preferences: include( + subscriptions: include(hash_including({key: "account_updates", opted_in: false, editable_state: "on"})), + ), + ) + expect(member.preferences.refresh).to have_attributes(account_updates_optout: true) + end + + it "401s for an invalid access token" do + post "/v1/preferences/public", access_token: "abcd", subscriptions: {} + + expect(last_response).to have_status(401) + end + + it "errors for an invalid subscription key" do + post "/v1/preferences/public", + access_token: member.preferences!.access_token, + subscriptions: {foo: false} + + expect(last_response).to have_status(400) + expect(last_response).to have_json_body. + that_includes(error: include(message: "Subscription foo is invalid")) + end + + it "errors for an invalid subscription value" do + post "/v1/preferences/public", + access_token: member.preferences!.access_token, + subscriptions: {account_updates: nil} + + expect(last_response).to have_status(400) + expect(last_response).to have_json_body. + that_includes(error: include(message: "Subscription value account_updates must be a bool")) + end + end + + describe "POST /v1/preferences" do + before(:each) do + login_as(member) + end + + it "updates prefs of the authed user" do + post "/v1/preferences", subscriptions: {account_updates: false} + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body.that_includes( + name: "Pedro Pascal", + preferences: include( + subscriptions: include(hash_including({key: "account_updates", opted_in: false, editable_state: "on"})), + ), + ) + expect(member.preferences.refresh).to have_attributes(account_updates_optout: true) + end + + it "401s if the user cannot auth" do + logout + + post "/v1/preferences", subscriptions: {account_updates: false} + + expect(last_response).to have_status(401) + end + end +end diff --git a/spec/suma/member_spec.rb b/spec/suma/member_spec.rb index adc30c7a..defea038 100644 --- a/spec/suma/member_spec.rb +++ b/spec/suma/member_spec.rb @@ -298,4 +298,39 @@ def skip_verification?(c, list=nil) end.to publish("suma.member.eligibilitychanged", [m.id]) end end + + describe "masking" do + it "masks name/phone/email" do + mem = described_class.new + expect(mem).to have_attributes( + masked_name: "***", + masked_email: "***", + masked_phone: "***", + ) + mem.name = "a" + mem.email = "a" + mem.phone = "a" + expect(mem).to have_attributes( + masked_name: "***", + masked_email: "***", + masked_phone: "***", + ) + + mem.name = "Pedro Pascal" + mem.email = "pedro@pascal.org" + mem.phone = "15552223333" + expect(mem).to have_attributes( + masked_name: "Pe***al", + masked_email: "ped***al.org", + masked_phone: "5***33", + ) + + mem.name = "Pedro" + mem.email = "ped@pas.org" + expect(mem).to have_attributes( + masked_name: "***", + masked_email: "***", + ) + end + end end From 3a9b69b7a667b578e060ac1a8a10998fc4057137 Mon Sep 17 00:00:00 2001 From: Rob Galanakis Date: Sun, 21 Jan 2024 13:39:51 -0800 Subject: [PATCH 4/8] Melt the preferences UI and API --- lib/suma/apps.rb | 2 + lib/suma/message/preferences.rb | 2 + webapp/public/locale/en/strings.json | 5 +- webapp/src/App.jsx | 11 +-- webapp/src/api.js | 5 +- webapp/src/components/PreferenceSettings.jsx | 99 ------------------- webapp/src/components/Preferences.jsx | 69 +++++++++++++ webapp/src/components/TopNav.jsx | 6 ++ webapp/src/pages/Preferences.jsx | 16 --- webapp/src/pages/PreferencesAuthed.jsx | 35 +++++++ ...gPreferences.jsx => PreferencesPublic.jsx} | 28 +++--- 11 files changed, 138 insertions(+), 140 deletions(-) delete mode 100644 webapp/src/components/PreferenceSettings.jsx create mode 100644 webapp/src/components/Preferences.jsx delete mode 100644 webapp/src/pages/Preferences.jsx create mode 100644 webapp/src/pages/PreferencesAuthed.jsx rename webapp/src/pages/{MessagingPreferences.jsx => PreferencesPublic.jsx} (62%) diff --git a/lib/suma/apps.rb b/lib/suma/apps.rb index 63f2af1c..b6c04821 100644 --- a/lib/suma/apps.rb +++ b/lib/suma/apps.rb @@ -25,6 +25,7 @@ require "suma/api/mobility" require "suma/api/payment_instruments" require "suma/api/payments" +require "suma/api/preferences" require "suma/api/system" require "suma/api/webhookdb" @@ -58,6 +59,7 @@ class API < Suma::Service mount Suma::API::Mobility mount Suma::API::PaymentInstruments mount Suma::API::Payments + mount Suma::API::Preferences mount Suma::API::Webhookdb add_swagger_documentation if ENV["RACK_ENV"] == "development" end diff --git a/lib/suma/message/preferences.rb b/lib/suma/message/preferences.rb index 120a5ce3..1db48d7b 100644 --- a/lib/suma/message/preferences.rb +++ b/lib/suma/message/preferences.rb @@ -54,6 +54,8 @@ def subscriptions return groups end + def public_url = "#{Suma.app_url}/preferences-public?token=#{self.access_token}" + def validate super self.validates_includes Suma::I18n.enabled_locale_codes, :preferred_language diff --git a/webapp/public/locale/en/strings.json b/webapp/public/locale/en/strings.json index 85d99e8d..c1ec900c 100644 --- a/webapp/public/locale/en/strings.json +++ b/webapp/public/locale/en/strings.json @@ -336,9 +336,8 @@ "helper_text": "For signing in, and other security-based messages. All suma members receive these messages.", "title": "Security" }, - "success": "Subscription preferences were succesfully changed.", - "title": "Subscriptions", - "unavailable": "Subscriptions are unavailable" + "success": "Your preferences were succesfully changed.", + "title": "Preferences" }, "private_accounts": { "auth_error": "Sorry, something went wrong. You can try again, or email apphelp@mysuma.org.", diff --git a/webapp/src/App.jsx b/webapp/src/App.jsx index a49a7215..7a6ed13c 100644 --- a/webapp/src/App.jsx +++ b/webapp/src/App.jsx @@ -28,7 +28,6 @@ import FundingLinkBankAccount from "./pages/FundingLinkBankAccount"; import Home from "./pages/Home"; import LedgersOverview from "./pages/LedgersOverview"; import MarkdownContent from "./pages/MarkdownContent"; -import MessagingPreferences from "./pages/MessagingPreferences"; import Mobility from "./pages/Mobility"; import Onboarding from "./pages/Onboarding"; import OnboardingFinish from "./pages/OnboardingFinish"; @@ -36,7 +35,8 @@ import OnboardingSignup from "./pages/OnboardingSignup"; import OneTimePassword from "./pages/OneTimePassword"; import OrderHistoryDetail from "./pages/OrderHistoryDetail"; import OrderHistoryList from "./pages/OrderHistoryList"; -import Preferences from "./pages/Preferences"; +import PreferencesAuthed from "./pages/PreferencesAuthed"; +import PreferencesPublic from "./pages/PreferencesPublic"; import PrivacyPolicy from "./pages/PrivacyPolicy"; import PrivateAccountsList from "./pages/PrivateAccountsList"; import Start from "./pages/Start"; @@ -468,18 +468,17 @@ function AppRoutes() { withScreenLoaderMount(), withMetatags({ title: t("titles:preferences") }), withLayout({ top: true, gutters: true }), - Preferences + PreferencesAuthed )} /> get("/api/v1/messaging/subscriptions", data), - updatePreferences: (data) => post("/v1/messaging/subscriptions", data), + getPreferencesPublic: (data) => get("/api/v1/preferences/public", data), + updatePreferencesPublic: (data) => post("/api/v1/preferences/public", data), + updatePreferences: (data) => post("/api/v1/preferences", data), }; diff --git a/webapp/src/components/PreferenceSettings.jsx b/webapp/src/components/PreferenceSettings.jsx deleted file mode 100644 index 62105545..00000000 --- a/webapp/src/components/PreferenceSettings.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import api from "../api"; -import { t } from "../localization"; -import useErrorToast from "../state/useErrorToast"; -import useScreenLoader from "../state/useScreenLoader"; -import useUser from "../state/useUser"; -import FormButtons from "./FormButtons"; -import FormSuccess from "./FormSuccess"; -import humps from "humps"; -import isEmpty from "lodash/isEmpty"; -import merge from "lodash/merge"; -import React from "react"; -import Alert from "react-bootstrap/Alert"; -import Form from "react-bootstrap/Form"; - -export default function PreferenceSettings({ - subscriptions, - successKey, - onSubscriptionsSaved, -}) { - const { showErrorToast } = useErrorToast(); - const { handleUpdateCurrentMember } = useUser(); - const screenLoader = useScreenLoader(); - const [subscriptionsState, setSubscriptionsState] = React.useState(subscriptions); - const [changes, setChanges] = React.useState({}); - const handleSubscriptionChange = (idx, changedSubscriptionObj) => { - const newSubscriptions = subscriptionsState; - newSubscriptions[idx] = changedSubscriptionObj; - setChanges( - merge({}, changes, { [changedSubscriptionObj.key]: changedSubscriptionObj.checked }) - ); - setSubscriptionsState(newSubscriptions); - }; - - function handleSubmit(e) { - e.preventDefault(); - if (isEmpty(changes)) { - return; - } - screenLoader.turnOn(); - api - .updatePreferences(changes) - .tap(handleUpdateCurrentMember) - .then(() => onSubscriptionsSaved()) - .catch((e) => showErrorToast(e, { extract: true })) - .finally(() => { - setChanges({}); - screenLoader.turnOff(); - }); - } - - if (isEmpty(subscriptionsState)) { - return ( - <> -

{t("preferences:title")}

-

{t("preferences:unavailable")}

- - ); - } - return ( -
-

{t("preferences:title")}

- {subscriptionsState.map((sub, idx) => ( - - ))} - {successKey && ( - - - - )} - - - ); -} - -function Checkbox({ index, subscription, onSubscriptionChange }) { - let { key, checked, editableState } = subscription; - const decamalizedKey = humps.decamelize(key); - return ( - - { - subscription.checked = e.target.checked; - onSubscriptionChange(index, subscription); - }} - /> - {t(`preferences:${decamalizedKey}:helper_text`)} - - ); -} diff --git a/webapp/src/components/Preferences.jsx b/webapp/src/components/Preferences.jsx new file mode 100644 index 00000000..311ca735 --- /dev/null +++ b/webapp/src/components/Preferences.jsx @@ -0,0 +1,69 @@ +import { t } from "../localization"; +import useErrorToast from "../state/useErrorToast"; +import useScreenLoader from "../state/useScreenLoader"; +import FormButtons from "./FormButtons"; +import has from "lodash/has"; +import React from "react"; +import Form from "react-bootstrap/Form"; + +export default function Preferences({ user, onApiSubmit, children, onSaved }) { + const { showErrorToast } = useErrorToast(); + const screenLoader = useScreenLoader(); + const [subscriptions, setSubscriptions] = React.useState({}); + + function handleSubmit(e) { + e.preventDefault(); + screenLoader.turnOn(); + onApiSubmit({ subscriptions }) + .then((r) => onSaved(r)) + .catch((e) => showErrorToast(e, { extract: true })) + .finally(() => { + setSubscriptions({}); + screenLoader.turnOff(); + }); + } + + return ( +
+

{t("preferences:title")}

+

Manage the types of communications you receive from suma.

+ {user.preferences.subscriptions.map((sub, idx) => { + const optedIn = has(subscriptions, sub.key) + ? subscriptions[sub.key] + : sub.optedIn; + return ( + setSubscriptions({ [sub.key]: ch })} + /> + ); + })} + {children} + + + ); +} + +function Subscription({ subscriptionKey, optedIn, editableState, onCheckChange }) { + return ( + + {editableState === "hidden" ? ( +

{t(`preferences:${subscriptionKey}:title`)}

+ ) : ( + onCheckChange(e.target.checked)} + /> + )} + {t(`preferences:${subscriptionKey}:helper_text`)} +
+ ); +} diff --git a/webapp/src/components/TopNav.jsx b/webapp/src/components/TopNav.jsx index 08c75917..a9fefc82 100644 --- a/webapp/src/components/TopNav.jsx +++ b/webapp/src/components/TopNav.jsx @@ -141,6 +141,12 @@ function AuthedUserButtons({ className, user, onCollapse }) { label={t("payments:payment_methods")} onNoChangeClick={onCollapse} /> + + + ); +} diff --git a/adminapp/src/pages/MemberDetailPage.jsx b/adminapp/src/pages/MemberDetailPage.jsx index 85535fd3..0744c64d 100644 --- a/adminapp/src/pages/MemberDetailPage.jsx +++ b/adminapp/src/pages/MemberDetailPage.jsx @@ -1,6 +1,7 @@ import api from "../api"; import AdminLink from "../components/AdminLink"; import BoolCheckmark from "../components/BoolCheckmark"; +import Copyable from "../components/Copyable"; import DetailGrid from "../components/DetailGrid"; import InlineEditField from "../components/InlineEditField"; import PaymentAccountRelatedLists from "../components/PaymentAccountRelatedLists"; @@ -437,12 +438,16 @@ function BankAccounts({ bankAccounts }) { } function MessagePreferences({ preferences }) { + if (!preferences) { + return null; + } + const { subscriptions, publicUrl } = preferences; return ( <> [ row.key, @@ -453,9 +458,9 @@ function MessagePreferences({ preferences }) { Give this link to the member when they request to change their messaging preferences:{" "} - - {preferences.publicUrl} - + + {publicUrl} + ); diff --git a/lib/suma/admin_api/members.rb b/lib/suma/admin_api/members.rb index 7abaea34..ba26c4bf 100644 --- a/lib/suma/admin_api/members.rb +++ b/lib/suma/admin_api/members.rb @@ -161,15 +161,15 @@ class MemberEligibilityConstraintEntity < BaseEntity expose :constraint, with: Suma::AdminAPI::Entities::EligibilityConstraintEntity end - class PreferenceSubscriptionEntity < BaseEntity + class PreferencesSubscriptionEntity < BaseEntity expose :key expose :opted_in expose :editable_state end - class PreferenceEntity < BaseEntity + class PreferencesEntity < BaseEntity expose :public_url - expose :subscriptions, with: PreferenceSubscriptionEntity + expose :subscriptions, with: PreferencesSubscriptionEntity end class DetailedMemberEntity < MemberEntity @@ -199,6 +199,6 @@ class DetailedMemberEntity < MemberEntity expose :sessions, with: MemberSessionEntity expose :orders, with: MemberOrderEntity expose :message_deliveries, with: MessageDeliveryEntity - expose :preferences!, as: :preferences, with: PreferenceEntity + expose :preferences!, as: :preferences, with: PreferencesEntity end end