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
2 changes: 1 addition & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def create_session_for_existing_user
current_user.user_sessions.find_by(session_token: session.id.to_s)
end

helper_method :current_user, :impersonating?, :admin_user
helper_method :current_user, :current_user_session, :impersonating?, :admin_user

# Track papertrail edits to specific users
before_action :set_paper_trail_whodunnit
Expand Down
30 changes: 23 additions & 7 deletions app/jobs/code_of_conduct_request_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@
# 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.
#
# With refresh: true, the job sends nothing new. It rewrites the DMs that were
# already sent (users.slack_coc_messages) with the current text and links.
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)
def perform(user_id, deadline: nil, reminder: false, refresh: false)
user = User.find_by(id: user_id)
return unless user&.can_authenticate?
return if user.slack_coc_accepted_at.present?
return refresh_slack_dms(user, deadline) if refresh
return if user.slack_coc_requested_at.present? && !reminder

send_slack_dm(user, deadline) if user.slack_id.present?
Expand Down Expand Up @@ -54,14 +58,26 @@ def send_slack_dm(user, deadline)
service = SlackService.new
return unless service.configured?

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

# One failed update must not stop the others.
def refresh_slack_dms(user, deadline)
service = SlackService.new
return unless service.configured?

user.slack_coc_messages.each do |message|
service.update_code_of_conduct(channel: message["channel"], ts: message["ts"], slack_user_id: user.slack_id,
**slack_message(user, deadline))
rescue => e
Rails.logger.error "Failed to refresh the CoC DM #{message["ts"]} for #{user.slack_id}: #{e.message}"
end
end

def slack_message(user, deadline)
{ title: self.class.slack_title(:update), paragraphs: self.class.paragraphs(user, deadline:), form: user.name_missing? }
end

end
59 changes: 36 additions & 23 deletions app/services/slack_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -200,33 +200,21 @@ def workspace_admin?(slack_user_id)
def post_code_of_conduct(slack_user_id, title: nil, paragraphs: nil, form: false)
raise ConfigurationError, "Slack client not configured" unless @client

title ||= "Welcome to Patchwork Labs! 👋"
paragraphs ||= ["Before you get full access to the community, please read our Code of Conduct and accept it."]
button = if form
{ text: "Review and accept", action_id: "open_coc_form", value: slack_user_id }
else
{ text: "I accept the Code of Conduct", action_id: "accept_coc", value: slack_user_id }
end

message = CodeOfConductSlackViews.request(
title: title,
paragraphs: paragraphs,
action_text: I18n.t(form ? "code_of_conduct_form.slack_form_action" : "code_of_conduct_form.slack_action"),
button: button,
coc_url: self.class.code_of_conduct_url,
web_url: Rails.application.routes.url_helpers.slack_onboarding_url(**Rails.application.config.action_mailer.default_url_options)
)

response = @client.chat_postMessage(
channel: slack_user_id,
text: "Please review and accept the Patchwork Labs Code of Conduct.",
blocks: message[:blocks]
)
response = @client.chat_postMessage(channel: slack_user_id, **code_of_conduct_message(slack_user_id, title:, paragraphs:, form:))
# 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

# Rewrites a code-of-conduct DM that was already sent (channel and ts from
# users.slack_coc_messages) with the current text and links. Takes the same
# options as #post_code_of_conduct.
def update_code_of_conduct(channel:, ts:, slack_user_id:, title: nil, paragraphs: nil, form: false)
raise ConfigurationError, "Slack client not configured" unless @client

@client.chat_update(channel: channel, ts: ts, **code_of_conduct_message(slack_user_id, title:, paragraphs:, form:))
end

# Opens the code-of-conduct form from a button click. trigger_id comes from
# the click and expires after 3 seconds, so call this while answering it.
# The view comes from CodeOfConductSlackViews.form.
Expand Down Expand Up @@ -272,8 +260,11 @@ def self.workspace_url
"https://#{subdomain}.slack.com" if subdomain.present?
end

DEFAULT_CODE_OF_CONDUCT_URL = "https://patchworklabs.org/conduct"

# SLACK_COC_URL or the credential overrides the public page on the website.
def self.code_of_conduct_url
ENV["SLACK_COC_URL"] || Rails.application.credentials.dig(:slack, :coc_url)
ENV["SLACK_COC_URL"].presence || Rails.application.credentials.dig(:slack, :coc_url).presence || DEFAULT_CODE_OF_CONDUCT_URL
end

# The email address of a Slack user, from users.info (needs users:read.email).
Expand Down Expand Up @@ -574,6 +565,28 @@ def push_profile_fields(slack_user_id, user)

private

