diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 406180c4..edfdee17 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/jobs/code_of_conduct_request_job.rb b/app/jobs/code_of_conduct_request_job.rb index cc67b150..7a6d612e 100644 --- a/app/jobs/code_of_conduct_request_job.rb +++ b/app/jobs/code_of_conduct_request_job.rb @@ -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? @@ -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 diff --git a/app/services/slack_service.rb b/app/services/slack_service.rb index 77263c8f..0bffee58 100644 --- a/app/services/slack_service.rb +++ b/app/services/slack_service.rb @@ -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. @@ -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). @@ -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? diff --git a/config/locales/code_of_conduct_request.en.yml b/config/locales/code_of_conduct_request.en.yml index f9685fe5..19810f50 100644 --- a/config/locales/code_of_conduct_request.en.yml +++ b/config/locales/code_of_conduct_request.en.yml @@ -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. diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md index 2854904d..cb823ab2 100644 --- a/docs/CODE_OF_CONDUCT.md +++ b/docs/CODE_OF_CONDUCT.md @@ -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. @@ -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. diff --git a/lib/tasks/slack.rake b/lib/tasks/slack.rake index 8f8ca6b8..4b852240 100644 --- a/lib/tasks/slack.rake +++ b/lib/tasks/slack.rake @@ -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. # diff --git a/spec/jobs/code_of_conduct_request_job_spec.rb b/spec/jobs/code_of_conduct_request_job_spec.rb index 0eed6a8a..8412d845 100644 --- a/spec/jobs/code_of_conduct_request_job_spec.rb +++ b/spec/jobs/code_of_conduct_request_job_spec.rb @@ -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) + %w[1.2 3.4].each do |ts| + expect(slack).to have_received(:update_code_of_conduct) + .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")) + 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) + end + end end diff --git a/spec/requests/session_revocation_spec.rb b/spec/requests/session_revocation_spec.rb index aea5f482..1f20a2b2 100644 --- a/spec/requests/session_revocation_spec.rb +++ b/spec/requests/session_revocation_spec.rb @@ -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) diff --git a/spec/services/slack_service_code_of_conduct_spec.rb b/spec/services/slack_service_code_of_conduct_spec.rb index f5e6d279..0b5c1a66 100644 --- a/spec/services/slack_service_code_of_conduct_spec.rb +++ b/spec/services/slack_service_code_of_conduct_spec.rb @@ -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" }) diff --git a/spec/services/slack_service_code_of_conduct_url_spec.rb b/spec/services/slack_service_code_of_conduct_url_spec.rb new file mode 100644 index 00000000..50e94762 --- /dev/null +++ b/spec/services/slack_service_code_of_conduct_url_spec.rb @@ -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