Skip to content

E2619 - Student Quizzes Backend / (diff solved)#349

Open
atmai0829 wants to merge 2 commits into
expertiza:mainfrom
maibin2004:main
Open

E2619 - Student Quizzes Backend / (diff solved)#349
atmai0829 wants to merge 2 commits into
expertiza:mainfrom
maibin2004:main

Conversation

@atmai0829
Copy link
Copy Markdown

@atmai0829 atmai0829 commented Apr 30, 2026

Implements the server-side quiz workflow for Expertiza. Submitting teams can now author quizzes with per-item correct answers. Reviewers must complete the team's quiz before starting their peer review. Quiz responses are automatically scored at submission time

Summary by CodeRabbit

New Features

  • Added quiz questionnaire creation and management for student assessments
  • Introduced peer review response mapping with quiz gating controls
  • Enhanced response lifecycle management with submit/unsubmit workflows
  • Teams can now link to and manage quiz questionnaires
  • Extended student task data with quiz assignment status tracking

Database Updates

  • New item types and questionnaire types tables added
  • Schema expanded to support quiz items, correct answers, and response tracking

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

📝 Walkthrough

Walkthrough

This PR introduces quiz response functionality and refactors question/item terminology across the application. It adds new API controllers for managing responses, response maps, quiz response maps, and sign-up topics. It extends models to support quiz-specific fields like correct_answer, updates the StudentTask class to include quiz state, and creates database migrations for new tables (ItemType, QuestionnaireType) and columns. The changes include new serializers, a mailer for invitations, and comprehensive test coverage for the new endpoints.

Changes

Cohort / File(s) Summary
API Controllers - Responses & Mapping
app/controllers/responses_controller.rb, app/controllers/response_maps_controller.rb, app/controllers/quiz_response_maps_controller.rb
New endpoints for response lifecycle (create/show/index/update/submit/unsubmit/destroy), peer-review response maps (index/create/destroy), and quiz response maps (create). Includes submission window gating, scoring via aggregate_questionnaire_score, and authorization checks.
API Controllers - Resources
app/controllers/sign_up_topics_controller.rb, app/controllers/questions_controller.rb, app/controllers/items_controller.rb, app/controllers/item_types_controller.rb, app/controllers/questionnaire_types_controller.rb
New endpoints for sign-up topics (CRUD), quiz item type validation with QUIZ_ITEM_TYPES constant, item and item/questionnaire type listing, and quiz types discovery route.
API Controllers - Existing
app/controllers/assignments_controller.rb, app/controllers/questionnaires_controller.rb, app/controllers/teams_controller.rb
Enhanced show with eager-loaded associations, improved error handling in questionnaires#create/update/destroy, nested assignment_questionnaires_attributes support with pre-processing, and new teams#set_quiz_questionnaire endpoint for linking quiz questionnaires.
Models - Core Domain
app/models/item.rb, app/models/questionnaire.rb, app/models/assignment.rb
Item now supports quiz-specific correct_answer field with validation, optional questionnaire reference, and quiz detection. Questionnaire enables nested item attributes, makes instructor optional for Quiz type, and orders items by sequence. Assignment enables nested assignment_questionnaires manipulation.
Models - Response & Mapping
app/models/response.rb, app/models/response_map.rb, app/models/quiz_response_map.rb
Response adds quiz scoring via comments-stored answers, rubric_label computation, and nested score attributes. ResponseMap introduces helper methods for labeling, update link gating, and round inference. QuizResponseMap makes assignment optional.
Models - Supporting
app/models/student_task.rb, app/models/team.rb, app/models/sign_up_topic.rb, app/models/item_type.rb, app/models/questionnaire_type.rb, app/models/concerns/response_map_subclass_titles.rb
StudentTask refactored to carry quiz state and return nil when assignment missing; Team adds optional quiz questionnaire link; SignUpTopic adds associations for teams, questionnaires, due dates; ItemType and QuestionnaireType models with name validation; survey response map title constant added.
Serializers
app/serializers/assignment_serializer.rb, app/serializers/questionnaire_serializer.rb, app/serializers/assignment_questionnaire_serializer.rb
Assignment serializer expands field coverage significantly and includes questionnaire collections. New Questionnaire and AssignmentQuestionnaire serializers format API responses with instructor safety checks and nested relationships.
Email
app/mailers/invitation_sent_mailer.rb, app/views/invitation_sent_mailer/send_invitation_email.html.erb
New mailer for sending team invitations with recipient name interpolation and sender configuration.
Routing & Configuration
config/routes.rb, config/database.yml
Routes file extended with new resources (response_maps, quiz_response_maps, responses), nested item routes, new collection routes (questionnaire_types, item_types, quiz_types), and new member routes (set_quiz_questionnaire, submit/unsubmit). Database config updated to use ENV.fetch with fallback defaults.
Database - Schema & Seeds
db/schema.rb, db/seeds.rb
Schema version bumped; assignments table gains review configuration columns, items table extended with quiz fields (correct_answer, textarea/textbox dimensions, col/row names), teams gains quiz_questionnaire_id, question_types renamed to item_types. Seeds extended to create ItemType and QuestionnaireType records.
Database - Migrations
db/migrate/20250321222753_*, db/migrate/20250418004442_*, db/migrate/202506*.rb, db/migrate/20251013*.rb, db/migrate/20251230*.rb, db/migrate/20260*.rb
Multiple migrations with conditional schema guards: rename sign_up_topic references, polymorphic team associations, add response round/version/item fields, rename question columns to item, add textarea/layout fields, rename question_types table, add correct_answer/quiz_questionnaire_id to items/teams, add missing assignment columns, conditionally add grade fields to participant/team.
Tests - Controllers & Requests
spec/controllers/responses_controller_spec.rb, spec/requests/api/v1/questions_spec.rb, spec/requests/api/v1/quiz_response_maps_controller_spec.rb, spec/requests/api/v1/response_maps_controller_spec.rb, spec/routing/sign_up_topics_routing_spec.rb
Comprehensive specs validating response lifecycle (draft/submitted states, submission windows), quiz type validation and creation, quiz/peer response map creation/error handling, route mappings, and JSON response structures.
Tests - Models
spec/models/item_spec.rb, spec/models/response_map_spec.rb, spec/models/response_map_label_spec.rb, spec/models/response_rubric_label_spec.rb, spec/models/response_spec.rb, spec/models/student_task_spec.rb
Model specs covering optional questionnaire, response map label resolution and update link gating logic, response rubric labeling with fallbacks, quiz scoring with comments-stored answers and case-insensitive type matching, and StudentTask quiz state serialization.
Test Factories & Fixtures
spec/factories/reponse.rb, spec/factories/response_maps.rb, spec/factories/project_topics.rb
New factories for response and response_map, line-ending normalization for project_topics.
API Documentation
swagger/v1/swagger.yaml
OpenAPI spec extended with Student Tasks list/view and Responses API (create/update/delete/submit/unsubmit) endpoints with request/response schemas.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Hop-hops through responses and quizzes so bright,
New maps for reviews, quiz questionnaires in sight!
Items with answers, migrations galore,
StudentTasks dancing with quiz state once more. 🎓✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'E2619 - Student Quizzes Backend / (diff solved)' is partially related to the changeset. It correctly identifies the main feature (Student Quizzes) and the backend scope, but includes unclear noise like '(diff solved)' that doesn't meaningfully convey the actual changes being made. Revise the title to be more specific about the primary change, e.g., 'Implement server-side student quiz workflow with auto-scoring' or 'Add student-created quiz questionnaires with review gating'. Remove the '(diff solved)' suffix as it is vague and adds no value.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Review rate limit: 9/10 reviews remaining, refill in 6 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

4 Warnings
⚠️ Pull request is too big (more than 500 LoC).
⚠️ Pull request touches too many files (more than 30 files).
⚠️ Pull request modifies config or setup files: config/database.yml.
⚠️ RSpec tests seem shallow (single it blocks or no context). Consider improving test structure.

Generated by 🚫 Danger

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/models/Item.rb (1)

5-27: ⚠️ Potential issue | 🟠 Major

Remove optional: true and simplify set_seq to match the database schema.

The schema enforces questionnaire_id NOT NULL, but line 5 sets optional: true on the association. This allows Items to pass model validation and execute callbacks (including the set_seq before_create hook) without a questionnaire, then fail at the database layer. The fallback self.seq ||= 1 on lines 25-26 masks the invalid state rather than preventing it. Since all actual usage requires a questionnaire and the column is NOT NULL, optional: true should be removed and set_seq simplified to assume the questionnaire is always present.

Suggested fix
-  belongs_to :questionnaire, optional: true, inverse_of: :items
+  belongs_to :questionnaire, inverse_of: :items
   def set_seq
-    if questionnaire
-      self.seq ||= questionnaire.items.size + 1
-    else
-      self.seq ||= 1
-    end
+    self.seq ||= questionnaire.items.size + 1
   end

Also update or remove the test at spec/models/item_spec.rb:59-63 that expects Items to be valid without a questionnaire.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/Item.rb` around lines 5 - 27, Remove the permissive association
and the nil-fallback in set_seq: delete the optional: true option on the
belongs_to :questionnaire declaration so Item requires a questionnaire at the
model level, and simplify the set_seq method (used by the before_create
callback) to assume questionnaire is always present (remove the else branch that
sets self.seq ||= 1 and just set self.seq ||= questionnaire.items.size + 1).
Also update or remove the spec/models/item_spec.rb test that asserts an Item can
be valid without a questionnaire.
🟠 Major comments (30)
app/mailers/invitation_sent_mailer.rb-3-10 (1)

3-10: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wire this mailer into the invitation flow (or remove duplication).

Invitation#send_invite_email currently calls InvitationMailer (app/models/invitation.rb, Lines 8-12), so this new InvitationSentMailer path appears unused.

Potential wiring change (outside this file)
-    InvitationMailer.with(invitation: self)
+    InvitationSentMailer.with(invitation: self)
                         .send_invitation_email
                         .deliver_later
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/mailers/invitation_sent_mailer.rb` around lines 3 - 10, You introduced an
unused duplicate mailer InvitationSentMailer while Invitation#send_invite_email
currently calls InvitationMailer; either update Invitation#send_invite_email to
call InvitationSentMailer (pass params[:invitation] and any required locals) and
ensure tests/uses expect that class, or remove InvitationSentMailer and
consolidate its logic into InvitationMailer to eliminate duplication; reference
InvitationSentMailer, InvitationMailer and the Invitation#send_invite_email
method when making the change.
spec/factories/reponse.rb-3-3 (1)

