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. +
+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