Skip to content

[TAN-8120] Sms campaigns#14226

Open
AchrafGoVocal wants to merge 137 commits into
masterfrom
TAN-8120-sms-campaigns
Open

[TAN-8120] Sms campaigns#14226
AchrafGoVocal wants to merge 137 commits into
masterfrom
TAN-8120-sms-campaigns

Conversation

@AchrafGoVocal

@AchrafGoVocal AchrafGoVocal commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Changelog

Added

  • [TAN-8120] Adds manual SMS campaigns.
  • [TAN-8120] Participants can add and change a phone number on their profile and confirm it with an SMS code.

Changed

  • [TAN-8120] Notification preferences are now split into separate "Email notifications" and "SMS notifications" sections, so participants can opt in per channel.

Technical

  • [TAN-8120] Adds an sms feature flag holding Twilio credentials in tenant settings, a Twilio provider with delivery-status callbacks, and an SMS delivery model.
  • [TAN-8120] Namespaces the frontend campaigns API and types into per-channel email/ and sms/ modules.

For translators

AchrafGoVocal and others added 30 commits May 22, 2026 17:45

@luucvanderzee luucvanderzee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Amazing work!! A few changes requested but honestly not that many for such a huge huge PR. I didn't do any functional testing and only quickly skipped through the BE tests. Can take another look at those later.

I think it would be great if we could rename the folder and module, otherwise it's going to be really confusing. But of course that will add even more changes to this PR


# This endpoint is used when a logged in user confirms a pending phone-number
# change. On success, new_phone is promoted to phone. The phone
# number isn't part of the auth token, so there's no JWT cookie to refresh.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The point of reset_jwt_cookie is not to add update the phone number/email in the token, but to invalidate other sessions. But then that seems to be happening with the token_expiry_key which reset_jwt_cookie does not seem to touch... @jamesspeake how does this work again? And do we need to invalidate the token in this case? I guess not, but wanted to check with you

Comment on lines +9 to +25
ActiveRecord::Base.transaction do
user.update!(new_phone: new_phone)
confirmation = user.new_phone_confirmation || user.create_new_phone_confirmation!
confirmation.reset_code!
campaign = EmailCampaigns::Campaigns::NewPhoneConfirmation.first_or_create!
EmailCampaigns::DeliveryService.new.send_now_to_user(campaign, user, { code: confirmation.code })

confirmation.update!(code_sent_at: Time.zone.now)
ExpireConfirmationCodeOrDeleteJob.set(
wait_until: confirmation.expiration_at
).perform_later(
user.id,
NewPhoneConfirmation.name,
confirmation.code
)
LogActivityJob.perform_later(user, 'received_confirmation_code', user, Time.now.to_i, payload: { new_phone: new_phone })
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I propose we keep it the same and look into this transaction stuff when we look at the other issue related to being able to request too many codes if you do it really fast

Comment thread back/app/models/user.rb
Comment thread back/app/policies/request_code_policy.rb Outdated
Comment thread back/app/services/user_confirmation_service.rb Outdated
# subject_multiloc is the email subject for content-configurable emails and
# the admin-facing label for SMS campaigns (SMS has no subject line).
attribute :subject_multiloc, if: proc { |object|
content_configurable?(object) || object.sms?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If sms has no subject line, should the check not be || !object.sms?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Maybe the comment is confusing, but what I meant is that the SMS itself has no subject line but the campaign does (it's just an internal label for the admin).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe we can clarify the comment? I still don't really get it.

parsed = Phonelib.parse(to)
raise Error, "Invalid phone number: #{to}" unless parsed.valid?

parsed.e164

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is this parser doing exactly? Maybe we can add a comment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It just checks the string we give it to see if it's a valid phone number and gives us some useful methods like .valid, .country ..etc. I've also added a comment.

Comment thread front/app/components/CampaignConsentForm/ChannelConsentSection.tsx Outdated
Comment thread front/app/containers/Admin/messaging/Sms/Show/Stats.tsx Outdated
const [screenReaderMessage, setScreenReaderMessage] = useState<string>('');
const { formatMessage } = useIntl();

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Instead of this useEffect and state variable, can't you just render the screenReaderMessage conditionally without adding extra state?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This useEffect is just copied from the email approach to be honest. It seems to be part of a relatively new change for a11y.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ok, let's make a note to replace it in both places because I can't really see any reason for this to work like this

@jamesspeake jamesspeake left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good. A few comments to look at and I've not looked in quite as much detail at the FE as it seemed a lot of reusing the same patterns.

Not sure what the e2e coverage is like for email campaigns - Assume you've tested the email campaigns interface well given how much refactoring there has been there with name changes / endpoint changes.

Have the changes to the consent screen been run past product? From reading the code it seems that even if it's turned off, there is a small change to the layout?


new_phone = request_code_phone_change_params[:new_phone]
if new_phone.blank?
render json: { errors: { new_phone: [{ error: 'cannot be blank' }] } }, status: :unprocessable_entity

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not sure if we use these on the FE to display messages, but usually I'd make these errors identifiers that can be used to map to translations eg cannot_be_blank

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We indeed do, it's just that it was pre existing and I just reused it. There are others like it like this and would probably be better to solve in a different PR.

Comment on lines +9 to +25
ActiveRecord::Base.transaction do
user.update!(new_phone: new_phone)
confirmation = user.new_phone_confirmation || user.create_new_phone_confirmation!
confirmation.reset_code!
campaign = EmailCampaigns::Campaigns::NewPhoneConfirmation.first_or_create!
EmailCampaigns::DeliveryService.new.send_now_to_user(campaign, user, { code: confirmation.code })

confirmation.update!(code_sent_at: Time.zone.now)
ExpireConfirmationCodeOrDeleteJob.set(
wait_until: confirmation.expiration_at
).perform_later(
user.id,
NewPhoneConfirmation.name,
confirmation.code
)
LogActivityJob.perform_later(user, 'received_confirmation_code', user, Time.now.to_i, payload: { new_phone: new_phone })
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If it fails, the whole job should get retried though? If it mirrors email then probably fine as it is and low risk that it will fail as any failure in send_now_to_user I think will just be written to the delivery record

Comment thread back/app/models/user.rb Outdated
Comment thread back/config/schemas/settings.schema.json.erb Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this works in a slightly different way to Hooks::MailgunEventsController as that one has to switch tenants, but worth a quick look to see if it makes sense for code to maybe share the same language eg Sms::Hooks::EventsController - are these referred to as webhooks? Not a blocker, but feels we're describing both in different ways

@AchrafGoVocal AchrafGoVocal Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree, fixed on this commit.

Comment thread back/Gemfile
Comment thread front/app/containers/Admin/messaging/Sms/All/index.tsx Outdated
key={deliveries.data.map((delivery) => delivery.id).join()}
>
{deliveries.data.map((delivery) => (
<TableRow delivery={delivery} key={delivery.id} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Probably not a conversation for this PR, but wonder why (from an accessibility perspective) we don't use HTML tables where we obviously have tabular data

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants