-
Notifications
You must be signed in to change notification settings - Fork 5
Implement class join codes back-end #789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
f6f5c2c
Add JoinCodeGenerator utility
fspeirs 4d5bb58
Add join_code to SchoolClass
fspeirs d5dfd16
Add regenerate_join_code endpoint
fspeirs 2bb19a3
Add School#valid_email?
fspeirs 83f836f
Add /api/join endpoint for student class enrollment
fspeirs 4110c68
Expose School#sso_enabled? on school JSON
fspeirs 3007552
Simplify join code format to CDDD-CDDD
fspeirs f97754f
refactor: rename School#sso_enabled? to auto_join_enabled?
fspeirs 348c303
feat: let teachers and owners use class join links
fspeirs 7684548
perf: use exists? for School#auto_join_enabled?
fspeirs 9c00b3a
fix: bound join code generation and rescue unique-index races
fspeirs a9e8ff6
fix: make join enrollment idempotent under concurrent requests
fspeirs 6aaf598
Update test harness description to match.
fspeirs 3e34cc2
Use a similar approach to teacher adding as student.
fspeirs 3339389
chore: add frozen_string_literal to add_join_code migration
fspeirs f2ed0f2
fix: harden backfill migration against retry exhaustion and races
fspeirs 7f73c09
fix: keep ClassStudent role validator active and handle concurrent race
fspeirs 3dbd8c8
fix: rescue RecordInvalid in teacher join path for concurrent races
fspeirs 3576349
fix: return model errors instead of raw exception in regenerate_join_…
fspeirs 35b5567
test: drop duplicate regenerate_join_code 200 example
fspeirs 40452ea
refactor: make join#create case statement exhaustive
fspeirs 1e53dcc
refactor: rename School#valid_email? to email_domain_in_school_domains?
fspeirs c055342
refactor: extract JoinStatusService from JoinController
fspeirs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Api | ||
| class JoinController < ApiController | ||
| before_action :authorize_user, only: :create | ||
| before_action :find_school_and_class | ||
|
|
||
| def show | ||
| @status = show_status | ||
| render :show, formats: [:json], status: :ok | ||
| end | ||
|
|
||
| def create | ||
| case action_status | ||
| when :wrong_school, :domain_mismatch, :not_a_student | ||
| render json: { error: action_status.to_s }, status: :forbidden | ||
| when :already_member, :owner | ||
| render json: { redirect_url: class_redirect_path }, status: :ok | ||
| when :joinable_as_teacher | ||
| add_user_to_class_as_teacher | ||
| render json: { redirect_url: class_redirect_path }, status: :ok | ||
| when :joinable | ||
| add_student_to_school_and_class | ||
| render json: { redirect_url: class_redirect_path }, status: :ok | ||
| else | ||
| raise "Unexpected join action_status: #{action_status.inspect}" | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def find_school_and_class | ||
| @school_class = SchoolClass.find_by!(join_code: JoinCodeGenerator.normalize(params[:join_code])) | ||
| @school = @school_class.school | ||
| end | ||
|
|
||
| def show_status | ||
| return :unauthenticated unless current_user | ||
|
|
||
| action_status | ||
| end | ||
|
|
||
| def action_status | ||
| @action_status ||= JoinStatusService.new(school: @school, school_class: @school_class, user: current_user).call | ||
| end | ||
|
|
||
| def class_redirect_path | ||
| "/school/#{@school.code}/class/#{@school_class.code}" | ||
| end | ||
|
|
||
| def add_student_to_school_and_class | ||
| ActiveRecord::Base.transaction do | ||
| Role.find_or_create_by!(school: @school, user_id: current_user.id, role: :student) | ||
| ClassStudent.find_or_create_by!(school_class: @school_class, student_id: current_user.id) do |class_student| | ||
| class_student.student = current_user | ||
| end | ||
| end | ||
| rescue ActiveRecord::RecordNotUnique | ||
| # Concurrent join request for the same user/class — DB unique index | ||
| # caught a race we couldn't catch at validation time. Already enrolled. | ||
| rescue ActiveRecord::RecordInvalid => e | ||
| raise unless e.record.errors.of_kind?(:student_id, :taken) | ||
| # Concurrent join request raced the in-memory uniqueness validator. Already enrolled. | ||
| end | ||
|
|
||
| def add_user_to_class_as_teacher | ||
| ClassTeacher.find_or_create_by!(school_class: @school_class, teacher_id: current_user.id) do |class_teacher| | ||
| class_teacher.teacher = current_user | ||
| end | ||
| rescue ActiveRecord::RecordNotUnique | ||
| # Concurrent join request for the same teacher/class — already enrolled. | ||
| rescue ActiveRecord::RecordInvalid => e | ||
| raise unless e.record.errors.of_kind?(:teacher_id, :taken) | ||
| # Concurrent join raced the in-memory uniqueness validator. Already enrolled. | ||
| end | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Computes the join "status" symbol that describes the relationship between | ||
| # `user` and `school_class` — the controller uses it to decide what HTTP | ||
| # response to return and whether to enrol the user. | ||
| # | ||
| # Possible return values: | ||
| # :already_member — user is already in this class | ||
| # :owner — user owns this school (redirect, no enrolment) | ||
| # :joinable_as_teacher — user teaches this school but isn't in the class | ||
| # :joinable — user can be enrolled as a student of this class | ||
| # :not_a_student — user has a non-student role in some other school | ||
| # :wrong_school — user is a student of a different school | ||
| # :domain_mismatch — user's email domain isn't registered for the school | ||
| class JoinStatusService | ||
| def initialize(school:, school_class:, user:) | ||
| @school = school | ||
| @school_class = school_class | ||
| @user = user | ||
| end | ||
|
|
||
| def call | ||
| return :already_member if user_is_member_of_class? | ||
| return existing_user_join_status if user_has_role_in_school? | ||
|
|
||
| new_user_join_status | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # The user already has a role in this school: which one decides the status. | ||
| def existing_user_join_status | ||
| return :owner if user_is_owner_of_school? | ||
| return :joinable_as_teacher if user_is_teacher_of_school? | ||
|
|
||
| :joinable # student is the only remaining role for this school | ||
| end | ||
|
|
||
| # The user has no role in this school yet: may they join as a new student? | ||
| def new_user_join_status | ||
| return :not_a_student if user_has_non_student_role? | ||
| return :wrong_school if user_in_different_school? | ||
| return :domain_mismatch unless @school.email_domain_in_school_domains?(@user.email) | ||
|
|
||
| :joinable | ||
| end | ||
|
|
||
| def user_is_member_of_class? | ||
| ClassStudent.exists?(school_class: @school_class, student_id: @user.id) || | ||
| ClassTeacher.exists?(school_class: @school_class, teacher_id: @user.id) | ||
| end | ||
|
|
||
| def user_is_owner_of_school? | ||
| Role.exists?(school: @school, user_id: @user.id, role: Role.roles[:owner]) | ||
| end | ||
|
|
||
| def user_is_teacher_of_school? | ||
| Role.exists?(school: @school, user_id: @user.id, role: Role.roles[:teacher]) | ||
| end | ||
|
|
||
| def user_has_role_in_school? | ||
| Role.exists?(school: @school, user_id: @user.id) | ||
| end | ||
|
|
||
| def user_has_non_student_role? | ||
| Role.where(user_id: @user.id).where.not(role: Role.roles[:student]).exists? | ||
| end | ||
|
|
||
| def user_in_different_school? | ||
| Role.where(user_id: @user.id).where.not(school_id: @school.id).exists? | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| json.status @status.to_s | ||
| json.school do | ||
| json.code @school.code | ||
| json.name @school.name | ||
| end | ||
| json.school_class do | ||
| json.code @school_class.code | ||
| json.name @school_class.name | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class AddJoinCodeToSchoolClasses < ActiveRecord::Migration[7.2] | ||
| def change | ||
| add_column :school_classes, :join_code, :string | ||
|
fspeirs marked this conversation as resolved.
|
||
| add_index :school_classes, :join_code, unique: true | ||
| end | ||
| end | ||
35 changes: 35 additions & 0 deletions
35
db/migrate/20260420104939_backfill_join_code_for_school_classes.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class BackfillJoinCodeForSchoolClasses < ActiveRecord::Migration[7.2] | ||
| MAX_ATTEMPTS = 10 | ||
|
|
||
| def up | ||
| SchoolClass.where(join_code: nil).find_each do |school_class| | ||
| backfill_with_retries(school_class) | ||
| end | ||
| end | ||
|
|
||
| def down | ||
| # No need to revert - join codes can stay | ||
| end | ||
|
fspeirs marked this conversation as resolved.
|
||
|
|
||
| private | ||
|
|
||
| def backfill_with_retries(school_class) | ||
| MAX_ATTEMPTS.times do | ||
| school_class.join_code = nil | ||
| school_class.assign_join_code | ||
| next if school_class.errors[:join_code].any? | ||
|
|
||
| begin | ||
| school_class.save!(validate: false) | ||
| return | ||
| rescue ActiveRecord::RecordNotUnique | ||
| school_class.errors.clear | ||
| next | ||
| end | ||
| end | ||
|
|
||
| raise "Could not generate a unique join_code for SchoolClass##{school_class.id} after #{MAX_ATTEMPTS} attempts" | ||
| end | ||
| end | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class JoinCodeGenerator | ||
| # Omit K, X, Z — commonly confused or offensive in short codes. | ||
| CONSONANTS = %w[B C D F G H J L M N P Q R S T V W Y].freeze | ||
|
|
||
| # Format: CDDD-CDDD (e.g., B123-C456). C = consonant from CONSONANTS, D = digit. | ||
| FORMAT_REGEX = Regexp.new("\\A(?:#{CONSONANTS.join('|')})\\d{3}-(?:#{CONSONANTS.join('|')})\\d{3}\\z").freeze | ||
|
fspeirs marked this conversation as resolved.
|
||
|
|
||
| cattr_accessor :random | ||
|
|
||
| self.random ||= Random.new | ||
|
|
||
| def self.generate | ||
| seg = lambda do | ||
| "#{CONSONANTS.sample(random: random)}#{format('%03d', random.rand(1000))}" | ||
| end | ||
|
|
||
| "#{seg.call}-#{seg.call}" | ||
| end | ||
|
|
||
| # Canonical hyphenated form for DB lookup; accepts typed codes with or without a hyphen. | ||
| def self.normalize(raw) | ||
| alnum = raw.to_s.upcase.gsub(/[^A-Z0-9]/, '') | ||
| return alnum if alnum.length != 8 | ||
|
|
||
| "#{alnum[0]}#{alnum[1, 3]}-#{alnum[4]}#{alnum[5, 3]}" | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.