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
7 changes: 7 additions & 0 deletions app/controllers/slack_onboarding_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ def show
@step = current_user ? current_user.slack_onboarding_step : :sign_up
end

# The current step, as JSON. The page polls it while it waits on something
# that can happen elsewhere (accepting in Slack, the promotion), and reloads
# when the step changes.
def status
render json: { step: current_user.slack_onboarding_step }
end

# Sends the Slack invite, or sends it again.
def create
unless %i[request_invite accept_invite].include?(current_user.slack_onboarding_step)
Expand Down
3 changes: 3 additions & 0 deletions app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ application.register("fingerprint", FingerprintController)
import LoginFormController from "./login_form_controller"
application.register("login-form", LoginFormController)

import OnboardingStatusController from "./onboarding_status_controller"
application.register("onboarding-status", OnboardingStatusController)

import PasswordController from "./password_controller"
application.register("password", PasswordController)

Expand Down
34 changes: 34 additions & 0 deletions app/javascript/controllers/onboarding_status_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Controller } from "@hotwired/stimulus"

// Reloads the /slack onboarding page when the person's step changes somewhere
// else, for example when they accept the Code of Conduct in Slack. Polls the
// status endpoint while the tab is visible, and stops after a while.
export default class extends Controller {
static values = { url: String, step: String }

connect() {
this.checks = 0
this.timer = setInterval(() => this.check(), 5000)
}

disconnect() {
clearInterval(this.timer)
}

async check() {
if (document.hidden) return
if (++this.checks > 120) return this.disconnect()

try {
const response = await fetch(this.urlValue, { headers: { Accept: "application/json" } })
if (!response.ok) return
const { step } = await response.json()
if (step !== this.stepValue) {
this.disconnect()
window.Turbo ? window.Turbo.visit(window.location.href, { action: "replace" }) : window.location.reload()
}
} catch {
// A failed check is retried on the next tick.
}
}
}
35 changes: 21 additions & 14 deletions app/jobs/slack_code_of_conduct_accepted_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
# 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.
# Then every code-of-conduct DM the user still has (User#slack_coc_messages)
# is replaced with a thank-you, wherever they accepted, so no live button is
# left behind. message is the DM the acceptance came from, as
# { channel:, ts: }. It is replaced too, which covers a Slack user with no
# Weave account.
class SlackCodeOfConductAcceptedJob < ApplicationJob
queue_as :default

Expand All @@ -23,27 +25,32 @@ def perform(slack_user_id, message: nil)
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
already_member = user&.slack_member? || false
promote(service, user, slack_user_id) unless already_member
replace_messages(service, user, message, already_member:)
rescue SlackService::ConfigurationError => e
Rails.logger.error "Slack not configured for promotion: #{e.message}"
end

private

def promote(service, user, slack_user_id)
result = service.promote_to_member(slack_user_id)
if result[:ok]
user&.update_columns(slack_membership: "member", updated_at: Time.current)
Rails.logger.info "Promoted #{slack_user_id} to full member after CoC acceptance"
else
Rails.logger.error "Failed to promote #{slack_user_id} after CoC acceptance: #{result[:error]}"
end
rescue SlackService::ConfigurationError => e
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}"
def replace_messages(service, user, message, already_member:)
messages = [*user&.take_code_of_conduct_messages!, message].compact.uniq
messages.each do |ref|
service.mark_code_of_conduct_accepted(channel: ref[:channel], ts: ref[:ts], already_member:)
rescue => e
Rails.logger.error "Failed to update CoC DM #{ref.inspect}: #{e.message}"
end
end

end
25 changes: 25 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
# slack_birthday :date
# slack_city :string
# slack_coc_accepted_at :datetime
# slack_coc_messages :jsonb not null
# slack_coc_requested_at :datetime
# slack_cost_center :string
# slack_country :string
Expand Down Expand Up @@ -502,6 +503,30 @@ def slack_onboarding_step

def slack_onboarding_complete? = slack_onboarding_step == :member

# Remembers a code-of-conduct DM that has a live button, so it can be
# replaced once the user accepts (see SlackCodeOfConductAcceptedJob). Appends
# in SQL, so two DMs sent at the same time are both kept.
def remember_code_of_conduct_message!(channel:, ts:)
return if channel.blank? || ts.blank?

self.class.where(id: id).update_all( # rubocop:disable Rails/SkipsModelValidations
["slack_coc_messages = slack_coc_messages || ?::jsonb", [{ channel: channel, ts: ts }].to_json]
)
end