3-3: ⚠️ Potential issue | 🟠 Major

Use a FactoryBot association for map_id instead of hardcoding

Hardcoded map_id { 1 } makes factory validity depend on a ResponseMap with id=1 existing in the test database. Use association :response_map, factory: :response_map to ensure each factory instance creates its own related ResponseMap, making the factory self-contained and test-safe.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/factories/reponse.rb` at line 3, Replace the hardcoded map_id { 1 } in
the Response factory with a FactoryBot association so each Response builds its
own ResponseMap; specifically, remove map_id { 1 } and add association
:response_map, factory: :response_map (or association :response_map if factory
name matches) so the factory sets the ResponseMap/response_map_id automatically
and no longer depends on an existing DB record.
spec/factories/response_maps.rb-3-5 (1)

3-5: ⚠️ Potential issue | 🟠 Major

Use associations instead of hardcoded IDs in the ResponseMap factory

The factory hardcodes reviewed_object_id, reviewer_id, and reviewee_id to 1, which makes it brittle. The model defines belongs_to associations for these foreign keys, and while the current migration doesn't enforce explicit FK constraints, this approach will fail if records with ID 1 don't exist or if FK constraints are added later. Use FactoryBot associations (matching the pattern in participants.rb and assignments.rb) to create linked records instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/factories/response_maps.rb` around lines 3 - 5, The ResponseMap factory
currently hardcodes reviewed_object_id, reviewer_id, and reviewee_id to 1;
replace those hardcoded foreign-key attributes with FactoryBot associations so
the factory builds/creates related records instead (mirror the pattern used in
participants.rb and assignments.rb). Update the factory to declare associations
for :reviewed_object (or the model name used by the belongs_to), :reviewer, and
:reviewee so FactoryBot will generate the linked objects automatically, removing
the brittle reliance on ID 1.
db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb-3-8 (1)

3-8: ⚠️ Potential issue | 🟠 Major

Use explicit up/down methods to restore migration reversibility

This migration is irreversible. Rails' CommandRecorder cannot reverse conditional logic like if column_exists?(...) inside def change. On rollback, the condition fails silently (the renamed column no longer matches the old name check), making the rollback a no-op and leaving the schema inconsistent. Additionally, rename_index should be guarded to handle schema drift gracefully.

Replace def change with explicit up and down methods:

Migration-safe structure
 class RenameSignUpTopicToProjectTopicInSignedUpTeams < ActiveRecord::Migration[8.0]
-  def change
-    if column_exists?(:signed_up_teams, :sign_up_topic_id)
-      rename_column :signed_up_teams, :sign_up_topic_id, :project_topic_id
-      rename_index :signed_up_teams, 
-                   :index_signed_up_teams_on_sign_up_topic_id, 
-                   :index_signed_up_teams_on_project_topic_id
-    end
+  def up
+    return unless column_exists?(:signed_up_teams, :sign_up_topic_id)
+
+    rename_column :signed_up_teams, :sign_up_topic_id, :project_topic_id
+    if index_exists?(:signed_up_teams, :index_signed_up_teams_on_sign_up_topic_id)
+      rename_index :signed_up_teams,
+                   :index_signed_up_teams_on_sign_up_topic_id,
+                   :index_signed_up_teams_on_project_topic_id
+    end
+  end
+
+  def down
+    return unless column_exists?(:signed_up_teams, :project_topic_id)
+
+    rename_column :signed_up_teams, :project_topic_id, :sign_up_topic_id
+    if index_exists?(:signed_up_teams, :index_signed_up_teams_on_project_topic_id)
+      rename_index :signed_up_teams,
+                   :index_signed_up_teams_on_project_topic_id,
+                   :index_signed_up_teams_on_sign_up_topic_id
+    end
   end
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@db/migrate/20250321222753_rename_sign_up_topic_to_project_topic_in_signed_up_teams.rb`
around lines 3 - 8, The migration uses conditional logic inside def change which
makes it irreversible; replace def change with explicit def up and def down
methods in the migration class, move the existing conditional rename logic into
def up (guarding both rename_column :signed_up_teams, :sign_up_topic_id,
:project_topic_id and the associated rename_index call with column_exists? and
index_exists? checks) and implement def down to reverse the operations (guarded
by column_exists? and index_exists?) to safely rename project_topic_id back to
sign_up_topic_id and rename the index back; ensure you reference the exact
symbols rename_column, rename_index, column_exists?, and index_exists? when
locating the code to change.
db/migrate/20260424000001_add_quiz_questionnaire_id_to_teams.rb-7-8 (1)

7-8: ⚠️ Potential issue | 🟠 Major

Add foreign key constraint for quiz_questionnaire_id

The migration adds the column and index but omits the foreign key constraint. Without it, orphan questionnaire IDs can persist if a questionnaire is deleted. This codebase already enforces FK constraints in similar scenarios (e.g., assignments_duties, invitations, question_advices). Add the constraint to match the established pattern and prevent data integrity issues.

Suggested fix
   def change
     add_column :teams, :quiz_questionnaire_id, :integer, null: true, default: nil
     add_index  :teams, :quiz_questionnaire_id
+    add_foreign_key :teams, :questionnaires, column: :quiz_questionnaire_id
   end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/migrate/20260424000001_add_quiz_questionnaire_id_to_teams.rb` around lines
7 - 8, Add a foreign key constraint for teams.quiz_questionnaire_id to enforce
referential integrity: in the migration (where add_column :teams,
:quiz_questionnaire_id and add_index :teams, :quiz_questionnaire_id are defined)
call add_foreign_key :teams, :quiz_questionnaires, column:
:quiz_questionnaire_id, on_delete: :nullify (to clear the FK when the referenced
questionnaire is deleted) so orphan IDs cannot persist; place this call after
the index declaration to match existing patterns like
assignments_duties/invitations/question_advices.
config/database.yml-10-18 (1)

10-18: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Environment DB isolation is broken when DATABASE_URL is missing.

With the current fallback, all three environments resolve to the same reimplementation database (the gsub never applies because fallback has no ?). This can cause destructive cross-environment behavior, especially for test and production fallback paths.

Suggested fix
 development:
   <<: *default
-  url: <%= ENV.fetch('DATABASE_URL', 'mysql2://root:expertiza@127.0.0.1:3307/reimplementation').gsub('?', '_development?') %>
+  url: <%= ENV.fetch('DATABASE_URL_DEVELOPMENT', 'mysql2://root:expertiza@127.0.0.1:3307/reimplementation_development') %>

 test:
   <<: *default
-  url: <%= ENV.fetch('DATABASE_URL', 'mysql2://root:expertiza@127.0.0.1:3307/reimplementation').gsub('?', '_test?') %>
+  url: <%= ENV.fetch('DATABASE_URL_TEST', 'mysql2://root:expertiza@127.0.0.1:3307/reimplementation_test') %>

 production:
   <<: *default
-  url: <%= ENV.fetch('DATABASE_URL', 'mysql2://root:expertiza@127.0.0.1:3307/reimplementation').gsub('?', '_production?') %>
+  url: <%= ENV.fetch('DATABASE_URL') %>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/database.yml` around lines 10 - 18, The fallback DATABASE_URLs
currently lack a '?' so the .gsub('?', '_development?')/.gsub('?',
'_test?')/.gsub('?', '_production?') never runs and all environments point to
the same database; update the three url: fallback strings (the ENV.fetch calls
that are passed to .gsub in the url entries) to include a query separator before
the environment tag (or use distinct fallback URLs per environment) so the gsub
can append the proper _development/_test/_production suffix and ensure
environment DB isolation.
app/models/sign_up_topic.rb-4-5 (1)

4-5: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wire signed_up_teams to project_topic_id.

SignedUpTeam belongs to :project_topic in app/models/signed_up_team.rb:10-11, so this association is pointing at the wrong foreign key. As written, sign_up_topic.signed_up_teams / teams will not resolve the actual join rows, and dependent: :destroy will miss them too.

Suggested fix
 class SignUpTopic < ApplicationRecord
-  has_many :signed_up_teams, foreign_key: 'topic_id', dependent: :destroy
+  has_many :signed_up_teams, foreign_key: 'project_topic_id', dependent: :destroy
   has_many :teams, through: :signed_up_teams # list all teams choose this topic, no matter in waitlist or not
   has_many :assignment_questionnaires, class_name: 'AssignmentQuestionnaire', foreign_key: 'topic_id', dependent: :destroy
   has_many :due_dates, as: :parent,class_name: 'DueDate', dependent: :destroy
   belongs_to :assignment
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/sign_up_topic.rb` around lines 4 - 5, The has_many association in
SignUpTopic is using the wrong foreign key; change the association on
SignUpTopic (the has_many :signed_up_teams declaration) to use foreign_key:
'project_topic_id' so it matches SignedUpTeam's belongs_to :project_topic and
ensures sign_up_topic.signed_up_teams, sign_up_topic.teams and dependent:
:destroy operate on the correct join rows.
app/controllers/sign_up_topics_controller.rb-66-69 (1)

66-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use destroy here; each(&:delete) always looks successful and skips cleanup.

each returns the collection, so this condition is truthy even if the deletes do the wrong thing, and delete bypasses callbacks plus the dependent: :destroy associations on SignUpTopic. That can leave related signed_up_teams, assignment_questionnaires, and due_dates behind.

