From 58f969d9e65932399253e27de50cca48e85142e6 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:26:32 -0400 Subject: [PATCH 1/2] Add reject-and-detach option to mission submission review Reviewers can now reject a mission submission and, in the same step, detach the project from the mission so the builder is freed to pick a different one. Detaching deducts 5 stardust from the builder. - Add a 'detach project' checkbox to the verdict form (rejections only) - Detach via Project#detach_mission!, guarded to the submission's current mission so a since-swapped mission is never yanked - Charge DETACH_PENALTY (5) stardust via a ledger entry on detach, which writes its own balance_adjustment PaperTrail audit - Tests for plain reject (no charge), reject+detach (-5, mission freed), and detach flag ignored on approve --- .../pages/admin/_mission_submissions.scss | 20 +++++++++ .../admin/missions/submissions_controller.rb | 41 ++++++++++++++++++- .../admin/missions/submissions/_form.html.erb | 11 +++++ .../missions/submissions_controller_test.rb | 41 +++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/admin/_mission_submissions.scss b/app/assets/stylesheets/pages/admin/_mission_submissions.scss index 798ce131d..76a5cdc84 100644 --- a/app/assets/stylesheets/pages/admin/_mission_submissions.scss +++ b/app/assets/stylesheets/pages/admin/_mission_submissions.scss @@ -830,6 +830,26 @@ resize: vertical; } + &__detach { + display: flex; + flex-direction: column; + gap: var(--space-xxs); + } + + &__detach-option { + display: flex; + align-items: center; + gap: var(--space-xs); + cursor: pointer; + font-size: 0.95rem; + } + + &__detach-hint { + font-size: 0.85rem; + color: var(--muted); + margin: 0; + } + &__payout-note { font-size: 0.9rem; color: var(--color-brand-yellow); diff --git a/app/controllers/admin/missions/submissions_controller.rb b/app/controllers/admin/missions/submissions_controller.rb index 8c49ba06f..906929e2c 100644 --- a/app/controllers/admin/missions/submissions_controller.rb +++ b/app/controllers/admin/missions/submissions_controller.rb @@ -3,6 +3,10 @@ module Missions class SubmissionsController < BaseController layout "application" + # Stardust deducted from the builder when a reviewer rejects and detaches + # their project from the mission. + DETACH_PENALTY = 5 + skip_before_action :authorize_mission_management before_action :release_other_claims, only: [ :next, :claim ] before_action :set_submission, only: [ :show, :update, :claim, :undo ] @@ -93,6 +97,10 @@ def update alert: "This submission can't be #{new_status} right now." and return end + detach_requested = new_status == "rejected" && + ActiveModel::Type::Boolean.new.cast(params.dig(:mission_submission, :detach_project)) + detached = false + Mission::Submission.transaction do if new_status == "approved" @submission.update!(reviewed_by: current_user, reviewed_at: Time.current, rejection_message: nil) @@ -102,14 +110,16 @@ def update else @submission.update!(reviewed_by: current_user, reviewed_at: Time.current, rejection_message: feedback) @submission.reject! + detached = detach_submission_project! if detach_requested end end notify_builder(new_status) reviewed = Mission::Submission.reviewed_today(current_user, mission: @mission) + verdict = detached ? "Rejected and detached the project (−#{DETACH_PENALTY} stardust)" : new_status.titleize redirect_to next_admin_mission_submissions_path(mission_slug), - notice: "#{new_status.titleize}. That's #{reviewed} reviewed today." + notice: "#{verdict}. That's #{reviewed} reviewed today." end def next @@ -281,6 +291,35 @@ def reverse_fixed_stardust_if_granted ) end + # Detaches the submission's project from the mission it was rejected on, + # but only while that mission is still the project's current one — so we + # never yank a mission the builder has since swapped to. Charges the + # builder DETACH_PENALTY stardust when it actually detaches, and returns + # whether a detach happened. The MissionAttachment change is versioned by + # PaperTrail and the ledger entry writes its own balance_adjustment audit + # (whodunnit set in the admin controller chain). + def detach_submission_project! + project = @submission.ship_event&.post&.project + return false unless project + return false unless project.current_mission == @submission.mission + + project.detach_mission! + charge_detach_penalty + true + end + + def charge_detach_penalty + builder = @submission.ship_event&.post&.user + return unless builder + + builder.ledger_entries.create!( + amount: -DETACH_PENALTY, + reason: "Mission detach penalty: #{@submission.mission.name}", + created_by: "mission_submission:#{@submission.id} detach (#{current_user.id})", + ledgerable: builder + ) + end + def notify_builder(status) builder = @submission.ship_event&.post&.user return unless builder diff --git a/app/views/admin/missions/submissions/_form.html.erb b/app/views/admin/missions/submissions/_form.html.erb index 210c3bc95..7791469a8 100644 --- a/app/views/admin/missions/submissions/_form.html.erb +++ b/app/views/admin/missions/submissions/_form.html.erb @@ -21,6 +21,17 @@ placeholder: "Required for rejections. Optional for approvals." %> +
+ +

+ Only applies when rejecting. Frees the project from <%= mission.name %> so + the builder can pick a different mission, and deducts 5 stardust from them. +

+
+ <% grants = [] %> <% if submission.payout_path == "static_prize" %> <% grants << "#{mission.fixed_stardust_payout} stardust" if mission.fixed_stardust_payout&.positive? %> diff --git a/test/controllers/admin/missions/submissions_controller_test.rb b/test/controllers/admin/missions/submissions_controller_test.rb index 8525b38f2..257730a8a 100644 --- a/test/controllers/admin/missions/submissions_controller_test.rb +++ b/test/controllers/admin/missions/submissions_controller_test.rb @@ -44,6 +44,47 @@ class Admin::Missions::SubmissionsControllerTest < ActionDispatch::IntegrationTe assert @submission.reload.pending? end + test "plain reject leaves the project attached and the balance untouched" do + sign_in @reviewer + Mission::Submission.atomic_claim!(@submission.id, @reviewer) + + assert_no_difference -> { @builder.reload.balance } do + patch admin_mission_submission_path(@mission.slug, @submission), + params: { mission_submission: { status: "rejected", feedback: "Needs work" } } + end + + assert_redirected_to next_admin_mission_submissions_path(@mission.slug) + assert @submission.reload.rejected? + assert_equal @mission, @project.reload.current_mission + end + + test "reject and detach frees the project and deducts 5 stardust" do + sign_in @reviewer + Mission::Submission.atomic_claim!(@submission.id, @reviewer) + + assert_difference -> { @builder.reload.balance }, -5 do + patch admin_mission_submission_path(@mission.slug, @submission), + params: { mission_submission: { status: "rejected", feedback: "Needs work", detach_project: "1" } } + end + + assert_redirected_to next_admin_mission_submissions_path(@mission.slug) + assert @submission.reload.rejected? + assert_nil @project.reload.current_mission + end + + test "detach flag is ignored when approving" do + sign_in @reviewer + Mission::Submission.atomic_claim!(@submission.id, @reviewer) + + assert_no_difference -> { @builder.reload.balance } do + patch admin_mission_submission_path(@mission.slug, @submission), + params: { mission_submission: { status: "approved", detach_project: "1" } } + end + + assert @submission.reload.approved? + assert_equal @mission, @project.reload.current_mission + end + test "claims are exclusive while fresh and stealable when expired" do other = User.create!(email: "other-#{SecureRandom.hex(4)}@example.test", display_name: "other-#{SecureRandom.hex(4)}", From e62b7ff73953a674d7d212fead90ec4b70825ee5 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:35:11 -0400 Subject: [PATCH 2/2] Drop the 5-stardust detach penalty Reject-and-detach now just frees the project from the mission with no stardust deduction. --- .../admin/missions/submissions_controller.rb | 27 +++---------------- .../admin/missions/submissions/_form.html.erb | 2 +- .../missions/submissions_controller_test.rb | 22 ++++++--------- 3 files changed, 13 insertions(+), 38 deletions(-) diff --git a/app/controllers/admin/missions/submissions_controller.rb b/app/controllers/admin/missions/submissions_controller.rb index 906929e2c..0ea137958 100644 --- a/app/controllers/admin/missions/submissions_controller.rb +++ b/app/controllers/admin/missions/submissions_controller.rb @@ -3,10 +3,6 @@ module Missions class SubmissionsController < BaseController layout "application" - # Stardust deducted from the builder when a reviewer rejects and detaches - # their project from the mission. - DETACH_PENALTY = 5 - skip_before_action :authorize_mission_management before_action :release_other_claims, only: [ :next, :claim ] before_action :set_submission, only: [ :show, :update, :claim, :undo ] @@ -117,7 +113,7 @@ def update notify_builder(new_status) reviewed = Mission::Submission.reviewed_today(current_user, mission: @mission) - verdict = detached ? "Rejected and detached the project (−#{DETACH_PENALTY} stardust)" : new_status.titleize + verdict = detached ? "Rejected and detached the project" : new_status.titleize redirect_to next_admin_mission_submissions_path(mission_slug), notice: "#{verdict}. That's #{reviewed} reviewed today." end @@ -293,33 +289,18 @@ def reverse_fixed_stardust_if_granted # Detaches the submission's project from the mission it was rejected on, # but only while that mission is still the project's current one — so we - # never yank a mission the builder has since swapped to. Charges the - # builder DETACH_PENALTY stardust when it actually detaches, and returns - # whether a detach happened. The MissionAttachment change is versioned by - # PaperTrail and the ledger entry writes its own balance_adjustment audit - # (whodunnit set in the admin controller chain). + # never yank a mission the builder has since swapped to. Returns whether + # a detach actually happened. The MissionAttachment change is versioned + # by PaperTrail (whodunnit set in the admin controller chain). def detach_submission_project! project = @submission.ship_event&.post&.project return false unless project return false unless project.current_mission == @submission.mission project.detach_mission! - charge_detach_penalty true end - def charge_detach_penalty - builder = @submission.ship_event&.post&.user - return unless builder - - builder.ledger_entries.create!( - amount: -DETACH_PENALTY, - reason: "Mission detach penalty: #{@submission.mission.name}", - created_by: "mission_submission:#{@submission.id} detach (#{current_user.id})", - ledgerable: builder - ) - end - def notify_builder(status) builder = @submission.ship_event&.post&.user return unless builder diff --git a/app/views/admin/missions/submissions/_form.html.erb b/app/views/admin/missions/submissions/_form.html.erb index 7791469a8..6c037a1d6 100644 --- a/app/views/admin/missions/submissions/_form.html.erb +++ b/app/views/admin/missions/submissions/_form.html.erb @@ -28,7 +28,7 @@

Only applies when rejecting. Frees the project from <%= mission.name %> so - the builder can pick a different mission, and deducts 5 stardust from them. + the builder can pick a different mission.

diff --git a/test/controllers/admin/missions/submissions_controller_test.rb b/test/controllers/admin/missions/submissions_controller_test.rb index 257730a8a..1028ee7b9 100644 --- a/test/controllers/admin/missions/submissions_controller_test.rb +++ b/test/controllers/admin/missions/submissions_controller_test.rb @@ -44,28 +44,24 @@ class Admin::Missions::SubmissionsControllerTest < ActionDispatch::IntegrationTe assert @submission.reload.pending? end - test "plain reject leaves the project attached and the balance untouched" do + test "plain reject leaves the project attached to the mission" do sign_in @reviewer Mission::Submission.atomic_claim!(@submission.id, @reviewer) - assert_no_difference -> { @builder.reload.balance } do - patch admin_mission_submission_path(@mission.slug, @submission), - params: { mission_submission: { status: "rejected", feedback: "Needs work" } } - end + patch admin_mission_submission_path(@mission.slug, @submission), + params: { mission_submission: { status: "rejected", feedback: "Needs work" } } assert_redirected_to next_admin_mission_submissions_path(@mission.slug) assert @submission.reload.rejected? assert_equal @mission, @project.reload.current_mission end - test "reject and detach frees the project and deducts 5 stardust" do + test "reject and detach frees the project from the mission" do sign_in @reviewer Mission::Submission.atomic_claim!(@submission.id, @reviewer) - assert_difference -> { @builder.reload.balance }, -5 do - patch admin_mission_submission_path(@mission.slug, @submission), - params: { mission_submission: { status: "rejected", feedback: "Needs work", detach_project: "1" } } - end + patch admin_mission_submission_path(@mission.slug, @submission), + params: { mission_submission: { status: "rejected", feedback: "Needs work", detach_project: "1" } } assert_redirected_to next_admin_mission_submissions_path(@mission.slug) assert @submission.reload.rejected? @@ -76,10 +72,8 @@ class Admin::Missions::SubmissionsControllerTest < ActionDispatch::IntegrationTe sign_in @reviewer Mission::Submission.atomic_claim!(@submission.id, @reviewer) - assert_no_difference -> { @builder.reload.balance } do - patch admin_mission_submission_path(@mission.slug, @submission), - params: { mission_submission: { status: "approved", detach_project: "1" } } - end + patch admin_mission_submission_path(@mission.slug, @submission), + params: { mission_submission: { status: "approved", detach_project: "1" } } assert @submission.reload.approved? assert_equal @mission, @project.reload.current_mission