# Returns the remembered DMs and forgets them. The row lock keeps a DM that
# is remembered at the same moment from being lost. (with_lock is not used:
# User#lock! means "lock the account".)
def take_code_of_conduct_messages!
messages = self.class.transaction do
rows = self.class.where(id: id)
taken = rows.lock.pick(:slack_coc_messages) || []
rows.update_all(slack_coc_messages: []) # rubocop:disable Rails/SkipsModelValidations
taken
end
messages.map { |message| message.symbolize_keys.slice(:channel, :ts) }
end

# 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 @@ -29,6 +29,7 @@
# slack_birthday :date
# slack_city :string
# slack_coc_accepted_at :datetime
# slack_coc_messages :jsonb not null
# slack_coc_requested_at :datetime
# slack_cost_center :string
# slack_country :string
Expand Down
5 changes: 4 additions & 1 deletion app/services/slack_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,14 @@ def post_code_of_conduct(slack_user_id, title: nil, paragraphs: nil, form: false
web_url: Rails.application.routes.url_helpers.slack_onboarding_url(**Rails.application.config.action_mailer.default_url_options)
)

@client.chat_postMessage(
response = @client.chat_postMessage(
channel: slack_user_id,
text: "Please review and accept the Patchwork Labs Code of Conduct.",
blocks: message[:blocks]
)
# Remembered so that accepting anywhere (Slack or the web) replaces it.
User.find_by(slack_id: slack_user_id)&.remember_code_of_conduct_message!(channel: response["channel"], ts: response["ts"])
response
end

# Opens the code-of-conduct form from a button click. trigger_id comes from
Expand Down
9 changes: 7 additions & 2 deletions app/views/slack_onboarding/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
link_class = "text-grape hover:text-ink font-medium underline decoration-dashed underline-offset-2"
%>

<div class="min-h-full flex items-center justify-center px-4 py-12">
<%# While the next step can happen in Slack, check now and then and reload
when it does. %>
<%= tag.div class: "min-h-full flex items-center justify-center px-4 py-12",
data: (if current_user && %i[accept_invite accept_code_of_conduct awaiting_promotion].include?(@step)
{ controller: "onboarding-status", onboarding_status_url_value: status_slack_onboarding_path, onboarding_status_step_value: @step }
end) do %>
<div class="patch patch-bound patch-tilt-l w-full max-w-lg">
<div class="quilt-binding"></div>

Expand Down Expand Up @@ -142,4 +147,4 @@
<% end %>
</div>
</div>
</div>
<% end %>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
# GET /(*path) redirect(302)
# root GET / home#index
# accept_code_of_conduct_slack_onboarding POST /slack/code-of-conduct(.:format) slack_onboarding#accept_code_of_conduct
# status_slack_onboarding GET /slack/status(.:format) slack_onboarding#status
# slack_onboarding GET /slack(.:format) slack_onboarding#show
# POST /slack(.:format) slack_onboarding#create
# GET /.well-known/*path well_known#show
Expand Down Expand Up @@ -359,6 +360,7 @@
# request or resend the invite.
resource :slack_onboarding, path: "slack", only: [:show, :create], controller: "slack_onboarding" do
post :accept_code_of_conduct, path: "code-of-conduct"
get :status
end

# Well-known routes for standard compliance
Expand Down
10 changes: 10 additions & 0 deletions db/migrate/20261010120000_add_slack_coc_messages_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

class AddSlackCocMessagesToUsers < ActiveRecord::Migration[8.1]
def change
# The code-of-conduct DMs sent to a user that still have a live button, as
# [{ "channel" => ..., "ts" => ... }]. They are all replaced with a
# thank-you once the user accepts, from Slack or the web.
add_column :users, :slack_coc_messages, :jsonb, default: [], null: false
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ flowchart TD
- The DM button accepts at once. When the Slack import saved a placeholder name (`NOTSET`, `Unknown`, `User`), the button opens a Slack form instead. The form has a required "I have read and accept" box, and name fields when the name is missing.
- The email links to sign-in. After sign-in, `/` sends the member to `/slack`, which shows the same accept step and name fields.
- The message text is in `config/locales/code_of_conduct_request.en.yml`.
- Weave remembers every CoC DM it sends (`users.slack_coc_messages`). When the person accepts, in Slack or on `/slack`, `SlackCodeOfConductAcceptedJob` replaces all of them with a thank-you, so no live button is left.
- While `/slack` waits on a step that can happen in Slack, it polls `GET /slack/status` every 5 seconds and reloads when the step changes.

## Runbook

Expand Down
1 change: 1 addition & 0 deletions spec/factories/users.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
# slack_birthday :date
# slack_city :string
# slack_coc_accepted_at :datetime
# slack_coc_messages :jsonb not null
# slack_coc_requested_at :datetime
# slack_cost_center :string
# slack_country :string
Expand Down
23 changes: 23 additions & 0 deletions spec/jobs/slack_code_of_conduct_accepted_job_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,27 @@

described_class.perform_now("U123", message: { channel: "D1", ts: "1.2" })
end

it "replaces every DM the user still has, wherever they accepted, and forgets them" do
user.update!(slack_membership: "member")
user.remember_code_of_conduct_message!(channel: "D1", ts: "1.1")
user.remember_code_of_conduct_message!(channel: "D1", ts: "1.2")
replaced = []
allow(service).to receive(:mark_code_of_conduct_accepted) { |**ref| replaced << ref }

described_class.perform_now("U123", message: { channel: "D1", ts: "1.2" })

expect(replaced).to eq([{ channel: "D1", ts: "1.1", already_member: true }, { channel: "D1", ts: "1.2", already_member: true }])
expect(user.reload.slack_coc_messages).to eq([])
end

it "keeps replacing the other DMs when one fails" do
user.update!(slack_membership: "member")
user.remember_code_of_conduct_message!(channel: "D1", ts: "1.1")
user.remember_code_of_conduct_message!(channel: "D1", ts: "1.2")
allow(service).to receive(:mark_code_of_conduct_accepted).with(hash_including(ts: "1.1")).and_raise(Slack::Web::Api::Errors::SlackError, "message_not_found")
expect(service).to receive(:mark_code_of_conduct_accepted).with(hash_including(ts: "1.2"))

described_class.perform_now("U123")
end
end
1 change: 1 addition & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
# slack_birthday :date
# slack_city :string
# slack_coc_accepted_at :datetime
# slack_coc_messages :jsonb not null
# slack_coc_requested_at :datetime
# slack_cost_center :string
# slack_country :string
Expand Down
29 changes: 29 additions & 0 deletions spec/requests/slack_onboarding_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,35 @@
end
end

describe "GET /slack/status" do
it "reports the current step, so the page can reload when it changes elsewhere" do
user.update!(slack_id: "U1", slack_membership: "member")
sign_in_via_magic_link(user)

get status_slack_onboarding_path

expect(response.parsed_body).to eq("step" => "accept_code_of_conduct")
end

it "makes the accept step check the status" do
user.update!(slack_id: "U1", slack_membership: "member")
sign_in_via_magic_link(user)

get slack_onboarding_path

expect(response.body).to include('data-controller="onboarding-status"', status_slack_onboarding_path)
end

it "does not check the status once the person is a full member" do
user.update!(slack_id: "U1", slack_membership: "member", slack_coc_accepted_at: 1.day.ago)
sign_in_via_magic_link(user)

get slack_onboarding_path

expect(response.body).not_to include("onboarding-status")
end
end

describe "POST /slack" do
before { sign_in_via_magic_link(user) }

Expand Down
1 change: 1 addition & 0 deletions spec/serializers/user_serializer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
# slack_birthday :date
# slack_city :string
# slack_coc_accepted_at :datetime
# slack_coc_messages :jsonb not null
# slack_coc_requested_at :datetime
# slack_cost_center :string
# slack_country :string
Expand Down
9 changes: 9 additions & 0 deletions spec/services/slack_service_code_of_conduct_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ def block_types(blocks) = blocks.map { |block| block[:type] }
service.post_code_of_conduct("U1", title: "Hello", paragraphs: ["First paragraph"])
end

it "remembers the DM, so accepting anywhere can replace it" do
user = create(:user, slack_id: "U1")
allow(client).to receive(:chat_postMessage).and_return({ "ok" => true, "channel" => "D1", "ts" => "1.2" })

service.post_code_of_conduct("U1")

expect(user.reload.slack_coc_messages).to eq([{ "channel" => "D1", "ts" => "1.2" }])
end

it "uses the form button when asked" do
expect(client).to receive(:chat_postMessage) do |args|
button = args[:blocks].find { |block| block[:type] == "actions" }[:elements].first
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
# slack_birthday :date
# slack_city :string
# slack_coc_accepted_at :datetime
# slack_coc_messages :jsonb not null
# slack_coc_requested_at :datetime
# slack_cost_center :string
# slack_country :string
Expand Down
1 change: 1 addition & 0 deletions test/models/user_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
# slack_birthday :date
# slack_city :string
# slack_coc_accepted_at :datetime
# slack_coc_messages :jsonb not null
# slack_coc_requested_at :datetime
# slack_cost_center :string
# slack_country :string
Expand Down
Loading