Suggested fix
-    if `@sign_up_topics.each`(&:delete)
-      render json: { message: "The topic has been deleted successfully. " }, status: :no_content
-    else
-      render json: `@sign_up_topic.errors`, status: :unprocessable_entity
-    end
+    SignUpTopic.transaction do
+      `@sign_up_topics.find_each`(&:destroy!)
+    end
+    head :no_content
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/sign_up_topics_controller.rb` around lines 66 - 69, The code
uses `@sign_up_topics.each`(&:delete) which is always truthy and bypasses
callbacks/dependent destroys; replace this with invoking destroy on each topic
and surface failures: iterate `@sign_up_topics` calling destroy (e.g.,
`@sign_up_topics.each` { |t| t.destroy }), collect any records where t.persisted?
or t.errors.present? (use sign_up_topic.errors) and if none failed render
no_content with success message, otherwise render the collected errors with
status :unprocessable_entity; ensure callbacks and dependent: :destroy run by
using destroy, not delete.
db/seeds.rb-158-176 (1)

158-176: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Seed canonical item-type values, not only display labels.

These rows use labels like "Dropdown" and "Multiple choice", but the rest of the app still operates on machine values like dropdown, multiple_choice, and scale for Item.question_type (spec/models/item_spec.rb Lines 79-109). If the frontend round-trips /item_types data back into item creation/update, the selected name will not map cleanly to the existing strategy dispatch. Please persist/expose a canonical value alongside the label, or normalize at the API boundary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/seeds.rb` around lines 158 - 176, The seed currently creates ItemType
records using only display labels from item_type_names; update the seeding to
persist a canonical machine value for each type so API/DB expose both label and
machine-friendly value (e.g., "Dropdown" → "dropdown") instead of relying only
on ItemType.find_or_create_by!(name: type_name). Modify the seeding logic that
builds item_types (the item_type_names array and the block that calls
ItemType.find_or_create_by!) to set a second attribute (e.g., value or key) with
normalized snake_case values matching Item.question_type (see
spec/models/item_spec.rb usages like dropdown, multiple_choice, scale), ensuring
new records include both the human-readable name and the canonical value used by
the app or normalize incoming API params to map names to these canonical values
at the Item creation/update boundary.
db/seeds.rb-139-177 (1)

139-177: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This lookup seeding is still blocked by the non-idempotent inserts above it.

The new find_or_create_by! blocks are rerunnable, but the earlier Institution.create!, Role.create!, and other plain create!/create calls will still raise on an already-seeded database before Lines 139-177 are reached. That means a second db:seed or a partial seed recovery still won't populate these new lookup tables. Please either make the earlier seed data idempotent too, or move these lookup seeds into their own rerunnable section/task.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/seeds.rb` around lines 139 - 177, The seeding for QuestionnaireType and
ItemType is idempotent (using QuestionnaireType.find_or_create_by! and
ItemType.find_or_create_by!) but earlier non-idempotent inserts like
Institution.create!, Role.create!, and other plain create/create calls will
cause a second db:seed to fail before these lookups run; either make those
earlier inserts idempotent (replace Institution.create!, Role.create!, etc. with
find_or_create_by!/find_or_initialize_by! plus save! or use upsert/upsert_all)
or move the questionnaire and item lookup blocks into a separate rerunnable
seed/task (e.g., separate seed file or rake task) so they can be run
independently—locate the offending calls to Institution.create!, Role.create!,
etc., and update them accordingly or extract the QuestionnaireType/ItemType
block into its own rerunnable seed.
app/models/team.rb-16-18 (1)

16-18: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce that quiz_questionnaire actually references a quiz.

TeamsController#set_quiz_questionnaire currently accepts any existing Questionnaire, and the quiz-response flow later treats reviewee_team.quiz_questionnaire as a quiz unconditionally. That means a review/survey questionnaire can be attached here and break the reviewer gate/scoring flow. Please add a model validation that rejects associated questionnaires whose questionnaire_type != 'Quiz'.

Suggested model-level guard
 class Team < ApplicationRecord
   # E2619: each submitting team owns their quiz questionnaire (set via "Create Quiz" on AssignReviewer page)
   belongs_to :quiz_questionnaire, class_name: 'Questionnaire', foreign_key: 'quiz_questionnaire_id',
              optional: true, inverse_of: false
+  validate :quiz_questionnaire_must_be_quiz
+
+  def quiz_questionnaire_must_be_quiz
+    return if quiz_questionnaire.blank? || quiz_questionnaire.questionnaire_type == 'Quiz'
+
+    errors.add(:quiz_questionnaire, 'must reference a Quiz questionnaire')
+  end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/team.rb` around lines 16 - 18, Add a model-level validation on
Team to ensure the associated quiz_questionnaire is actually a quiz: implement a
custom validator (e.g., validate :quiz_questionnaire_must_be_quiz) that checks
if quiz_questionnaire is present and then rejects the record unless
quiz_questionnaire.questionnaire_type == 'Quiz'; update
TeamsController#set_quiz_questionnaire handling as needed to surface validation
errors rather than silently accepting non-Quiz Questionnaires.
app/controllers/teams_controller.rb-90-94 (1)

90-94: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-quiz questionnaires in set_quiz_questionnaire.

Existence-only validation allows linking arbitrary questionnaire types, which can break quiz-response flow and scoring assumptions.

Suggested fix
-    unless Questionnaire.exists?(id: questionnaire_id)
+    questionnaire = Questionnaire.find_by(id: questionnaire_id)
+    unless questionnaire
       return render json: { error: 'Questionnaire not found' }, status: :not_found
     end
-    `@team.update`!(quiz_questionnaire_id: questionnaire_id)
+    unless %w[Quiz QuizQuestionnaire].include?(questionnaire.questionnaire_type)
+      return render json: { error: 'Questionnaire must be a quiz questionnaire' }, status: :unprocessable_entity
+    end
+    `@team.update`!(quiz_questionnaire_id: questionnaire.id)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/teams_controller.rb` around lines 90 - 94, The current code
only checks existence and may link non-quiz questionnaires; change the
validation in set_quiz_questionnaire to ensure the targeted Questionnaire is of
the quiz type (e.g., use a scope or attribute check such as
Questionnaire.exists?(id: questionnaire_id, kind: 'quiz') or load the record and
assert questionnaire.quiz? before updating). Locate the block that currently
uses Questionnaire.exists?(id: questionnaire_id), replace it with a check that
validates the questionnaire’s quiz type (or load via Questionnaire.find_by(id:
questionnaire_id) and return the same not_found error if nil or not a quiz),
then only call `@team.update`!(quiz_questionnaire_id: questionnaire_id) and render
on success. Ensure the error response remains { error: 'Questionnaire not found'
}/status :not_found or adjust message to indicate invalid quiz type as needed.
app/controllers/items_controller.rb-6-13 (1)

6-13: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove raw item/JSON logging from this request path.

Logging full attributes/payloads can leak quiz answers and creates noisy request-time logs.

Suggested fix
-    Rails.logger.info "Items for Questionnaire #{questionnaire.id}:"
-    questionnaire.items.each do |item|
-      Rails.logger.info item.attributes.inspect
-    end
-
     items_json = questionnaire.items.as_json
-    Rails.logger.info "JSON being rendered: #{items_json.inspect}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/items_controller.rb` around lines 6 - 13, Remove the
request-time raw payload logging in ItemsController where Rails.logger is used
to print questionnaire.items, item.attributes.inspect, and items_json.inspect;
delete those Rails.logger.info lines (or replace them with a single
non-sensitive debug log that does not include item attributes or answers) so the
controller no longer emits full item hashes/JSON for the questionnaire variable
or the items_json local; ensure any remaining logs reference only non-sensitive
identifiers (e.g., questionnaire.id) and not item attributes or payload content.
db/schema.rb-107-126 (1)

107-126: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

These new assignment columns duplicate existing settings.

This migration leaves pairs like show_teammate_reviews/show_teammate_review, enable_pair_programming/is_pair_programming, max_reviews_per_submission/maximum_number_of_reviews_per_submission, review_assignment_strategy/review_strategy, and rounds_of_reviews/number_of_review_rounds in the same table. That creates two sources of truth, and AssignmentsController#assignment_params is already writing the new names. Unless the rest of the model/serializer layer is fully migrated in lockstep, reads and writes will diverge.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/schema.rb` around lines 107 - 126, The migration introduced duplicate
columns (e.g., show_teammate_review vs show_teammate_reviews,
is_pair_programming vs enable_pair_programming,
maximum_number_of_reviews_per_submission vs max_reviews_per_submission,
review_strategy vs review_assignment_strategy, number_of_review_rounds vs
rounds_of_reviews) causing two sources of truth while
AssignmentsController#assignment_params already writes the new names; remove or
consolidate the duplicated columns so only the canonical column names used by
AssignmentsController#assignment_params remain, update any model
attributes/serializers referencing the old names to the canonical names, and
create a migration that backfills values from the old column to the chosen
canonical column (or drops the old columns after backfill) to ensure reads and
writes stay consistent.
app/controllers/questionnaires_controller.rb-33-34 (1)

33-34: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't expose backtraces in API responses.

These lines return raw exception messages and stack frames to the client. That leaks internal implementation details and file paths on every unexpected failure. Log the exception server-side and return a generic 500 payload instead.

Suggested fix
-    rescue => e
-      render json: { error: e.message, backtrace: e.backtrace.take(5) }, status: :internal_server_error
+    rescue StandardError => e
+      Rails.logger.error(e.full_message)
+      render json: { error: 'Failed to create questionnaire' }, status: :internal_server_error
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/questionnaires_controller.rb` around lines 33 - 34, Replace
the current rescue block that renders exception message and backtrace (the lines
with "rescue => e" and "render json: { error: e.message, backtrace:
e.backtrace.take(5) }, status: :internal_server_error") so that the exception is
logged server-side (e.g., Rails.logger.error or logger.error including e.message
and e.backtrace) and the controller returns a generic JSON 500 response such as
render json: { error: "Internal server error" }, status: :internal_server_error;
keep the logging detailed but do not expose e.message or backtrace to the
client.
db/schema.rb-422-422 (1)

422-422: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add an index and foreign key for teams.quiz_questionnaire_id.

This is a new relationship-looking column, but the schema leaves it unchecked and unindexed. That allows dangling questionnaire IDs and makes team→quiz lookups slower as teams grows.

Suggested migration follow-up
+ add_index :teams, :quiz_questionnaire_id
+ add_foreign_key :teams, :questionnaires, column: :quiz_questionnaire_id
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/schema.rb` at line 422, Add an index and a foreign key constraint for the
teams.quiz_questionnaire_id column: create a migration that runs add_index
:teams, :quiz_questionnaire_id (optionally name the index) and add_foreign_key
:teams, :quiz_questionnaires, column: :quiz_questionnaire_id, on_delete:
:restrict (or :nullify if you prefer nulling on delete); before adding the
foreign key, scan/backfill or remove any dangling quiz_questionnaire_id values
in teams so the constraint will apply cleanly.
app/serializers/questionnaire_serializer.rb-6-11 (1)

