Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preferences #590

Merged
merged 8 commits into from
Jan 26, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions adminapp/src/pages/MemberDetailPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export default function MemberDetailPage() {
<Charges charges={member.charges} />
<BankAccounts bankAccounts={member.bankAccounts} />
<PaymentAccountRelatedLists paymentAccount={member.paymentAccount} />
<MessagePreferences preferences={member.preferences} />
<MessageDeliveries messageDeliveries={member.messageDeliveries} />
<Sessions sessions={member.sessions} />
<ResetCodes resetCodes={member.resetCodes} />
Expand Down Expand Up @@ -435,6 +436,22 @@ function BankAccounts({ bankAccounts }) {
);
}

function MessagePreferences({ preferences }) {
return (
<RelatedList
title="Message Preferences"
headers={["Key", "Opted In", "Editable State"]}
rows={preferences}
keyRowAttr="id"
toCells={(row) => [
row.key,
<BoolCheckmark key={2}>{row.optedIn}</BoolCheckmark>,
row.editableState,
]}
/>
);
}

function MessageDeliveries({ messageDeliveries }) {
return (
<RelatedList
Expand Down
22 changes: 22 additions & 0 deletions db/migrations/040_prefs_page.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions lib/suma.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions lib/suma/admin_api/entities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ class MessageDeliveryEntity < BaseEntity
expose :recipient, with: MemberEntity
end

class MessagePreferenceSubscriptionEntity < BaseEntity
expose :key
expose :opted_in
expose :editable_state
end

class BankAccountEntity < PaymentInstrumentEntity
include AutoExposeDetail
expose :verified_at
Expand Down
2 changes: 2 additions & 0 deletions lib/suma/admin_api/members.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,5 +188,7 @@ class DetailedMemberEntity < MemberEntity
expose :sessions, with: MemberSessionEntity
expose :orders, with: MemberOrderEntity
expose :message_deliveries, with: MessageDeliveryEntity
expose :preferences, with: MessagePreferenceSubscriptionEntity,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is ideal (should be like {preferences: {subscriptions: []}} but it's admin so easy to change later.

&self.delegate_to(:preferences!, :subscriptions)
end
end
11 changes: 11 additions & 0 deletions lib/suma/api/entities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
74 changes: 74 additions & 0 deletions lib/suma/api/preferences.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions lib/suma/apps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions lib/suma/member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
[
Expand Down Expand Up @@ -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

#
Expand Down
28 changes: 28 additions & 0 deletions lib/suma/message/preferences.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +39,23 @@ def dispatch(message)
return sent
end

# @return [Array<SubscriptionGroup>]
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 public_url = "#{Suma.app_url}/preferences-public?token=#{self.access_token}"

def validate
super
self.validates_includes Suma::I18n.enabled_locale_codes, :preferred_language
Expand Down
111 changes: 111 additions & 0 deletions spec/suma/api/preferences_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading