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..0ea137958 100644 --- a/app/controllers/admin/missions/submissions_controller.rb +++ b/app/controllers/admin/missions/submissions_controller.rb @@ -93,6 +93,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 +106,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" : 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 +287,20 @@ 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. 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! + true + 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..6c037a1d6 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. +

+
+ <% 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..1028ee7b9 100644 --- a/test/controllers/admin/missions/submissions_controller_test.rb +++ b/test/controllers/admin/missions/submissions_controller_test.rb @@ -44,6 +44,41 @@ class Admin::Missions::SubmissionsControllerTest < ActionDispatch::IntegrationTe assert @submission.reload.pending? end + test "plain reject leaves the project attached to the mission" do + sign_in @reviewer + Mission::Submission.atomic_claim!(@submission.id, @reviewer) + + 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 from the mission" do + sign_in @reviewer + Mission::Submission.atomic_claim!(@submission.id, @reviewer) + + 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? + 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) + + 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 + 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)}",