6-11: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the instructor payload shape stable.

This serializer changes instructor from an object-with-id fallback to either nil or { name, email }. That can break existing clients that dereference questionnaire.instructor.id or assume the key is always an object.

Suggested fix
   def instructor
     inst = object.instructor
-    return nil unless inst
-    { name: inst.name, email: inst.email }
+    return { id: nil, name: nil, email: nil } unless inst
+
+    { id: inst.id, name: inst.name, email: inst.email }
   end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/serializers/questionnaire_serializer.rb` around lines 6 - 11, The
instructor serializer currently returns nil or a {name, email} hash which breaks
clients expecting an object with an id; update the instructor method (in
questionnaire_serializer.rb) to preserve the original payload shape by always
returning an object-like hash for instructor: include id, name and email keys
(use object.instructor.id, .name, .email) and when no instructor exists return
the same object-shaped structure with nil values (e.g. id: nil, name: nil,
email: nil) so existing consumers can safely dereference
questionnaire.instructor.id.
app/controllers/questionnaires_controller.rb-41-44 (1)

41-44: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle destroy! failures explicitly.

Switching to destroy! is good for honoring dependencies, but this action only rescues ActiveRecord::RecordNotFound. If a callback or dependent destroy blocks deletion, Rails will now raise ActiveRecord::RecordNotDestroyed and return a 500 instead of a 422-style API error.

Suggested fix
   def destroy
     begin
       `@questionnaire` = Questionnaire.find(params[:id])
       `@questionnaire.destroy`! # ensures dependent items are removed
     rescue ActiveRecord::RecordNotFound
       render json: $ERROR_INFO.to_s, status: :not_found and return
+    rescue ActiveRecord::RecordNotDestroyed => e
+      render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity and return
     end
   end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/questionnaires_controller.rb` around lines 41 - 44, The
destroy action in QuestionnairesController currently rescues only
ActiveRecord::RecordNotFound but not ActiveRecord::RecordNotDestroyed, so any
callback/dependent failure will raise and return 500; update the begin/rescue
around Questionnaire.find(params[:id]) and `@questionnaire.destroy`! to also
rescue ActiveRecord::RecordNotDestroyed (or capture the exception), and render a
422 Unprocessable Entity response with the failure details (use
exception.record.errors.full_messages or `@questionnaire.errors.full_messages`)
instead of letting it bubble to a 500; keep the RecordNotFound rescue as-is for
404 handling.
app/models/questionnaire.rb-18-19 (1)

18-19: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat QuizQuestionnaire as quiz-owned too.