# The text and blocks of a code-of-conduct DM (see #post_code_of_conduct).
def code_of_conduct_message(slack_user_id, title:, paragraphs:, form:)
title ||= "Welcome to Patchwork Labs! 👋"
paragraphs ||= ["Before you get full access to the community, please read our Code of Conduct and accept it."]
button = if form
{ text: "Review and accept", action_id: "open_coc_form", value: slack_user_id }
else
{ text: "I accept the Code of Conduct", action_id: "accept_coc", value: slack_user_id }
end

message = CodeOfConductSlackViews.request(
title: title,
paragraphs: paragraphs,
action_text: I18n.t(form ? "code_of_conduct_form.slack_form_action" : "code_of_conduct_form.slack_action"),
button: button,
coc_url: self.class.code_of_conduct_url,
web_url: Rails.application.routes.url_helpers.slack_onboarding_url(**Rails.application.config.action_mailer.default_url_options)
)

{ text: "Please review and accept the Patchwork Labs Code of Conduct.", blocks: message[:blocks] }
end

def build_client
return nil if token.blank?

Expand Down
2 changes: 1 addition & 1 deletion config/locales/code_of_conduct_request.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ en:
subject: "Please accept the Patchwork Labs Code of Conduct"
greeting: "Hi %{first_name},"
update: |-
Here is a quick update on Patchwork Labs. We now run programs that we call patches. In a patch, you build and ship a project, and we support it with compute, grants, hardware or mentorship. Project Ganymede is our first patch: it gives students free compute and mentorship for their research.
Here is a quick update on Patchwork Labs. We now run programs that we call patches. In a patch, you build and ship a project, and we support it with compute, grants or hardware. Project Ganymede is our first patch: it gives students free compute for their research.

We also set up Weave, our new account system. Your Weave account is linked to your Slack account, and you use it to sign in to Patchwork apps.

Expand Down
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,7 @@ 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`.
- The Code of Conduct link is `SLACK_COC_URL` (or the `slack.coc_url` credential). Without it, Weave links to https://patchworklabs.org/conduct.
- 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.

Expand All @@ -27,6 +28,7 @@ Run these in the production container.
2. Preview the message: `bin/rails slack:coc:request DEADLINE=2026-11-01`.
3. Send it: `bin/rails slack:coc:request SEND=1 DEADLINE=2026-11-01`.
4. Optional: send a reminder: `bin/rails slack:coc:request SEND=1 REMIND=1 DEADLINE=2026-11-01`.
To fix the text or links of DMs that were already sent, without a new DM or email: `bin/rails slack:coc:refresh SEND=1 DEADLINE=2026-11-01`.
5. After the deadline, preview the demotions: `bin/rails slack:coc:demote ASKED_BEFORE=2026-10-15`.
6. Demote: `bin/rails slack:coc:demote CONFIRM=1 ASKED_BEFORE=2026-10-15`. Slack admins and owners cannot be demoted. They get a DM whose button opens the form with the box to check, and an email.

Expand Down
30 changes: 30 additions & 0 deletions lib/tasks/slack.rake
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,36 @@ namespace :slack do
puts "Enqueued #{users.size} requests."
end

# Rewrites the request DMs that were already sent with the current text
# and links. Sends no new DM and no email. Nothing changes without SEND=1.
# Pass the DEADLINE of the original request, so the text keeps it.
#
# bin/rails slack:coc:refresh DEADLINE=2026-11-01 # preview
# bin/rails slack:coc:refresh SEND=1 DEADLINE=2026-11-01
desc "Rewrite the code of conduct DMs already sent with the current text (SEND=1 to send)"
task refresh: :environment do
deadline = ENV["DEADLINE"].presence && Date.iso8601(ENV["DEADLINE"])
users = User.code_of_conduct_pending.where("jsonb_array_length(slack_coc_messages) > 0").select(&:can_authenticate?)

sample = users.first || User.new(first_name: "Ada", last_name: "Lovelace")
puts "New text for #{sample.email.presence || 'a sample member'}:"
puts "-" * 50
puts CodeOfConductRequestJob.paragraphs(sample, deadline:).join("\n\n")
puts "-" * 50
puts "#{users.size} members, #{users.sum { |user| user.slack_coc_messages.size }} DMs to rewrite."

unless ENV["SEND"] == "1"
puts "Preview only. Run again with SEND=1 to rewrite them."
next
end

# Space the jobs out to stay under Slack's chat.update rate limit.
users.each_with_index do |user, index|
CodeOfConductRequestJob.set(wait: (index * 2).seconds).perform_later(user.id, deadline: deadline, refresh: true)
end
puts "Enqueued #{users.size} refreshes."
end

# Moves members who were asked and still have not accepted back to
# single-channel guests. Nothing changes without CONFIRM=1.
#
Expand Down
36 changes: 36 additions & 0 deletions spec/jobs/code_of_conduct_request_job_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,40 @@

expect { described_class.perform_now(user.id) }.not_to have_enqueued_mail(UserMailer, :code_of_conduct_request)
end

describe "refresh" do
before do
user.update!(slack_coc_requested_at: 1.hour.ago, slack_coc_messages: [{ channel: "D1", ts: "1.2" }, { channel: "D1", ts: "3.4" }])
allow(slack).to receive(:update_code_of_conduct)
end

it "rewrites every DM already sent, and sends no new DM or email" do
expect { described_class.perform_now(user.id, deadline: Date.new(2026, 11, 1), refresh: true) }
.not_to have_enqueued_mail(UserMailer, :code_of_conduct_request)

expect(slack).not_to have_received(:post_code_of_conduct)

Check failure on line 71 in spec/jobs/code_of_conduct_request_job_spec.rb

View workflow job for this annotation

GitHub Actions / lint

RSpec/MessageSpies: Prefer `receive` for setting message expectations.
%w[1.2 3.4].each do |ts|
expect(slack).to have_received(:update_code_of_conduct)

Check failure on line 73 in spec/jobs/code_of_conduct_request_job_spec.rb

View workflow job for this annotation

GitHub Actions / lint

RSpec/MessageSpies: Prefer `receive` for setting message expectations.
.with(channel: "D1", ts: ts, slack_user_id: "U1", title: anything,
paragraphs: include(a_string_including("November 1, 2026")), form: false)
end
end

it "keeps going when one update fails" do
allow(slack).to receive(:update_code_of_conduct).with(hash_including(ts: "1.2"))
.and_raise(Slack::Web::Api::Errors::SlackError, "message_not_found")

described_class.perform_now(user.id, refresh: true)

expect(slack).to have_received(:update_code_of_conduct).with(hash_including(ts: "3.4"))

Check failure on line 85 in spec/jobs/code_of_conduct_request_job_spec.rb

View workflow job for this annotation

GitHub Actions / lint

RSpec/MessageSpies: Prefer `receive` for setting message expectations.
end

it "leaves the DMs of someone who already accepted alone" do
user.update!(slack_coc_accepted_at: 1.minute.ago)

described_class.perform_now(user.id, refresh: true)

expect(slack).not_to have_received(:update_code_of_conduct)

Check failure on line 93 in spec/jobs/code_of_conduct_request_job_spec.rb

View workflow job for this annotation

GitHub Actions / lint

RSpec/MessageSpies: Prefer `receive` for setting message expectations.
end
end
end
9 changes: 9 additions & 0 deletions spec/requests/session_revocation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@
expect(response).to have_http_status(:ok)
end

it "lists the sessions and marks the current one" do
sign_in_via_magic_link(user)

get profile_sessions_path

expect(response).to have_http_status(:ok)
expect(response.body).to include("Current")
end

it "stops authenticating once the user is locked" do
sign_in_via_magic_link(user)

Expand Down
13 changes: 13 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,19 @@ def block_types(blocks) = blocks.map { |block| block[:type] }
service.post_code_of_conduct("U1", title: "Hello", paragraphs: ["First paragraph"])
end

it "rewrites a sent DM with the current links, and does not remember it twice" do
user = create(:user, slack_id: "U1")
expect(client).to receive(:chat_update) do |args|
expect(args).to include(channel: "D1", ts: "1.2")
expect(args[:blocks][2][:accessory][:url]).to eq("https://patchworklabs.org/coc")
expect(args[:blocks][5][:elements].first).to include(action_id: "open_coc_form", value: "U1")
end

service.update_code_of_conduct(channel: "D1", ts: "1.2", slack_user_id: "U1", title: "Hello", paragraphs: ["Hi"], form: true)

expect(user.reload.slack_coc_messages).to eq([])
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" })
Expand Down
26 changes: 26 additions & 0 deletions spec/services/slack_service_code_of_conduct_url_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe SlackService, ".code_of_conduct_url" do
around do |example|
original = ENV.fetch("SLACK_COC_URL", nil)
example.run
ensure
ENV["SLACK_COC_URL"] = original
end

it "links to the Code of Conduct on the website when nothing is configured" do
ENV["SLACK_COC_URL"] = ""
allow(Rails.application.credentials).to receive(:dig).and_call_original
allow(Rails.application.credentials).to receive(:dig).with(:slack, :coc_url).and_return(nil)

expect(described_class.code_of_conduct_url).to eq("https://patchworklabs.org/conduct")
end

it "uses SLACK_COC_URL when it is set" do
ENV["SLACK_COC_URL"] = "https://example.org/coc"

expect(described_class.code_of_conduct_url).to eq("https://example.org/coc")
end
end
Loading