Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 5 additions & 3 deletions app/controllers/home_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ class HomeController < ApplicationController
def index
# Signed-in members land on their account dashboard, not the
# "you found the front door" splash (which is for anonymous visitors).
# Joining the Slack is part of becoming a member, so anyone who hasn't
# finished lands on the next step of that instead.
# Joining the Slack (which includes accepting the code of conduct) is part
# of becoming a member, so anyone who hasn't finished lands on the next
# step of that instead.
return unless current_user

redirect_to(current_user.email_verified? && current_user.slack_pending? ? slack_onboarding_path : profile_path)
onboarding = current_user.email_verified? && !current_user.slack_onboarding_complete?
redirect_to(onboarding ? slack_onboarding_path : profile_path)
end

end
36 changes: 32 additions & 4 deletions app/controllers/slack_onboarding_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,44 @@ def create
end

# Accepts the code of conduct from the web, for people who can't find the
# Slack DM. Also retries the promotion if it failed after an earlier
# acceptance.
# Slack DM and for full members who joined before the code-of-conduct flow.
# Also asks for a real name when the Slack import could not find one, and
# retries the promotion if it failed after an earlier acceptance.
def accept_code_of_conduct
unless %i[accept_code_of_conduct awaiting_promotion].include?(current_user.slack_onboarding_step)
redirect_to slack_onboarding_path
return
end

SlackCodeOfConductAcceptedJob.perform_later(current_user.slack_id)
redirect_to slack_onboarding_path, notice: "Thanks for accepting the Code of Conduct. Your full Slack access is on its way."
# "Try again" after an earlier acceptance needs no new check.
unless params[:accept] == "1" || current_user.slack_coc_accepted_at.present?
render_form_errors(accept: I18n.t("code_of_conduct_form.accept_error"))
return
end

result = CodeOfConductAcceptance.call(
current_user,
names: params.slice(*CodeOfConductAcceptance::NAME_FIELDS).permit(*CodeOfConductAcceptance::NAME_FIELDS).to_h.symbolize_keys
)
unless result.success?
render_form_errors(result.errors)
return
end

notice = if current_user.slack_member?
"Thanks for accepting the Code of Conduct."
else
"Thanks for accepting the Code of Conduct. Your full Slack access is on its way."
end
redirect_to slack_onboarding_path, notice: notice
end

private

def render_form_errors(errors)
@step = current_user.slack_onboarding_step
@form_errors = errors
render :show, status: :unprocessable_content
end

end
82 changes: 71 additions & 11 deletions app/controllers/webhooks/slack_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,33 +57,93 @@ def events
render json: { status: "ok" }, status: :ok
end

# Slack interactivity (Block Kit button clicks). Handles the "I accept the
# Code of Conduct" button, which promotes a single-channel guest to a full
# member. Slack posts the interaction as a form-encoded `payload` field.
# Slack interactivity. Handles the code-of-conduct DM (see
# SlackService#post_code_of_conduct):
#
# - "accept_coc" button: accepts at once, and promotes a single-channel
# guest to a full member.
# - "open_coc_form" button: opens the form with the box to check, which
# also asks for a missing name.
# - Submission of that form: saves the name and accepts.
#
# Slack posts the interaction as a form-encoded `payload` field.
def interactions
payload = JSON.parse(params[:payload].to_s.presence || "{}")

if payload["type"] == "view_submission"
return submit_code_of_conduct_form(payload) if payload.dig("view", "callback_id") == SlackService::CODE_OF_CONDUCT_FORM

head :ok
return
end

action = (payload["actions"] || []).first || {}

if action["action_id"] == "accept_coc"
case action["action_id"]
when "accept_coc"
slack_user_id = action["value"].presence || payload.dig("user", "id")
SlackCodeOfConductAcceptedJob.perform_later(slack_user_id) if slack_user_id.present?

# Replace the original message so the button can't be used twice.
render json: {
replace_original: true,
text: ":white_check_mark: Thanks for accepting the Code of Conduct — you now have full access to the Patchwork Labs Slack. Welcome! :tada:"
}
return
already_member = User.find_by(slack_id: slack_user_id)&.slack_member? || false
render json: { replace_original: true, text: SlackService.code_of_conduct_thanks(already_member:) }
when "open_coc_form"
open_code_of_conduct_form(payload)
head :ok
else
head :ok
end

head :ok
rescue JSON::ParserError => e
Rails.logger.error "[Slack Interactions] Invalid payload: #{e.message}"
head :bad_request
end

private

# Called while Slack waits for the answer to the click: the trigger_id
# expires after 3 seconds.
def open_code_of_conduct_form(payload)
SlackService.new.open_code_of_conduct_form(
trigger_id: payload["trigger_id"],
user: User.find_by(slack_id: payload.dig("user", "id")),
message: { channel: payload.dig("container", "channel_id"), ts: payload.dig("container", "message_ts") }.compact.presence
)
rescue => e
Rails.logger.error "[Slack Interactions] Could not open CoC form: #{e.message}"
end