This exemption only skips instructor_id validation for questionnaire_type == 'Quiz', but the rest of the codebase already treats QuizQuestionnaire as a valid quiz type as well. Those records will still fail validation here, so one of the supported quiz spellings remains broken.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/questionnaire.rb` around lines 18 - 19, The validation currently
exempts only questionnaire_type == 'Quiz', causing QuizQuestionnaire records to
fail; update the presence validation for instructor_id in
app/models/questionnaire.rb to skip when the questionnaire is a quiz by checking
both 'Quiz' and 'QuizQuestionnaire' (e.g., change the unless lambda to test
questionnaire_type.in?(['Quiz','QuizQuestionnaire']) or equivalent) so
QuizQuestionnaire is treated as a quiz-owned type like Quiz.
swagger/v1/swagger.yaml-1932-1954 (1)

1932-1954: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove the duplicate student_tasks path entries.

/student_tasks/list and /student_tasks/view are already declared earlier in this file. Re-declaring them here with different shapes makes the spec ambiguous, and most parsers will keep only one copy, so part of the API contract silently disappears.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@swagger/v1/swagger.yaml` around lines 1932 - 1954, Remove the duplicate
OpenAPI path entries for /student_tasks/list and /student_tasks/view from this
swagger.yaml fragment: locate the second occurrences of the paths named
"/student_tasks/list" and "/student_tasks/view" (the blocks with summary "List
all Student Tasks" and "View a student task") and delete those duplicate path
objects so only the original definitions remain; ensure no other references rely
on these duplicated shapes and run the OpenAPI validator to confirm the spec is
unambiguous.
app/models/response.rb-38-60 (1)

38-60: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This changes the peer baseline from “average of percentages” to “percentage of summed points.”

When peers answer different subsets, total_numerator / total_denominator weights responses with larger maximum_score more heavily. The discrepancy check below is comparing against the mean peer percentage, so this can suppress or trigger notifications incorrectly. Compute each peer’s normalized score first, then average those values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/response.rb` around lines 38 - 60, The current aggregation (using
total_numerator/total_denominator) biases peers with larger maximum_score;
instead, for each response in existing_responses (skipping self via id ==
response.id) compute that peer's normalized score by dividing
response.aggregate_questionnaire_score by response.maximum_score (guarding
against zero max), accumulate those per-peer normalized scores and increment
count, then set average_score = (sum_of_normalized_scores / count). Update the
block that builds total_numerator/total_denominator and the average_score
calculation to use this per-peer normalization approach (referencing
existing_responses, response.aggregate_questionnaire_score,
response.maximum_score, count, and average_score).
app/serializers/assignment_serializer.rb-19-21 (1)

19-21: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preload these associations before using this serializer by default.

Adding assignment_questionnaires, questionnaires, and due_dates here means the default assignment render path now needs those associations eagerly loaded. AssignmentsController#index still does Assignment.all, and show still does Assignment.find(...), so this turns assignment listing into an N+1 query path once there is real data.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/serializers/assignment_serializer.rb` around lines 19 - 21, The
serializer declares has_many :assignment_questionnaires, :questionnaires, and
:due_dates which forces eager loading; fix the N+1 by preloading these
associations where assignments are fetched: update AssignmentsController#index
to use Assignment.includes(:assignment_questionnaires, :questionnaires,
:due_dates).all (or the equivalent scope) and update AssignmentsController#show
to use Assignment.includes(...).find(id) (or use a scope that includes them) so
the AssignmentSerializer can safely rely on those associations without causing
N+1 queries.
swagger/v1/swagger.yaml-1955-2078 (1)

1955-2078: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The responses section no longer matches ResponsesController.

ResponsesController#create looks up map_id/response_map_id, not user_id, questionnaire_id, or content, and it can return 200 when reusing an existing draft. update expects a nested response payload with scores_attributes, while submit/unsubmit do not consume the content body shown here. The unsubmit path should also mirror the real route placeholder style, not a literal :id segment. As written, generated docs/clients will call the wrong contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@swagger/v1/swagger.yaml` around lines 1955 - 2078, The OpenAPI spec for
Responses endpoints is out of sync with ResponsesController: update the POST
/responses requestBody to require map_id/response_map_id (not
user_id/questionnaire_id/content) and reflect that create
(ResponsesController#create) may return 200 when reusing an existing draft in
addition to 201; change the PATCH /responses/{id} requestBody to accept a nested
"response" object with "scores_attributes" (matching update), remove the unused
"content" body from the PATCH /responses/{id}/submit and PATCH
/responses/{id}/unsubmit operations (they do not consume content), and fix the
unsubmit path to use the {id} placeholder (not ":id"); ensure response status
codes mirror the controller (e.g., create 200/201, update 200/403/404/422,
submit/unsubmit codes as implemented).
app/controllers/responses_controller.rb-225-248 (1)

225-248: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Pick the due date for this response type/round, not just the first upcoming date.

Assignments can have multiple deadlines. This method ignores the map type and round and gates submission on whichever due date happens to be upcoming.first or first, so a review can be incorrectly opened or closed based on an unrelated submission/metareview phase.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/responses_controller.rb` around lines 225 - 248, The method
submission_window_open? is currently checking only the first/upcoming due date;
instead pick the due date that matches this response's response_map (round and
map type) before comparing times. Locate response.response_map (and its round
and map_type/type attributes) and assignment.due_dates, then find the specific
due_date where due_date.round == response.response_map.round (and matches the
map_type/deadline_type if your model uses that field) — if none is found treat
as open (return true), otherwise compare that due_date.due_at > Time.current;
apply the same lookup when using due_dates.upcoming or legacy future?
collections so you check the matching round/type entry not just the first
element.
app/controllers/quiz_response_maps_controller.rb-81-94 (1)

81-94: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This idempotent create is still racy.

find_by followed by new/save allows two concurrent requests to create duplicate QuizResponseMaps for the same reviewer/questionnaire pair. If this endpoint can be retried from the UI, protect it with a unique index and switch to find_or_create_by!/create_or_find_by! on that key.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/quiz_response_maps_controller.rb` around lines 81 - 94, The
current find_by + new + save(validate: false) flow for QuizResponseMap is racy
and can produce duplicates under concurrent requests; add a unique DB constraint
on the tuple (reviewed_object_id, reviewer_id, reviewee_id) and replace the
find/new/save sequence with an atomic finder/creator call such as
QuizResponseMap.create_or_find_by!(reviewed_object_id: quiz_questionnaire.id,
reviewer_id: reviewer_participant.id, reviewee_id: reviewer_participant.id) (or
find_or_create_by! depending on Rails version) to rely on the DB uniqueness to
prevent duplicates and surface errors if creation fails.
app/models/student_task.rb-65-78 (1)

65-78: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Do not collapse quiz state to the first review map.

This code picks one arbitrary ReviewResponseMap and treats that team's quiz as the assignment's quiz. That conflicts with the per-map design already documented in app/controllers/response_maps_controller.rb Lines 58-60 ("quiz_taken must be checked per map, not per assignment"). A reviewer with multiple assigned teams can therefore get the wrong quiz_questionnaire_id or appear quiz-complete after finishing only one team's quiz.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/student_task.rb` around lines 65 - 78, The code incorrectly
collapses quiz state by picking the first ReviewResponseMap; instead iterate
over all ReviewResponseMap records for the reviewer
(ReviewResponseMap.where(reviewer_id: participant.id)) and for each map resolve
its team (Team.find_by(id: rm.reviewee_id)) to get that map's
quiz_questionnaire_id, then check quiz_taken per map by querying
QuizResponseMap.where(reviewer_id: participant.id, reviewed_object_id:
quiz_questionnaire_id).joins("INNER JOIN responses ON responses.map_id =
response_maps.id").where(responses: { is_submitted: true }).exists?; replace the
existing single quiz_questionnaire_id/has_quiz/quiz_taken logic with per-map
checks (e.g., produce a hash or list of maps -> { has_quiz:, quiz_taken: } or
otherwise propagate per-map results) so you no longer infer quiz state from the
first map.
app/controllers/response_maps_controller.rb-40-53 (1)

40-53: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope this query to ReviewResponseMap.

ResponseMap.where(reviewer_id: ...) is broader than the endpoint contract here. Filtering out reviewer_id == reviewee_id only removes quiz/self maps; other response-map subclasses can still leak into this payload and be serialized as peer reviews if their reviewed_object_id happens to resolve to an assignment. That will confuse the row-level quiz gating state.

Suggested change
-    map_scope = ResponseMap.where(reviewer_id: participants.map(&:id))
+    map_scope = ReviewResponseMap.where(reviewer_id: participants.map(&:id))
     map_scope = map_scope.where(reviewed_object_id: assignment_id) if assignment_id
     maps = map_scope.to_a
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/response_maps_controller.rb` around lines 40 - 53, The query
currently uses ResponseMap.where(reviewer_id: participants.map(&:id)) which is
too broad and may return non-review maps; change it to explicitly query
ReviewResponseMap (e.g., ReviewResponseMap.where(reviewer_id: ...)) so only peer
review maps are returned, keeping the subsequent guards (map.reviewer_id ==
map.reviewee_id and Assignment.find_by(id: map.reviewed_object_id)) intact;
update any related variable names or comments around map_scope and maps if
needed to reflect that the scope is now specifically ReviewResponseMap.
app/models/response_map.rb-144-160 (1)

144-160: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter to review deadlines when inferring the current round.

assignment.due_dates includes more than review deadlines, but this method treats any dated row with a round as eligible. Assignment#num_review_rounds in app/models/assignment.rb Lines 31-34 already filters on DueDate::REVIEW_DEADLINE_TYPE_ID; this code should do the same or needs_update_link? can advance to a later round off a submission/team deadline and show Update too early.

Suggested change
     due_dates = Array(assignment.due_dates).select do |d|
-      d.respond_to?(:round) && d.round.present? &&
+      d.respond_to?(:deadline_type_id) &&
+        d.deadline_type_id == DueDate::REVIEW_DEADLINE_TYPE_ID &&
+        d.respond_to?(:round) && d.round.present? &&
         d.respond_to?(:due_at) && d.due_at.present?
     end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/response_map.rb` around lines 144 - 160, Filter the due_dates to
only review deadlines before computing the reference round: when building
due_dates from assignment.due_dates, apply the same filter used in
Assignment#num_review_rounds (e.g. select entries where deadline_type_id ==
DueDate::REVIEW_DEADLINE_TYPE_ID) in addition to the existing round/due_at
checks so that the logic in needs_update_link? and the final
reference.round.to_i only considers review deadlines.
app/controllers/response_maps_controller.rb-121-137 (1)

121-137: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the assignment/team pair before creating the map.

This action only checks that the ids are non-zero. As written, it can create an AssignmentParticipant and ReviewResponseMap for a missing assignment, or link a reviewer to a team from a different assignment. That leaves orphaned mappings and bypasses assignment scoping.

Suggested change
+    assignment = Assignment.find_by(id: assignment_id)
+    return render json: { error: 'Assignment not found' }, status: :not_found unless assignment
+
+    reviewee_team = Team.find_by(id: reviewee_team_id, parent_id: assignment_id)
+    return render json: { error: 'Reviewee team not found for assignment' },
+                  status: :unprocessable_entity unless reviewee_team
+
     # Find or create the reviewer's participant record for this assignment
     reviewer_participant = AssignmentParticipant.find_by(user_id: reviewer_user_id, parent_id: assignment_id)
@@
     map = ReviewResponseMap.find_or_create_by!(
       reviewed_object_id: assignment_id,
       reviewer_id:        reviewer_participant.id,
-      reviewee_id:        reviewee_team_id
+      reviewee_id:        reviewee_team.id
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/response_maps_controller.rb` around lines 121 - 137, Before
creating AssignmentParticipant or ReviewResponseMap, verify the assignment
exists and that the reviewee team belongs to that assignment: fetch
Assignment.find_by(id: assignment_id) and fetch the team via Team.find_by(id:
reviewee_team_id, parent_id: assignment_id) (or equivalent) and return/error if
either is nil; only then proceed to create or find AssignmentParticipant
(reviewer_participant) and ReviewResponseMap, ensuring ReviewResponseMap.lookup
uses the validated reviewer_participant.id and the validated reviewee_team_id so
you never create a participant or map for a missing assignment or a team from
another assignment.
app/models/response_map.rb-123-132 (1)

123-132: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle ReviewResponseMap#reviewee when it is already a Team.

For peer reviews, reviewee is a Team (app/models/review_response_map.rb Lines 3-4), so the reviewee.respond_to?(:team) branch never runs. That means teams_participants.updated_at is ignored for the normal review path, and teammate changes will not flip needs_update_link? even though this method says they should.

Suggested change
-    # Check team-related timestamps if the reviewee has a team
-    if reviewee.respond_to?(:team) && reviewee.team
-      team = reviewee.team
+    team =
+      if reviewee.is_a?(Team)
+        reviewee
+      elsif reviewee.respond_to?(:team)
+        reviewee.team
+      end
+
+    # Check team-related timestamps if the reviewee has a team
+    if team
       candidates << team.updated_at if team.respond_to?(:updated_at) && team.updated_at.present?

-    # Also gather timestamps from join records (teams_participants) so collaborator edits count as activity
+      # Also gather timestamps from join records (teams_participants) so collaborator edits count as activity
       if team.respond_to?(:teams_participants)
         team.teams_participants.each do |tp|
           candidates << tp.updated_at if tp.respond_to?(:updated_at) && tp.updated_at.present?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/response_map.rb` around lines 123 - 132, The code only checks
reviewee.respond_to?(:team) so when reviewee is already a Team (peer reviews)
its teams_participants timestamps are skipped; update the logic in
ReviewResponseMap (where reviewee is accessed) to treat a reviewee that is a
Team the same as reviewee.team—e.g. resolve a local team variable with team =
(reviewee.respond_to?(:team) && reviewee.team) ||
(reviewee.respond_to?(:teams_participants) ? reviewee : nil) and then collect
team.updated_at and team.teams_participants.each { |tp| tp.updated_at } if
present; ensure this change is used by needs_update_link? so teammate edits are
considered for peer reviews.
🟡 Minor comments (1)
app/models/student_task.rb-55-57 (1)

55-57: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard participant before dereferencing it.

from_participant_id passes Participant.find_by(id: id) into this method, so an unknown id currently raises on Line 56 instead of returning nil as the method contract says.

Suggested change
   def self.create_from_participant(participant)
+    return nil if participant.nil?
+
     asgn = participant.assignment
     return nil if asgn.nil?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/student_task.rb` around lines 55 - 57, The method
create_from_participant should guard against a nil participant before
dereferencing it: add an early return (e.g., return nil) at the top of
create_from_participant if participant.nil? so that when from_participant_id
passes Participant.find_by(id: id) the method honors its contract and returns
nil instead of raising; update the create_from_participant method (and any
callers like from_participant_id) to check participant.nil? before using
participant.assignment.
🧹 Nitpick comments (7)
spec/factories/project_topics.rb (1)

1-9: 💤 Low value

LGTM — factory logic unchanged; minor EOF newline nit.

The :project_topic factory definition looks correct (sequences/attributes/association intact). If this PR only normalized CRLF→LF and removed the trailing newline at EOF, consider re-adding the final newline to keep POSIX/tooling/lint expectations consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/factories/project_topics.rb` around lines 1 - 9, The file currently
lacks a trailing newline at EOF; open the spec/factories file containing the
factory :project_topic (the block that defines factory :project_topic and
sequences like sequence(:topic_name) and sequence(:topic_identifier)) and add a
single final newline character at the end of the file so the file ends with a
newline to satisfy POSIX/tooling/lint expectations.
app/mailers/invitation_sent_mailer.rb (1)

4-4: ⚡ Quick win

Use environment-configured sender instead of a hardcoded placeholder.

Line 4 hardcodes from@example.com; prefer app credentials/config so non-dev environments don’t send with a placeholder address.

Proposed refactor
-  default from: 'from@example.com'
+  default from: Rails.application.credentials.dig(:mail, :from_address) || ENV.fetch('MAIL_FROM_ADDRESS')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/mailers/invitation_sent_mailer.rb` at line 4, The mailer currently
hardcodes the sender in the InvitationSentMailer via "default from:
'from@example.com'"; replace that with an environment/credentials-backed value
(for example use Rails.application.credentials.dig(:email, :default_from) or
ENV['DEFAULT_FROM_EMAIL']) and update the default declaration in
InvitationSentMailer to reference that config value so non-dev environments use
the proper sender address.
app/models/questionnaire_type.rb (1)

2-3: Add a unique database index on questionnaire_types.name.

The model validation validates :name, presence: true, uniqueness: true is not race-safe. Concurrent inserts can still create duplicate lookup rows that will be exposed by the /questionnaire_types endpoint. Add add_index :questionnaire_types, :name, unique: true to the migration to enforce this at the database level.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/questionnaire_type.rb` around lines 2 - 3, The uniqueness
validation on QuestionnaireType (validates :name, presence: true, uniqueness:
true) is not race-safe; add a unique DB constraint by creating a migration that
runs add_index :questionnaire_types, :name, unique: true (or alter the existing
migration if it hasn't been run) so the database enforces uniqueness; name the
migration clearly (e.g., add_unique_index_to_questionnaire_types_name),
run/rollback and migrate to apply, and ensure schema.rb is updated so the
database and the model validation stay consistent.
app/controllers/questions_controller.rb (1)

41-47: ⚡ Quick win

Use a single source of truth for quiz item types.

This allowlist is duplicated from Item::QUIZ_ITEM_TYPES; drift here can create hard-to-debug mismatches between create validation and model behavior.

Suggested refactor
-  QUIZ_ITEM_TYPES = %w[TextField MultipleChoiceRadio MultipleChoiceCheckbox Scale Checkbox].freeze
+  QUIZ_ITEM_TYPES = Item::QUIZ_ITEM_TYPES
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/questions_controller.rb` around lines 41 - 47, The
QUIZ_ITEM_TYPES constant in QuestionsController duplicates
Item::QUIZ_ITEM_TYPES; remove the duplicated %w[...] constant and replace all
uses of QUIZ_ITEM_TYPES in this controller with the canonical
Item::QUIZ_ITEM_TYPES (or delegate a private method like quiz_item_types that
returns Item::QUIZ_ITEM_TYPES) so the controller uses a single source of truth
(reference symbols: QUIZ_ITEM_TYPES, Item::QUIZ_ITEM_TYPES,
QuestionsController).
spec/controllers/responses_controller_spec.rb (1)

192-212: ⚡ Quick win

Assert the auto-scoring side effect in submit.

This example stubs aggregate_questionnaire_score to 42, but never checks that the first PATCH actually persisted or returned that score. Since automatic scoring is one of this PR’s headline behaviors, I'd add an expectation on the saved attribute or response payload here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/controllers/responses_controller_spec.rb` around lines 192 - 212, The
test stubs aggregate_questionnaire_score to return 42 but never asserts that the
score was applied on the first PATCH; update the spec to verify the auto-scoring
side effect after the first patch by either parsing the first response payload
or confirming the double was saved with the score: call patch :submit, params: {
id: 1 } then parse JSON.parse(response.body) and expect the returned payload to
include the aggregate score (e.g. ['aggregate_questionnaire_score'] == 42), or
change the response_double.as_json stub to include the score and assert that
as_json contains 42; you can also add an expectation that
response_double.aggregate_questionnaire_score was invoked and that
response_double.save was called after setting is_submitted via is_submitted=.
spec/requests/api/v1/response_maps_controller_spec.rb (2)

103-112: ⚡ Quick win

Actually assert that quiz maps are excluded.

This example proves review_map is returned, but it never checks that quiz_map.id is absent. It would still pass if the endpoint returned both entries, which is the regression this case is supposed to catch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/requests/api/v1/response_maps_controller_spec.rb` around lines 103 -
112, The test currently asserts that review_map.id is present but does not check
quiz_map is excluded; update the example in response_maps_controller_spec.rb
(the response(200, 'returns peer-review maps excluding quiz maps') block) to
parse response.body into maps (already stored in maps) and add an assertion like
expect(maps.map { |m| m['id'] }).not_to include(quiz_map.id) so the spec fails
if quiz maps are returned; keep the existing checks for review_map.id and the
Array type.

211-235: ⚡ Quick win

Tighten the idempotency check to prove reuse.

Asserting that the response id is an integer does not prove the existing map was reused. This should also verify that the returned id matches the pre-created map and that the (reviewed_object_id, reviewer_id, reviewee_id) tuple still exists only once after the request.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/requests/api/v1/response_maps_controller_spec.rb` around lines 211 -
235, The test currently only asserts the response id is an Integer; update the
before block to capture the pre-created ReviewResponseMap (use the
ReviewResponseMap.find_or_create_by! call result into a local variable or let,
e.g. precreated_map) and then in the run_test! block assert that
JSON.parse(response.body)['id'] equals precreated_map.id and that
ReviewResponseMap.where(reviewed_object_id: assignment.id, reviewer_id:
precreated_map.reviewer_id, reviewee_id: precreated_map.reviewee_id).count == 1
to ensure the tuple was reused and not duplicated.

Comment on lines +30 to +36
# When the frontend sends assignment_questionnaires_attributes without ids,
# clear existing records first to avoid duplicates.
if params[:assignment]&.key?(:assignment_questionnaires_attributes)
incoming = params[:assignment][:assignment_questionnaires_attributes]
has_ids = incoming.is_a?(Array) && incoming.any? { |aq| aq[:id].present? }
assignment.assignment_questionnaires.destroy_all unless has_ids
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "assignments_controller.rb" | head -5

Repository: expertiza/reimplementation-back-end

Length of output: 123


🏁 Script executed:

git ls-files | grep -E "(assignments_controller|assignment.*spec)" | head -20

Repository: expertiza/reimplementation-back-end

Length of output: 338


🏁 Script executed:

wc -l app/controllers/assignments_controller.rb

Repository: expertiza/reimplementation-back-end

Length of output: 125


🏁 Script executed:

head -60 app/controllers/assignments_controller.rb | cat -n

Repository: expertiza/reimplementation-back-end

Length of output: 2426


🏁 Script executed:

grep -n "assignment_questionnaires_attributes" test/controllers/assignments_controller_test.rb spec/requests/api/v1/assignment_controller_spec.rb 2>/dev/null | head -20

Repository: expertiza/reimplementation-back-end

Length of output: 61


🏁 Script executed:

grep -r "assignment_questionnaires_attributes" spec/ test/ --include="*.rb" | head -15

Repository: expertiza/reimplementation-back-end

Length of output: 61


🏁 Script executed:

sed -n '26,43p' app/controllers/assignments_controller.rb | cat -n

Repository: expertiza/reimplementation-back-end

Length of output: 918


🏁 Script executed:

grep -n "accepts_nested_attributes_for" app/models/assignment.rb

Repository: expertiza/reimplementation-back-end

Length of output: 162


🏁 Script executed:

cat app/models/assignment.rb | grep -A 10 "accepts_nested_attributes_for"

Repository: expertiza/reimplementation-back-end

Length of output: 892


🏁 Script executed:

grep -n "def assignment_params" app/controllers/assignments_controller.rb -A 15

Repository: expertiza/reimplementation-back-end

Length of output: 501


🏁 Script executed:

cat test/controllers/assignments_controller_test.rb | head -100

Repository: expertiza/reimplementation-back-end

Length of output: 253


🏁 Script executed:

wc -l spec/requests/api/v1/assignment_controller_spec.rb

Repository: expertiza/reimplementation-back-end

Length of output: 134


🏁 Script executed:

head -100 spec/requests/api/v1/assignment_controller_spec.rb | cat -n

Repository: expertiza/reimplementation-back-end

Length of output: 4129


🏁 Script executed:

grep -n "assignment_questionnaires" spec/requests/api/v1/assignment_controller_spec.rb | head -20

Repository: expertiza/reimplementation-back-end

Length of output: 61


🏁 Script executed:

sed -n '200,300p' spec/requests/api/v1/assignment_controller_spec.rb | cat -n

Repository: expertiza/reimplementation-back-end

Length of output: 4223


🏁 Script executed:

grep -n "patch\|put" spec/requests/api/v1/assignment_controller_spec.rb -i | head -20

Repository: expertiza/reimplementation-back-end

Length of output: 324


🏁 Script executed:

sed -n '130,180p' spec/requests/api/v1/assignment_controller_spec.rb | cat -n

Repository: expertiza/reimplementation-back-end

Length of output: 2190


🏁 Script executed:

grep -rn "assignment_questionnaires_attributes" . --include="*.rb" --include="*.json"

Repository: expertiza/reimplementation-back-end

Length of output: 615


🏁 Script executed:

sed -n '224,250p' app/controllers/assignments_controller.rb | cat -n

Repository: expertiza/reimplementation-back-end

Length of output: 990


🏁 Script executed:

web_search
How do Rails nested attributes arrive in params - Array or Hash?

Repository: expertiza/reimplementation-back-end

Length of output: 172


🏁 Script executed:

grep -rn "assignment_questionnaires" . --include="*.js" --include="*.jsx" --include="*.ts" | head -20

Repository: expertiza/reimplementation-back-end

Length of output: 61


🌐 Web query:

Rails nested attributes params format Array vs Hash ActionController::Parameters

💡 Result:

In Ruby on Rails, nested attributes for models using accepts_nested_attributes_for (typically for has_many associations) expect parameters in a specific format within ActionController::Parameters. From HTML forms using fields_for, Rails generates params as a hash with string integer keys (e.g., {"0" => {...}, "1" => {...}}), not a true array. This represents an indexed collection suitable for HTTP form submissions. ActiveRecord accepts both formats for nested attributes: - Hash of hashes: posts_attributes: {"0" => {title: "A"}, "1" => {title: "B"}} - Array of hashes: posts_attributes: [{title: "A"}, {title: "B"}] The keys in the hash form are ignored (except 'id' keys trigger special array wrapping), converting to an array internally. For strong parameters with permit (traditional): - Use array syntax: params.require(:post).permit(:title, posts_attributes: [:body, :id, :_destroy]) - This permits both hash (with numeric string keys from forms) and array formats. The rule of thumb: "To permit a hash, pass an array" applies here—the permit arg [:body] permits each nested hash's permitted keys regardless of outer structure. For newer expect (Rails 7+ preferred, stricter): - For arrays of objects: params.expect(post: [:title, posts_attributes: [[:body]]]) (double brackets) - Single bracket [:body] may strip nested hash from fields_for; double [[:body]] explicitly expects array of hashes. Example form-generated params: { post: { title: "Test", comments_attributes: { "0" => {body: "First"}, "1" => {body: "Second"} } } } Both permit and expect with correct syntax pass this to the model successfully. APIs may send JSON arrays directly. Sources confirm this dual acceptance (Rails API docs) and form generation (Guides). As of Rails 8 (2026), behavior unchanged.

Citations:


🏁 Script executed:

grep -rn "assignment_questionnaires" app/models/ --include="*.rb" -B 2 -A 2

Repository: expertiza/reimplementation-back-end

Length of output: 2750


🏁 Script executed:

python3 << 'EOF'
# Let's trace what types params can be when nested attributes are sent
import json

# When form-generated params arrive in Rails:
form_params = {
    "assignment": {
        "assignment_questionnaires_attributes": {
            "0": {"id": 1, "questionnaire_id": 5},
            "1": {"id": 2, "questionnaire_id": 6}
        }
    }
}

# When JSON API sends it:
api_params = {
    "assignment": {
        "assignment_questionnaires_attributes": [
            {"id": 1, "questionnaire_id": 5},
            {"id": 2, "questionnaire_id": 6}
        ]
    }
}

print("Form-generated (Hash):")
incoming_form = form_params["assignment"]["assignment_questionnaires_attributes"]
print(f"  Type: {type(incoming_form)}")
print(f"  is_a?(Array): {isinstance(incoming_form, list)}")
print(f"  Values exist with ids: {any(v.get('id') for v in incoming_form.values() if isinstance(v, dict))}")

print("\nJSON API (Array):")
incoming_api = api_params["assignment"]["assignment_questionnaires_attributes"]
print(f"  Type: {type(incoming_api)}")
print(f"  is_a?(Array): {isinstance(incoming_api, list)}")
print(f"  Values exist with ids: {any(v.get('id') for v in incoming_api if isinstance(v, dict))}")
EOF

Repository: expertiza/reimplementation-back-end

Length of output: 270


🏁 Script executed:

grep -rn "assignment_questionnaires_attributes" . --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null | head -20

Repository: expertiza/reimplementation-back-end

Length of output: 61


🏁 Script executed:

find . -type f -name "*.ts" -o -name "*.tsx" -o -name "*.js" | xargs grep -l "assignment" | grep -i "form\|edit" | head -5

Repository: expertiza/reimplementation-back-end

Length of output: 61


🏁 Script executed:

sed -n '26,43p' app/controllers/assignments_controller.rb | cat -n

Repository: expertiza/reimplementation-back-end

Length of output: 918


Fix destructive preprocessing logic to handle both Hash and Array nested attribute formats.

The type check on line 34 only recognizes Array input, but Rails commonly sends nested attributes as an indexed Hash (e.g., from HTML forms: {"0" => {...}, "1" => {...}}). In that case, has_ids is false even when IDs are present, triggering unwanted deletion of all existing questionnaires. Additionally, because deletion occurs before the update call on line 38, any subsequent validation failure leaves the assignment permanently without its questionnaires.

Suggested fix
     if params[:assignment]&.key?(:assignment_questionnaires_attributes)
       incoming = params[:assignment][:assignment_questionnaires_attributes]
-      has_ids = incoming.is_a?(Array) && incoming.any? { |aq| aq[:id].present? }
-      assignment.assignment_questionnaires.destroy_all unless has_ids
+      incoming_values =
+        case incoming
+        when Array
+          incoming
+        when ActionController::Parameters, Hash
+          incoming.values
+        else
+          []
+        end
+
+      has_ids = incoming_values.any? { |aq| aq[:id].present? || aq['id'].present? }
+
+      Assignment.transaction do
+        assignment.assignment_questionnaires.destroy_all unless has_ids
+        raise ActiveRecord::Rollback unless assignment.update(assignment_params)
+      end
     end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/assignments_controller.rb` around lines 30 - 36, The current
preprocessing treats incoming nested attributes as Array only, causing false
negatives when Rails sends an indexed Hash and leading to premature destroy_all
on assignment.assignment_questionnaires; update the logic to normalize incoming
= params[:assignment][:assignment_questionnaires_attributes] into a values array
(incoming.is_a?(Hash) ? incoming.values : incoming) and compute has_ids by
checking both string and symbol keys (e.g., any? { |aq| aq[:id].present? ||
aq['id'].present? }), and move the destructive
assignment.assignment_questionnaires.destroy_all so it runs only after a
successful assignment.update(...) (or wrap both in a transaction) to avoid
losing questionnaires on validation failure.

Comment on lines +11 to +14
items_json = questionnaire.items.as_json
Rails.logger.info "JSON being rendered: #{items_json.inspect}"

render json: items_json
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Do not expose quiz correct_answer in the index response.

This endpoint currently serializes items directly, which can reveal answer keys for quiz items and undermine quiz gating.

Suggested fix
-    items_json = questionnaire.items.as_json
+    items_json = questionnaire.items.map do |item|
+      item.as_json.except('correct_answer')
+    end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
items_json = questionnaire.items.as_json
Rails.logger.info "JSON being rendered: #{items_json.inspect}"
render json: items_json
items_json = questionnaire.items.map do |item|
item.as_json.except('correct_answer')
end
Rails.logger.info "JSON being rendered: #{items_json.inspect}"
render json: items_json
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/items_controller.rb` around lines 11 - 14, The index action
is serializing questionnaire.items (items_json) and exposing sensitive quiz
fields like correct_answer; update the serialization to exclude or redact that
attribute before logging and rendering by either mapping questionnaire.items to
a safe hash (e.g., items = questionnaire.items.map { |i|
i.as_json.except('correct_answer') } or using a serializer that omits
:correct_answer) and change the Rails.logger.info to log the redacted items
variable, then render json: items instead of items_json; ensure the attribute
name correct_answer is referenced so the filter covers it consistently.

Comment on lines +48 to +61
if reviewee_team_id.present? && !reviewee_team_id.zero?
reviewee_team = Team.find_by(id: reviewee_team_id)
else
# Fall back: find the review map for this reviewer on this assignment. find_by returns
# only one record — if a student reviews multiple teams this is ambiguous, which is why
# callers should always supply reviewee_team_id.
reviewer_participant_lookup = AssignmentParticipant.find_by(user_id: reviewer_user_id, parent_id: assignment_id)
review_map = ReviewResponseMap.find_by(reviewer_id: reviewer_user_id, reviewed_object_id: assignment_id)
unless review_map
review_map = ReviewResponseMap.find_by(reviewer_id: reviewer_participant_lookup&.id, reviewed_object_id: assignment_id)
end
reviewee_team = review_map ? Team.find_by(id: review_map.reviewee_id) : nil
end
quiz_questionnaire = reviewee_team&.quiz_questionnaire
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Scope reviewee_team_id to the assignment before using its quiz.

Team.find_by(id: reviewee_team_id) accepts any team in the system. A caller can pair an assignment_id from one assignment with a reviewee_team_id from another and get a quiz map for the wrong team’s questionnaire. Resolve the team through the assignment before reading quiz_questionnaire.

🧰 Tools
🪛 RuboCop (1.86.1)

[convention] 56-58: Use the double pipe equals operator ||= instead.

(Style/OrAssignment)


[convention] 57-57: Line is too long. [127/120]

(Layout/LineLength)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/quiz_response_maps_controller.rb` around lines 48 - 61, When
resolving reviewee_team (used later to read quiz_questionnaire), scope the Team
lookup to the assignment by including the assignment identifier: replace
Team.find_by(id: reviewee_team_id) with a lookup that also matches the
assignment (e.g., Team.find_by(id: reviewee_team_id, parent_id: assignment_id))
and likewise when deriving reviewee_team from review_map use Team.find_by(id:
review_map.reviewee_id, parent_id: assignment_id); update the branch that falls
back to review_map so both code paths only return a team that belongs to the
given assignment before accessing quiz_questionnaire.

Comment on lines +8 to +10
case action_name
when 'create', 'show', 'index'
true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

create, show, and index are currently readable by any caller who can hit the endpoint.

These branches return true without checking response/map ownership or teaching-staff privileges. That means a user who can guess a map_id or response id can open someone else’s draft or read their scores/comments through show and index.

🧰 Tools
🪛 RuboCop (1.86.1)

[warning] 10-10: Literal true used in void context.

(Lint/Void)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/responses_controller.rb` around lines 8 - 10, The
create/show/index branches on action_name currently return true and bypass
ownership/privilege checks; update ResponsesController so these actions enforce
authorization by verifying that the current_user either owns the target
Map/Response or has teaching-staff privileges before allowing access. Locate the
logic that switches on action_name (the when 'create', 'show', 'index' branch)
and replace the unconditional true with calls to your existing authorization
helpers (or a new before_action) that: for create check current_user owns the
Map with the provided map_id, and for show/index check current_user owns the
Response(s) or is teaching staff; ensure you reference the same identifiers
(action_name, map_id, Response model, and any teaching_staff role/method) so the
permission checks are applied consistently.

Comment on lines +150 to +156
def response_params
params.require(:response).permit(
:map_id,
:is_submitted,
:submitted_at,
scores_attributes: [:id, :item_id, :answer, :comments]
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Don't let update mutate workflow-owned fields.

Permitting map_id, is_submitted, and submitted_at lets a reviewer who owns any draft rebind it to a different map or mark it submitted via PATCH /responses/:id, bypassing the deadline and scoring logic in submit. This payload should be limited to answer content only.

🧰 Tools
🪛 RuboCop (1.86.1)

[convention] 155-155: Use %i or %I for an array of symbols.

(Style/SymbolArray)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/responses_controller.rb` around lines 150 - 156, The strong
params in response_params currently permit workflow-owned fields (map_id,
is_submitted, submitted_at) which allows PATCH /responses/:id to rebind or
submit a response; remove those keys so response_params only permits answer
content (e.g. scores_attributes: [:id, :item_id, :answer, :comments]) and ensure
any map_id or submission state changes are handled only in the create/submit
flow or dedicated controller methods (not in update) so that update (and any use
of response_params in the update action) cannot mutate workflow-owned fields.

Comment on lines +7 to +8
@to_user = User.find(@invitation.to_id)
@from_user = User.find(@invitation.from_id)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Resolve participant/user ID mismatch in recipient lookup.

Lines 7-8 treat Invitation.to_id/from_id as User IDs, but they are AssignmentParticipant IDs (app/models/invitation.rb, Lines 6-7). This can resolve the wrong user or fail.

Proposed fix
-    `@to_user` = User.find(`@invitation.to_id`)
-    `@from_user` = User.find(`@invitation.from_id`)
+    `@to_user` = `@invitation.to_participant.user`
+    `@from_user` = `@invitation.from_participant.user`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@to_user = User.find(@invitation.to_id)
@from_user = User.find(@invitation.from_id)
`@to_user` = `@invitation.to_participant.user`
`@from_user` = `@invitation.from_participant.user`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/mailers/invitation_sent_mailer.rb` around lines 7 - 8, The mailer is
looking up Users using Invitation.to_id/from_id but those IDs are
AssignmentParticipant IDs; change the lookup in invitation_sent_mailer (where
`@to_user` and `@from_user` are set) to first fetch the AssignmentParticipant
records via AssignmentParticipant.find(`@invitation.to_id`) and
AssignmentParticipant.find(`@invitation.from_id`) and then get the associated User
(e.g., via .user or .user_id + User.find) to assign `@to_user` and `@from_user` so
the mailer sends to the correct users.

Comment thread app/models/response.rb
Comment on lines +107 to +123
# E2619: quiz maps are identified by reviewer_id == reviewee_id (the student reviews
# themselves). The response_maps table has no STI type column so is_a?(QuizResponseMap)
# always returns false; this is the only reliable discriminator.
is_quiz = map.reviewer_id == map.reviewee_id
# E2619: The frontend stores question types with spaces ("Text field", "Multiple choice",
# "Multiple choice checkbox"). Include both the spaced and CamelCase variants so scoring
# works regardless of which convention was used when the quiz was created.
comment_scored_types = %w[
TextField MultipleChoiceRadio MultipleChoiceCheckbox
Text\ field Multiple\ choice Multiple\ choice\ checkbox
].freeze
scores.each do |s|
# For quiz responses, the weights will be 1 or 0, depending on if correct
sum += s.answer * s.item.weight unless s.answer.nil? #|| !s.item.scorable?
# E2619: TextField, MultipleChoiceRadio, and MultipleChoiceCheckbox quiz items put
# the student's selected/typed answer into the comments column (answer is null).
# Score them by case-insensitive equality against item.correct_answer.
if is_quiz && comment_scored_types.include?(s.item.question_type)
correct = s.item.correct_answer.to_s.strip.downcase
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Don't infer quiz mode from reviewer_id == reviewee_id.

On normal review maps those ids come from different tables (AssignmentParticipant vs Team), so equal numeric values are possible. When that happens, an ordinary peer review will be scored as a quiz. While iterating scores, the reliable signal is the item/questionnaire itself (s.item.is_quiz_item? or s.item.questionnaire.questionnaire_type), not cross-table id equality.

Also applies to: 151-160

🧰 Tools
🪛 RuboCop (1.86.1)

[convention] 114-117: Use an array literal [...] for an array of words.

(Style/WordArray)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/response.rb` around lines 107 - 123, Don't infer quiz mode from
map.reviewer_id == map.reviewee_id; instead detect quiz items from the
item/questionnaire itself. Remove or stop using the is_quiz boolean derived from
map.reviewer_id/map.reviewee_id and change the scoring condition to use
s.item.is_quiz_item? (or check s.item.questionnaire.questionnaire_type == 'Quiz'
if is_quiz_item? is unavailable). Update the check that currently reads
comment_scored_types.include?(s.item.question_type) && is_quiz to use
s.item.is_quiz_item? && comment_scored_types.include?(s.item.question_type) (or
the questionnaire_type-based equivalent), and apply the same replacement to the
other similar block mentioned (the second occurrence around the later scoring
logic).

</head>

<body>
<h1>You have been invited to join the team by <%= @to_user.fullname %></h1>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix inviter interpolation and attribute name in the email body.

Line 7 uses @to_user.fullname; User exposes full_name, and this sentence should show the inviter (@from_user). Current code can fail at render time and shows the wrong person.

Proposed fix
-  <h1>You have been invited to join the team by <%= `@to_user.fullname` %></h1>
+  <h1>You have been invited to join the team by <%= `@from_user.full_name` %></h1>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<h1>You have been invited to join the team by <%= @to_user.fullname %></h1>
<h1>You have been invited to join the team by <%= `@from_user.full_name` %></h1>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/invitation_sent_mailer/send_invitation_email.html.erb` at line 7,
The template uses the wrong variable and attribute — change the interpolation
from `@to_user.fullname` to use the inviter `@from_user` and the correct attribute
name full_name (i.e. replace `@to_user.fullname` with `@from_user.full_name`) in
send_invitation_email.html.erb so the email shows the inviter and doesn't error
at render time.

Comment thread config/routes.rb
Comment on lines +84 to +85
get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all'
delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Ruby on Rails routing, is to: 'questions#show_all#questionnaire'valid, or must the route target be exactlycontroller#actionwith a single#?

💡 Result:

In Rails routing, to: 'questions#show_all#questionnaire' is not valid for mapping to a controller action. The to: string form expects exactly controller#action (a single # separating controller and action).

Citations:


These route targets use invalid Rails action paths.

Rails routes expect controller#action with a single #. The routes on lines 84-85 include a second #:

  • to:'questions#show_all#questionnaire' should likely be to:'questions#show_all' or to:'questions#questionnaire'
  • to:'questions#delete_all#questionnaire' should likely be to:'questions#delete_all' or to:'questions#questionnaire'

These will fail to dispatch to any controller action.

🧰 Tools
🪛 RuboCop (1.86.1)

[convention] 84-84: Space missing after colon.

(Layout/SpaceAfterColon)


[convention] 85-85: Space missing after colon.

(Layout/SpaceAfterColon)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/routes.rb` around lines 84 - 85, The two routes use invalid action
paths with two '#' symbols ('questions#show_all#questionnaire' and
'questions#delete_all#questionnaire'); update the to: values to a single
controller#action string (e.g., use 'questions#show_all' or
'questions#questionnaire' for the GET route and 'questions#delete_all' or
'questions#questionnaire' for the DELETE route) so they dispatch to a valid
action (adjust the mapped action names to match the actual controller methods
show_all, delete_all, or questionnaire).

Comment on lines +6 to +15
if column_exists?(:teams, :assignment_id)
if foreign_key_exists?(:teams, :assignments)
remove_reference :teams, :assignment, foreign_key: true
else
remove_reference :teams, :assignment, foreign_key: false
end
end

# Add polymorphic association fields (type column already exists)
add_column :teams, :parent_id, :integer, null: false
add_column :teams, :parent_id, :integer, null: false unless column_exists?(:teams, :parent_id)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether a separate migration already backfills teams.assignment_id -> teams.parent_id
rg -n -C3 "assignment_id.*parent_id|parent_id.*assignment_id|UPDATE teams|change_column_null\\s*:teams,\\s*:parent_id" db/migrate

Repository: expertiza/reimplementation-back-end

Length of output: 61


🏁 Script executed:

# Locate and read the actual migration file
find db/migrate -name "*polymorphic*association*teams*" -type f

Repository: expertiza/reimplementation-back-end

Length of output: 151


🏁 Script executed:

# Also check for any data seeding or backfill in db/seeds or lib tasks
find db -name "*.rb" -type f | head -20

Repository: expertiza/reimplementation-back-end

Length of output: 1056


🏁 Script executed:

# Search for any backfill-related code outside migrations (seeds, lib, etc.)
rg -l "assignment_id|parent_id" db/ --type rb | grep -v migrate

Repository: expertiza/reimplementation-back-end

Length of output: 106


🏁 Script executed:

cat -n db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb

Repository: expertiza/reimplementation-back-end

Length of output: 809


🏁 Script executed:

# Check db/seeds.rb for backfill logic
if [ -f db/seeds.rb ]; then
  rg -n "assignment_id|parent_id" db/seeds.rb -A3 -B3
else
  echo "db/seeds.rb not found"
fi

Repository: expertiza/reimplementation-back-end

Length of output: 1018


🏁 Script executed:

# Search for any Rake tasks related to data migration
find . -name "Rakefile" -o -name "*.rake" | head -10

Repository: expertiza/reimplementation-back-end

Length of output: 90


Unsafe data migration: removes assignment_id without backfilling parent_id first.

This migration removes the assignment_id column and adds parent_id with null: false, but contains no logic to copy existing relationship data. On production databases with data, existing assignment relationships are lost and the migration fails due to the null constraint.

The seeds confirm parent_id should replace assignment_id relationships, but there is no separate backfill migration and no external mechanism to preserve existing data.

Suggested migration pattern (backfill before drop)
-class ChangeToPolymorphicAssociationInTeams < ActiveRecord::Migration[8.0]
-  def change
-    # Remove old assignment reference if it still exists
-    if column_exists?(:teams, :assignment_id)
-      if foreign_key_exists?(:teams, :assignments)
-        remove_reference :teams, :assignment, foreign_key: true
-      else
-        remove_reference :teams, :assignment, foreign_key: false
-      end
-    end
-
-    # Add polymorphic association fields (type column already exists)
-    add_column :teams, :parent_id, :integer, null: false unless column_exists?(:teams, :parent_id)
-  end
-end
+class ChangeToPolymorphicAssociationInTeams < ActiveRecord::Migration[8.0]
+  def up
+    add_column :teams, :parent_id, :integer unless column_exists?(:teams, :parent_id)
+
+    if column_exists?(:teams, :assignment_id)
+      execute <<~SQL
+        UPDATE teams
+        SET parent_id = assignment_id
+        WHERE parent_id IS NULL
+      SQL
+
+      change_column_null :teams, :parent_id, false
+      remove_reference :teams, :assignment, foreign_key: foreign_key_exists?(:teams, :assignments)
+    end
+  end
+
+  def down
+    add_reference :teams, :assignment, foreign_key: true unless column_exists?(:teams, :assignment_id)
+    execute <<~SQL
+      UPDATE teams
+      SET assignment_id = parent_id
+      WHERE assignment_id IS NULL
+    SQL
+    remove_column :teams, :parent_id if column_exists?(:teams, :parent_id)
+  end
+end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb`
around lines 6 - 15, The migration currently removes assignment_id then adds
parent_id with null: false without preserving data; update the migration to (1)
add parent_id as nullable (add_column :teams, :parent_id, :integer, null: true
unless column_exists?(:teams, :parent_id)), (2) backfill parent_id from
assignment_id for existing rows (copy assignment_id -> parent_id), (3) run a
safe verification step if desired, (4) change parent_id to null: false or add a
NOT NULL constraint, and only then remove the assignment_id reference (use
foreign_key_exists? and remove_reference :teams, :assignment as currently used);
ensure you reference the existing helpers column_exists?, foreign_key_exists?,
remove_reference, assignment_id and parent_id in the same migration or split
into separate backfill and cleanup migrations to avoid losing data.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant