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/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/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..9c6cbae0c 100644 --- a/app/policies/admin/certification/ysws_policy.rb +++ b/app/policies/admin/certification/ysws_policy.rb @@ -1,10 +1,10 @@ class Admin::Certification::YswsPolicy < ApplicationPolicy def index? - user.admin? || user.has_role?(:guardian_of_integrity) + user.can_review_ysws? end def show? - index? + user.can_review_ysws? && user.can_review_project_category?(record.try(:project)) end def dashboard? @@ -12,10 +12,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 || 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? }, 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)