# The person who submits the form is the one who accepts, so the user
# comes from the payload's user, never from a value in the form.
def submit_code_of_conduct_form(payload)
slack_user_id = payload.dig("user", "id")
values = payload.dig("view", "state", "values") || {}
message = JSON.parse(payload.dig("view", "private_metadata").presence || "{}").symbolize_keys.slice(:channel, :ts).presence
user = User.find_by(slack_id: slack_user_id)

# Slack makes the box required, but check it here too.
accepted = Array(values.dig("accept", "value", "selected_options")).any? { |option| option["value"] == "accept" }
unless accepted
render json: { response_action: "errors", errors: { "accept" => I18n.t("code_of_conduct_form.accept_error") } }
return
end

if user
result = CodeOfConductAcceptance.call(
user,
names: CodeOfConductAcceptance::NAME_FIELDS.index_with { |field| values.dig(field.to_s, "value", "value") },
message: message
)
unless result.success?
render json: { response_action: "errors", errors: result.errors.transform_keys(&:to_s) }
return
end
elsif slack_user_id.present?
SlackCodeOfConductAcceptedJob.perform_later(slack_user_id, **{ message: message }.compact)
end

render json: { response_action: "clear" }
end

def devise_configured?
defined?(Devise)
end
Expand Down
60 changes: 60 additions & 0 deletions app/jobs/code_of_conduct_request_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

# Asks a full Slack member who joined before the code-of-conduct flow to
# accept it (User.code_of_conduct_pending). Sends the request two ways,
# because many members don't check Slack often:
#
# - a Slack DM with a button that accepts (or opens a form that also asks for
# a missing name), and
# - an email with a link to sign in to Weave and accept on /slack.
#
# The text is in config/locales/code_of_conduct_request.en.yml. Enqueued by
# `bin/rails slack:coc:request`. Idempotent: a member who has accepted is
# skipped, and so is one who was already asked, unless reminder is true.
class CodeOfConductRequestJob < ApplicationJob
queue_as :default

# deadline is the date after which members who have not accepted move to
# guest access. nil leaves the deadline out of the message.
def perform(user_id, deadline: nil, reminder: false)
user = User.find_by(id: user_id)
return unless user&.can_authenticate?
return if user.slack_coc_accepted_at.present?
return if user.slack_coc_requested_at.present? && !reminder

send_slack_dm(user, deadline) if user.slack_id.present?
UserMailer.code_of_conduct_request(user, deadline: deadline).deliver_later
user.update_columns(slack_coc_requested_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
end

# The opening of the message, as plain-text paragraphs. Shared by the Slack
# DM and the email.
def self.paragraphs(user, deadline: nil)
t = ->(key, **args) { I18n.t("code_of_conduct_request.#{key}", **args) }
[
# Capitalized like User#full_name: many Slack-imported names are lowercase.
t.call(:greeting, first_name: user.name_missing? ? "there" : user.first_name.gsub(/\b\p{L}/, &:upcase)),
*t.call(:update).split(/\n{2,}/),
(t.call(:name_missing) if user.name_missing?),
(t.call(:deadline, deadline: deadline.strftime("%B %-d, %Y")) if deadline)
].compact
end

private

# A Slack failure must not stop the email.
def send_slack_dm(user, deadline)
service = SlackService.new
return unless service.configured?

service.post_code_of_conduct(
user.slack_id,
title: I18n.t("code_of_conduct_request.slack_title"),
paragraphs: self.class.paragraphs(user, deadline:),
form: user.name_missing?
)
rescue => e
Rails.logger.error "Failed to DM the CoC request to #{user.slack_id}: #{e.message}"
end

end
28 changes: 28 additions & 0 deletions app/jobs/push_slack_name_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

# Sets the name on a user's Slack profile after they give it on the
# code-of-conduct form (see CodeOfConductAcceptance). With a nickname, only
# the Slack display name is set, so the preferred name stays out of Slack.
# Without one, the Slack first and last name are set to the preferred name.
# Slack errors and rate limits are retried.
class PushSlackNameJob < ApplicationJob
queue_as :default

retry_on Slack::Web::Api::Errors::TooManyRequestsError, attempts: 10,
wait: ->(executions) { [executions * 30, 300].min }
retry_on Slack::Web::Api::Errors::SlackError, Slack::Web::Api::Errors::ServerError,
wait: :polynomially_longer, attempts: 5

def perform(user_id, nickname: nil)
user = User.find_by(id: user_id)
return if user&.slack_id.blank?

profile = if nickname.present?
{ display_name: nickname }
else
{ first_name: user.first_name, last_name: user.last_name }
end
SlackService.new.update_slack_profile_name(user.slack_id, **profile)
end

end
29 changes: 24 additions & 5 deletions app/jobs/slack_code_of_conduct_accepted_job.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
# frozen_string_literal: true

# Runs when a single-channel guest accepts the code of conduct, either with the
# button in their Slack DM or on the /slack onboarding page. Records the
# acceptance, promotes them from guest to full workspace member, and marks
# them a member in Weave once Slack confirms the promotion.
# Runs when someone accepts the code of conduct: with the button in their Slack
# DM, with the Slack form, or on the /slack onboarding page. Records the
# acceptance, promotes a single-channel guest to full workspace member, and
# marks them a member in Weave once Slack confirms the promotion.
#
# A full member who joined before the code-of-conduct flow is already a member,
# so only the acceptance is recorded for them.
#
# message is the Slack DM that asked for the acceptance, as { channel:, ts: }.
# When it is given, the DM is replaced with a thank-you so its button can't be
# used again.
class SlackCodeOfConductAcceptedJob < ApplicationJob
queue_as :default

def perform(slack_user_id)
def perform(slack_user_id, message: nil)
return if slack_user_id.blank?

user = User.find_by(slack_id: slack_user_id)
Expand All @@ -16,6 +23,10 @@ def perform(slack_user_id)
service = SlackService.new
return unless service.configured?

already_member = user&.slack_member?
replace_message(service, message, already_member:) if message.present?
return if already_member

result = service.promote_to_member(slack_user_id)
if result[:ok]
user&.update_columns(slack_membership: "member", updated_at: Time.current)
Expand All @@ -27,4 +38,12 @@ def perform(slack_user_id)
Rails.logger.error "Slack not configured for promotion: #{e.message}"
end

private

def replace_message(service, message, already_member:)
service.mark_code_of_conduct_accepted(channel: message[:channel], ts: message[:ts], already_member:)
rescue => e
Rails.logger.error "Failed to update CoC DM #{message.inspect}: #{e.message}"
end

end
15 changes: 15 additions & 0 deletions app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,19 @@ def slack_email_address_added(email_address)
)
end

