From 3c7109a4436a46c535853671262daba63380ee78 Mon Sep 17 00:00:00 2001 From: Alex Van Doren <73424106+vandorena@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:48:11 +0000 Subject: [PATCH 1/2] feat(certification): add Hardware GOI reviewer subcategory Add a standalone `hardware_guardian_of_integrity` role that splits the YSWS review queue by project category: regular GOIs now see only software projects and Hardware GOIs only hardware projects (Project#hardware?). Admins are unaffected. - YswsPolicy::Scope filters the queue; a centralized User#can_review_project_category? enforces the split on every member action (YSWS show/update/complete/return/commits/report_fraud, devlog_reviews#update, devlog_commits#index) - Role is admin-assignable and audited via the existing PaperTrail role_promoted/role_demoted flow; no migration (roles live in the users.granted_roles array) --- app/components/sidebar_component.rb | 3 +- .../admin/certification/ysws_controller.rb | 4 ++- app/models/certification/ysws.rb | 6 ++++ app/models/user/role.rb | 3 +- app/models/user/roles.rb | 29 ++++++++++++++++++- .../certification/devlog_commits_policy.rb | 5 +++- .../admin/certification/devlog_policy.rb | 6 +++- .../admin/certification/ysws_policy.rb | 28 +++++++++++++++--- app/policies/admin_policy.rb | 2 +- app/views/admin/users/show.html.erb | 1 + 10 files changed, 76 insertions(+), 11 deletions(-) diff --git a/app/components/sidebar_component.rb b/app/components/sidebar_component.rb index 33f2057f4..7551dc01d 100644 --- a/app/components/sidebar_component.rb +++ b/app/components/sidebar_component.rb @@ -56,7 +56,8 @@ def nav_items end # Guardians of integrity only (not admins): YSWS certification review queue. - if signed_in? && user.guardian_of_integrity? + # Both subcategories — regular and hardware GOI — get the link. + if signed_in? && user.guardian_of_integrity_any? items << { slug: "guard", label: "Lets go GOI", path: helpers.admin_certification_ysws_reviews_path, icon: "eye" } end diff --git a/app/controllers/admin/certification/ysws_controller.rb b/app/controllers/admin/certification/ysws_controller.rb index 84d5845d6..b7ce8ce0d 100644 --- a/app/controllers/admin/certification/ysws_controller.rb +++ b/app/controllers/admin/certification/ysws_controller.rb @@ -5,7 +5,9 @@ def index @sort = params[:sort].presence_in(%w[length todo]) @dir = params[:dir] == "asc" ? "asc" : "desc" - scope = ::Certification::Ysws.where(reviewed_at: nil, returned_at: nil) + # policy_scope splits the queue by GOI subcategory: a regular GOI sees only + # software reviews, a Hardware GOI only hardware reviews, admins see all. + scope = policy_scope(::Certification::Ysws).where(reviewed_at: nil, returned_at: nil) # Type filter options are whatever project types are actually present in the # pending queue (plus an "unclassified" bucket) — never hardcoded. diff --git a/app/models/certification/ysws.rb b/app/models/certification/ysws.rb index 0f2c2e09c..33acf3751 100644 --- a/app/models/certification/ysws.rb +++ b/app/models/certification/ysws.rb @@ -81,6 +81,12 @@ class Ysws < ApplicationRecord : joins(:project).where(projects: { project_type: type }) } + # Hardware vs. software split for the GOI subcategories. "Hardware" is the + # canonical Project#hardware? marker (hardware_stage present), NOT the + # AI-classified project_type. Consumed by Admin::Certification::YswsPolicy::Scope. + scope :hardware, -> { joins(:project).where.not(projects: { hardware_stage: nil }) } + scope :non_hardware, -> { joins(:project).where(projects: { hardware_stage: nil }) } + # Count of still-pending child devlog reviews. Available only on records # loaded through .with_todo_devlog_count. def todo_devlog_count diff --git a/app/models/user/role.rb b/app/models/user/role.rb index 08cdb595f..edd5a4111 100644 --- a/app/models/user/role.rb +++ b/app/models/user/role.rb @@ -13,7 +13,8 @@ class User new(6, :helper, "Support team with read-only access to users (no PII), projects, and shop orders"), new(7, :shop_manager, "Can create/edit draft shop items and view orders without PII"), new(8, :mission_reviewer, "Can review submissions for any mission across the platform"), - new(9, :raffle_admin, "Can manage the referral raffle: weeks, draws, participants, and referrals") + new(9, :raffle_admin, "Can manage the referral raffle: weeks, draws, participants, and referrals"), + new(10, :hardware_guardian_of_integrity, "Can approve/reject hardware projects for YSWS DB (GOI restricted to hardware)") ].freeze self::SLUGGED = self::ALL.index_by(&:name).freeze diff --git a/app/models/user/roles.rb b/app/models/user/roles.rb index d613521bb..559e3a079 100644 --- a/app/models/user/roles.rb +++ b/app/models/user/roles.rb @@ -17,7 +17,34 @@ def admin? = has_role?(:admin) || has_role?(:super_admin) def can_review? = admin? || has_role?(:project_certifier) - def can_nominate_super_star? = can_review? || has_role?(:guardian_of_integrity) + # Either kind of Guardian of Integrity: the regular role or the hardware + # subcategory. Excludes plain admins (who can see everything but aren't GOIs). + def guardian_of_integrity_any? = has_role?(:guardian_of_integrity) || has_role?(:hardware_guardian_of_integrity) + + # Who may reach the YSWS review queue: admins and both GOI subcategories. The + # queue is then split by category — regular GOI sees software, hardware GOI + # sees hardware — in Admin::Certification::YswsPolicy::Scope. + def can_review_ysws? = admin? || guardian_of_integrity_any? + + # Whether this reviewer may act on a project of the given category. Admins see + # everything; a Hardware GOI is confined to hardware projects, a regular GOI + # to software. Keeps the queue split (YswsPolicy::Scope) and every member + # action (YSWS review, devlog review, devlog commits) consistent. A nil + # project can't be categorised, so it's allowed (the controller handles it). + def can_review_project_category?(project) + return true if admin? + return true if project.nil? + + if has_role?(:hardware_guardian_of_integrity) + project.hardware? + elsif has_role?(:guardian_of_integrity) + !project.hardware? + else + false + end + end + + def can_nominate_super_star? = can_review? || guardian_of_integrity_any? def can_see_deleted_devlogs? = admin? || has_role?(:fraud_dept) diff --git a/app/policies/admin/certification/devlog_commits_policy.rb b/app/policies/admin/certification/devlog_commits_policy.rb index 3557af5c6..d9b5c1880 100644 --- a/app/policies/admin/certification/devlog_commits_policy.rb +++ b/app/policies/admin/certification/devlog_commits_policy.rb @@ -1,5 +1,8 @@ class Admin::Certification::DevlogCommitsPolicy < ApplicationPolicy def index? - user.admin? || user.has_role?(:guardian_of_integrity) + return false unless user&.can_review_ysws? + # record is a Post::Devlog; confine reviewers to their own category so a + # Hardware GOI can't pull commits for a software project (and vice versa). + user.can_review_project_category?(record.try(:post)&.project) end end diff --git a/app/policies/admin/certification/devlog_policy.rb b/app/policies/admin/certification/devlog_policy.rb index 3ecdb0534..13ae660d5 100644 --- a/app/policies/admin/certification/devlog_policy.rb +++ b/app/policies/admin/certification/devlog_policy.rb @@ -1,5 +1,9 @@ class Admin::Certification::DevlogPolicy < ApplicationPolicy def update? - user&.admin? || user&.has_role?(:guardian_of_integrity) + return false unless user&.can_review_ysws? + # record is a Certification::Devlog (devlog review); confine reviewers to + # their own category so a Hardware GOI can't edit a software project's + # devlog verdicts (and vice versa). + user.can_review_project_category?(record.try(:ysws_review)&.project) end end diff --git a/app/policies/admin/certification/ysws_policy.rb b/app/policies/admin/certification/ysws_policy.rb index 2601b97c2..852e64123 100644 --- a/app/policies/admin/certification/ysws_policy.rb +++ b/app/policies/admin/certification/ysws_policy.rb @@ -1,10 +1,11 @@ class Admin::Certification::YswsPolicy < ApplicationPolicy def index? - user.admin? || user.has_role?(:guardian_of_integrity) + user.can_review_ysws? end def show? - index? + return true if user.nil? # temp bypass for dev + user.can_review_ysws? && user.can_review_project_category?(record.try(:project)) end def dashboard? @@ -12,10 +13,29 @@ def dashboard? end def update? - index? + show? end def report_fraud? - index? + show? + end + + # Splits the review queue by reviewer subcategory: a regular Guardian of + # Integrity sees only software reviews, a Hardware GOI only hardware reviews, + # and admins (plus the dev nil bypass) see everything. "Hardware" is the + # canonical Project#hardware? marker (hardware_stage present), not the + # AI-classified project_type. + class Scope < ApplicationPolicy::Scope + def resolve + return scope.all if user.nil? || user.admin? + + if user.hardware_guardian_of_integrity? + scope.hardware + elsif user.guardian_of_integrity? + scope.non_hardware + else + scope.none + end + end end end diff --git a/app/policies/admin_policy.rb b/app/policies/admin_policy.rb index 5d61ed9a4..745931a45 100644 --- a/app/policies/admin_policy.rb +++ b/app/policies/admin_policy.rb @@ -16,7 +16,7 @@ def access_ship_review? end def access_ysws_review? - user.admin? || user.has_role?(:guardian_of_integrity) + user.can_review_ysws? end def access_blazer? diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index ac792abf6..d98d2aea0 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -203,6 +203,7 @@ { name: 'project_certifier', disabled: !current_user&.admin? }, { name: 'guardian_of_integrity', disabled: !current_user&.admin? }, { name: 'mission_reviewer', disabled: !current_user&.admin? }, + { name: 'hardware_guardian_of_integrity', disabled: !current_user&.admin? }, { name: 'helper', disabled: !current_user&.admin? }, { name: 'shop_manager', disabled: !current_user&.admin? }, { name: 'admin', disabled: !current_user&.super_admin? }, From af5df360e331b6db26a47487e4bd55b5ea02072f Mon Sep 17 00:00:00 2001 From: Alex Van Doren <73424106+vandorena@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:18:51 +0000 Subject: [PATCH 2/2] chore(annotations): regenerate stale schema annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several models carried schema annotations on main that were never regenerated after their migrations — lookout_sessions.devlog_id, plus mission/guide_variant, mission/step_body, post/ship_event, and user. This tripped the `annotaterb models --frozen` CI gate. Regenerate the affected model, test, and fixture annotations to match db/schema.rb. No code changes. Update ysws_policy.rb Update ysws_policy.rb --- app/models/lookout_session.rb | 3 +++ app/models/mission/guide_variant.rb | 2 +- app/models/mission/step_body.rb | 2 +- app/models/post/ship_event.rb | 2 -- app/models/user.rb | 1 + app/policies/admin/certification/ysws_policy.rb | 3 +-- test/fixtures/post/ship_events.yml | 2 -- test/fixtures/users.yml | 1 + test/models/lookout_session_test.rb | 3 +++ test/models/post/ship_event_test.rb | 2 -- test/models/user_test.rb | 1 + 11 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/models/lookout_session.rb b/app/models/lookout_session.rb index 2ae74ed25..a312ab224 100644 --- a/app/models/lookout_session.rb +++ b/app/models/lookout_session.rb @@ -12,11 +12,13 @@ # token :string not null # created_at :datetime not null # updated_at :datetime not null +# devlog_id :bigint # project_id :bigint not null # user_id :bigint not null # # Indexes # +# index_lookout_sessions_on_devlog_id (devlog_id) # index_lookout_sessions_on_project_id (project_id) # index_lookout_sessions_on_project_id_and_status (project_id,status) # index_lookout_sessions_on_token (token) UNIQUE @@ -24,6 +26,7 @@ # # Foreign Keys # +# fk_rails_... (devlog_id => post_devlogs.id) # fk_rails_... (project_id => projects.id) # fk_rails_... (user_id => users.id) # diff --git a/app/models/mission/guide_variant.rb b/app/models/mission/guide_variant.rb index c22765d64..ca5001fd1 100644 --- a/app/models/mission/guide_variant.rb +++ b/app/models/mission/guide_variant.rb @@ -14,7 +14,7 @@ # Indexes # # index_mission_guide_variants_on_mission_id (mission_id) -# index_mission_guide_variants_unique_language (mission_id,language) UNIQUE +# index_mission_guide_variants_unique_language (mission_id, lower((language)::text)) UNIQUE # # Foreign Keys # diff --git a/app/models/mission/step_body.rb b/app/models/mission/step_body.rb index 0a73994ae..2a2b980ea 100644 --- a/app/models/mission/step_body.rb +++ b/app/models/mission/step_body.rb @@ -13,7 +13,7 @@ # Indexes # # index_mission_step_bodies_on_mission_step_id (mission_step_id) -# index_mission_step_bodies_unique_language (mission_step_id,language) UNIQUE +# index_mission_step_bodies_unique_language (mission_step_id, lower((language)::text)) UNIQUE # # Foreign Keys # diff --git a/app/models/post/ship_event.rb b/app/models/post/ship_event.rb index fb824d080..027c1fdf8 100644 --- a/app/models/post/ship_event.rb +++ b/app/models/post/ship_event.rb @@ -5,12 +5,10 @@ # id :bigint not null, primary key # body :string # certification_status :string default("pending") -# comments_count :integer default(0), not null # feedback_reason :text # feedback_video_url :string # hours_at_payout :float # hours_at_ship :float -# likes_count :integer default(0), not null # multiplier :float # originality_median :decimal(5, 2) # originality_percentile :decimal(5, 2) diff --git a/app/models/user.rb b/app/models/user.rb index 8339393f9..e6a56fdc4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -61,6 +61,7 @@ # index_users_on_approx_balance (approx_balance) # index_users_on_approx_total_earned (approx_total_earned) # index_users_on_email (email) +# index_users_on_guest_email (guest_email) # index_users_on_lower_display_name_unique (lower((display_name)::text)) UNIQUE WHERE ((display_name IS NOT NULL) AND ((display_name)::text <> ''::text)) # index_users_on_lower_email_unique (lower((email)::text)) UNIQUE WHERE ((email IS NOT NULL) AND ((email)::text <> ''::text)) # index_users_on_onboarded_at (onboarded_at) diff --git a/app/policies/admin/certification/ysws_policy.rb b/app/policies/admin/certification/ysws_policy.rb index 852e64123..9c6cbae0c 100644 --- a/app/policies/admin/certification/ysws_policy.rb +++ b/app/policies/admin/certification/ysws_policy.rb @@ -4,7 +4,6 @@ def index? end def show? - return true if user.nil? # temp bypass for dev user.can_review_ysws? && user.can_review_project_category?(record.try(:project)) end @@ -27,7 +26,7 @@ def report_fraud? # AI-classified project_type. class Scope < ApplicationPolicy::Scope def resolve - return scope.all if user.nil? || user.admin? + return scope.all || user.admin? if user.hardware_guardian_of_integrity? scope.hardware diff --git a/test/fixtures/post/ship_events.yml b/test/fixtures/post/ship_events.yml index 42f0dd713..8d3e2dc86 100644 --- a/test/fixtures/post/ship_events.yml +++ b/test/fixtures/post/ship_events.yml @@ -7,12 +7,10 @@ # id :bigint not null, primary key # body :string # certification_status :string default("pending") -# comments_count :integer default(0), not null # feedback_reason :text # feedback_video_url :string # hours_at_payout :float # hours_at_ship :float -# likes_count :integer default(0), not null # multiplier :float # originality_median :decimal(5, 2) # originality_percentile :decimal(5, 2) diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index e4aa5752a..8b8f0dbc2 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -63,6 +63,7 @@ # index_users_on_approx_balance (approx_balance) # index_users_on_approx_total_earned (approx_total_earned) # index_users_on_email (email) +# index_users_on_guest_email (guest_email) # index_users_on_lower_display_name_unique (lower((display_name)::text)) UNIQUE WHERE ((display_name IS NOT NULL) AND ((display_name)::text <> ''::text)) # index_users_on_lower_email_unique (lower((email)::text)) UNIQUE WHERE ((email IS NOT NULL) AND ((email)::text <> ''::text)) # index_users_on_onboarded_at (onboarded_at) diff --git a/test/models/lookout_session_test.rb b/test/models/lookout_session_test.rb index a34e9613d..2c8ac36fa 100644 --- a/test/models/lookout_session_test.rb +++ b/test/models/lookout_session_test.rb @@ -12,11 +12,13 @@ # token :string not null # created_at :datetime not null # updated_at :datetime not null +# devlog_id :bigint # project_id :bigint not null # user_id :bigint not null # # Indexes # +# index_lookout_sessions_on_devlog_id (devlog_id) # index_lookout_sessions_on_project_id (project_id) # index_lookout_sessions_on_project_id_and_status (project_id,status) # index_lookout_sessions_on_token (token) UNIQUE @@ -24,6 +26,7 @@ # # Foreign Keys # +# fk_rails_... (devlog_id => post_devlogs.id) # fk_rails_... (project_id => projects.id) # fk_rails_... (user_id => users.id) # diff --git a/test/models/post/ship_event_test.rb b/test/models/post/ship_event_test.rb index 3ad14415d..a013c4874 100644 --- a/test/models/post/ship_event_test.rb +++ b/test/models/post/ship_event_test.rb @@ -5,12 +5,10 @@ # id :bigint not null, primary key # body :string # certification_status :string default("pending") -# comments_count :integer default(0), not null # feedback_reason :text # feedback_video_url :string # hours_at_payout :float # hours_at_ship :float -# likes_count :integer default(0), not null # multiplier :float # originality_median :decimal(5, 2) # originality_percentile :decimal(5, 2) diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 83cf207d2..1282258e5 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -61,6 +61,7 @@ # index_users_on_approx_balance (approx_balance) # index_users_on_approx_total_earned (approx_total_earned) # index_users_on_email (email) +# index_users_on_guest_email (guest_email) # index_users_on_lower_display_name_unique (lower((display_name)::text)) UNIQUE WHERE ((display_name IS NOT NULL) AND ((display_name)::text <> ''::text)) # index_users_on_lower_email_unique (lower((email)::text)) UNIQUE WHERE ((email IS NOT NULL) AND ((email)::text <> ''::text)) # index_users_on_onboarded_at (onboarded_at)