# Asks a member who joined the Slack before the code-of-conduct flow to
# accept it (see CodeOfConductRequestJob). Signing in sends them to /slack,
# which shows the accept step.
def code_of_conduct_request(user, deadline: nil)
@user = user
@paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:)
@login_url = login_url
@code_of_conduct_url = SlackService.code_of_conduct_url

mail(
to: @user.email,
subject: env_subject(I18n.t("code_of_conduct_request.subject"))
)
end

end
20 changes: 19 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
# slack_birthday :date
# slack_city :string
# slack_coc_accepted_at :datetime
# slack_coc_requested_at :datetime
# slack_cost_center :string
# slack_country :string
# slack_department :string
Expand Down Expand Up @@ -187,6 +188,15 @@ def self.generate_secure_password
# as the `slack_member` claim.
enum :slack_membership, { pending: "pending", member: "member" }, prefix: :slack

# Full Slack members who have not accepted the code of conduct. They joined
# before the code-of-conduct flow, so nothing has asked them yet (see
# CodeOfConductRequestJob).
scope :code_of_conduct_pending, -> { slack_member.where(slack_coc_accepted_at: nil).where.not(slack_id: nil) }

# Names the Slack import saves when a Slack profile has no name.
PLACEHOLDER_NAMES = %w[NOTSET Unknown User].freeze
scope :name_missing, -> { where(first_name: PLACEHOLDER_NAMES).or(where(last_name: PLACEHOLDER_NAMES)) }

scope :last_seen_within, ->(ago) { joins(:user_sessions).where(user_sessions: { last_seen_at: ago.. }).distinct }
scope :currently_online, -> { last_seen_within(15.minutes.ago) }
scope :active, -> { last_seen_within(30.days.ago) }
Expand Down Expand Up @@ -253,6 +263,9 @@ def self.p_id_format = /\A#{Weave.p_id_prefix}\d[A-F0-9]{9}\z/
end
end

# True when the name is one the Slack import made up (see PLACEHOLDER_NAMES).
def name_missing? = PLACEHOLDER_NAMES.include?(first_name) || PLACEHOLDER_NAMES.include?(last_name)

def full_name
# Display names capitalized (many Slack-imported names arrive lowercase).
# Only upcase the first letter of each word so intentional casing like
Expand Down Expand Up @@ -472,15 +485,20 @@ def in_slack_workspace? = slack_id.present?

# Where this person is in joining the Slack. Drives the /slack onboarding
# page. Each step is the next thing the person must do.
#
# Full members who joined before the code-of-conduct flow still have to
# accept it. They are not demoted for that, so they skip :awaiting_promotion.
def slack_onboarding_step
return :member if slack_member?
return slack_coc_accepted_at.present? ? :member : :accept_code_of_conduct if slack_member?
return :awaiting_promotion if in_slack_workspace? && slack_coc_accepted_at.present?
return :accept_code_of_conduct if in_slack_workspace?
return :accept_invite if slack_invited_at.present?

:request_invite
end

def slack_onboarding_complete? = slack_onboarding_step == :member

# The membership a Slack user object (from users.info, users.lookupByEmail,
# users.list or a team_join/user_change event) entitles its owner to. Guests
# and deactivated accounts are not members.
Expand Down
1 change: 1 addition & 0 deletions app/serializers/user_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
# slack_birthday :date
# slack_city :string
# slack_coc_accepted_at :datetime
# slack_coc_requested_at :datetime
# slack_cost_center :string
# slack_country :string
# slack_department :string
Expand Down
Loading
Loading