Skip to content

Commit fe67e5c

Browse files
fspeirsCopilot
andauthored
Implement class join codes back-end (#789)
This PR implements the back-end for class join codes — students join a class by entering an 8-character code in the format `CDDD-CDDD` (consonant + three digits, twice, separated by a hyphen). ## Core Logic The core logic for this work lives in `JoinController#create`, which determines what response the front-end gets based on the user who is trying to join. This method returns a `show.json.builder` with values that allow the front-end to determine what to show the user, or where to redirect them to based on their status. The status value is computed in `action_status`. Another feature of `JoinController#show` is that it may be called by unauthenticated users, so that we can destructure the join code into a school and class in order to show the user a school and class name before they're redirected to log in. There are modifications to `School` to allow that model to carry a join code and regenerate it. Conceptually, a join code is a globally unique code that represents both a class code and a school code. The other component of interest is the `JoinCodeGenerator` which produces the codes in the first place. ## Changes - Add `join_code` column with a unique index to `school_classes`, plus a backfill migration for existing classes. - Add `JoinCodeGenerator` with `.generate` and `.normalize` (canonicalises user input to the hyphenated form for DB lookup). - Auto-assign join codes on `SchoolClass` create/import with a uniqueness retry, plus a `regenerate_join_code!` helper. - Add `GET /api/join/:join_code` returning the prospective user's status. The full set of statuses is: - `unauthenticated` — no current user - `joinable` — the user can be enrolled as a student - `joinable_as_teacher` — the user already has a teacher role for this school and will be added to the class as a teacher - `owner` — the user owns this school; treated as already a member for redirect purposes - `already_member` — the user is already a member of this class - `wrong_school` — the user has a role in a different school - `domain_mismatch` — the user's email domain is not registered for this school - `not_a_student` — the user has a non-student role elsewhere - Add `POST /api/join/:join_code` to enrol the current user as a student (or teacher, if `joinable_as_teacher`) of the school and class. - Add `POST /api/schools/:school_id/classes/:id/regenerate_join_code` (nested under the existing `school_classes` resource). - Expose `join_code` on the school class JSON - Add `School#valid_email?` (built on #787's domain validation) for the `domain_mismatch` check. - Add `School#auto_join_enabled?` to allow the front end to prompt users correctly about the requirements for auto-join. - Update CanCan abilities so school owners and class teachers can `regenerate_join_code`. - Add specs covering the model, generator, controllers, and abilities. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 84f36ee commit fe67e5c

22 files changed

Lines changed: 928 additions & 4 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
class JoinController < ApiController
5+
before_action :authorize_user, only: :create
6+
before_action :find_school_and_class
7+
8+
def show
9+
@status = show_status
10+
render :show, formats: [:json], status: :ok
11+
end
12+
13+
def create
14+
case action_status
15+
when :wrong_school, :domain_mismatch, :not_a_student
16+
render json: { error: action_status.to_s }, status: :forbidden
17+
when :already_member, :owner
18+
render json: { redirect_url: class_redirect_path }, status: :ok
19+
when :joinable_as_teacher
20+
add_user_to_class_as_teacher
21+
render json: { redirect_url: class_redirect_path }, status: :ok
22+
when :joinable
23+
add_student_to_school_and_class
24+
render json: { redirect_url: class_redirect_path }, status: :ok
25+
else
26+
raise "Unexpected join action_status: #{action_status.inspect}"
27+
end
28+
end
29+
30+
private
31+
32+
def find_school_and_class
33+
@school_class = SchoolClass.find_by!(join_code: JoinCodeGenerator.normalize(params[:join_code]))
34+
@school = @school_class.school
35+
end
36+
37+
def show_status
38+
return :unauthenticated unless current_user
39+
40+
action_status
41+
end
42+
43+
def action_status
44+
@action_status ||= JoinStatusService.new(school: @school, school_class: @school_class, user: current_user).call
45+
end
46+
47+
def class_redirect_path
48+
"/school/#{@school.code}/class/#{@school_class.code}"
49+
end
50+
51+
def add_student_to_school_and_class
52+
ActiveRecord::Base.transaction do
53+
Role.find_or_create_by!(school: @school, user_id: current_user.id, role: :student)
54+
ClassStudent.find_or_create_by!(school_class: @school_class, student_id: current_user.id) do |class_student|
55+
class_student.student = current_user
56+
end
57+
end
58+
rescue ActiveRecord::RecordNotUnique
59+
# Concurrent join request for the same user/class — DB unique index
60+
# caught a race we couldn't catch at validation time. Already enrolled.
61+
rescue ActiveRecord::RecordInvalid => e
62+
raise unless e.record.errors.of_kind?(:student_id, :taken)
63+
# Concurrent join request raced the in-memory uniqueness validator. Already enrolled.
64+
end
65+
66+
def add_user_to_class_as_teacher
67+
ClassTeacher.find_or_create_by!(school_class: @school_class, teacher_id: current_user.id) do |class_teacher|
68+
class_teacher.teacher = current_user
69+
end
70+
rescue ActiveRecord::RecordNotUnique
71+
# Concurrent join request for the same teacher/class — already enrolled.
72+
rescue ActiveRecord::RecordInvalid => e
73+
raise unless e.record.errors.of_kind?(:teacher_id, :taken)
74+
# Concurrent join raced the in-memory uniqueness validator. Already enrolled.
75+
end
76+
end
77+
end

app/controllers/api/school_classes_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ def destroy
8181
end
8282
end
8383

84+
def regenerate_join_code
85+
@school_class.regenerate_join_code!
86+
@school_class_with_teachers = @school_class.with_teachers
87+
render :show, formats: [:json], status: :ok
88+
rescue ActiveRecord::RecordInvalid
89+
render json: { error: @school_class.errors.full_messages.to_sentence }, status: :unprocessable_content
90+
end
91+
8492
private
8593

8694
def render_student_index(school_classes)

app/models/ability.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def define_authenticated_non_student_abilities(user)
6262
def define_school_owner_abilities(school:)
6363
can(%i[read update destroy], School, id: school.id)
6464
can(%i[read], :school_member)
65-
can(%i[read create import update destroy], SchoolClass, school: { id: school.id })
65+
can(%i[read create import update destroy regenerate_join_code], SchoolClass, school: { id: school.id })
6666
can(%i[read show_context], Project, school_id: school.id, lesson: { visibility: %w[teachers students] })
6767
can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } })
6868
can(%i[read create destroy], :school_owner)
@@ -78,7 +78,7 @@ def define_school_teacher_abilities(user:, school:)
7878
can(%i[read], School, id: school.id)
7979
can(%i[read], :school_member)
8080
can(%i[create import], SchoolClass, school: { id: school.id })
81-
can(%i[read update destroy], SchoolClass, school: { id: school.id }, teachers: { teacher_id: user.id })
81+
can(%i[read update destroy regenerate_join_code], SchoolClass, school: { id: school.id }, teachers: { teacher_id: user.id })
8282
can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id }, teachers: { teacher_id: user.id } })
8383
can(%i[read], :school_owner)
8484
can(%i[read], :school_teacher)

app/models/school.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,26 @@ def import_in_progress?
117117
.exists?(description: id)
118118
end
119119

120+
def auto_join_enabled?
121+
school_email_domains.exists?
122+
end
123+
120124
def valid_domain?(candidate_domain)
121125
validated_domain = SchoolEmailDomainValidator.call(candidate_domain)
122126
school_email_domains.exists?(domain: validated_domain)
123127
rescue ::SchoolEmailDomainValidator::Error
124128
false
125129
end
126130

131+
def email_domain_in_school_domains?(email)
132+
return false if email.blank?
133+
134+
local, separator, domain = email.to_s.rpartition('@')
135+
return false if separator.empty? || local.blank? || domain.blank?
136+
137+
valid_domain?(domain.strip.downcase)
138+
end
139+
127140
private
128141

129142
# Ensure the reference is nil, not an empty string

app/models/school_class.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ class SchoolClass < ApplicationRecord
1010
scope :with_teachers, ->(user_id) { joins(:teachers).where(teachers: { id: user_id }) }
1111

1212
before_validation :assign_class_code, on: %i[create import]
13+
before_validation :assign_join_code, on: %i[create import]
1314

1415
validates :name, presence: true
1516
validates :code, uniqueness: { scope: :school_id }, presence: true, format: { with: /\d\d-\d\d-\d\d/, allow_nil: false }
17+
validates :join_code, uniqueness: true, presence: true, format: { with: JoinCodeGenerator::FORMAT_REGEX, allow_nil: false }
1618
validate :code_cannot_be_changed
1719
validate :school_class_has_at_least_one_teacher
1820

@@ -58,6 +60,27 @@ def assign_class_code
5860
errors.add(:code, 'could not be generated')
5961
end
6062

63+
def assign_join_code
64+
return if join_code.present?
65+
66+
5.times do
67+
self.join_code = JoinCodeGenerator.generate
68+
return if join_code_is_unique?
69+
end
70+
71+
errors.add(:join_code, 'could not be generated')
72+
end
73+
74+
def regenerate_join_code!
75+
self.join_code = nil
76+
assign_join_code
77+
save!
78+
rescue ActiveRecord::RecordNotUnique
79+
self.join_code = nil
80+
assign_join_code
81+
save!
82+
end
83+
6184
def submitted_projects_count
6285
lessons.to_a.sum(&:submitted_projects_count)
6386
end
@@ -77,4 +100,8 @@ def code_cannot_be_changed
77100
def code_is_unique_within_school?
78101
code.present? && SchoolClass.where(code:, school:).none?
79102
end
103+
104+
def join_code_is_unique?
105+
join_code.present? && SchoolClass.where(join_code:).where.not(id:).none?
106+
end
80107
end
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
# Computes the join "status" symbol that describes the relationship between
4+
# `user` and `school_class` — the controller uses it to decide what HTTP
5+
# response to return and whether to enrol the user.
6+
#
7+
# Possible return values:
8+
# :already_member — user is already in this class
9+
# :owner — user owns this school (redirect, no enrolment)
10+
# :joinable_as_teacher — user teaches this school but isn't in the class
11+
# :joinable — user can be enrolled as a student of this class
12+
# :not_a_student — user has a non-student role in some other school
13+
# :wrong_school — user is a student of a different school
14+
# :domain_mismatch — user's email domain isn't registered for the school
15+
class JoinStatusService
16+
def initialize(school:, school_class:, user:)
17+
@school = school
18+
@school_class = school_class
19+
@user = user
20+
end
21+
22+
def call
23+
return :already_member if user_is_member_of_class?
24+
return existing_user_join_status if user_has_role_in_school?
25+
26+
new_user_join_status
27+
end
28+
29+
private
30+
31+
# The user already has a role in this school: which one decides the status.
32+
def existing_user_join_status
33+
return :owner if user_is_owner_of_school?
34+
return :joinable_as_teacher if user_is_teacher_of_school?
35+
36+
:joinable # student is the only remaining role for this school
37+
end
38+
39+
# The user has no role in this school yet: may they join as a new student?
40+
def new_user_join_status
41+
return :not_a_student if user_has_non_student_role?
42+
return :wrong_school if user_in_different_school?
43+
return :domain_mismatch unless @school.email_domain_in_school_domains?(@user.email)
44+
45+
:joinable
46+
end
47+
48+
def user_is_member_of_class?
49+
ClassStudent.exists?(school_class: @school_class, student_id: @user.id) ||
50+
ClassTeacher.exists?(school_class: @school_class, teacher_id: @user.id)
51+
end
52+
53+
def user_is_owner_of_school?
54+
Role.exists?(school: @school, user_id: @user.id, role: Role.roles[:owner])
55+
end
56+
57+
def user_is_teacher_of_school?
58+
Role.exists?(school: @school, user_id: @user.id, role: Role.roles[:teacher])
59+
end
60+
61+
def user_has_role_in_school?
62+
Role.exists?(school: @school, user_id: @user.id)
63+
end
64+
65+
def user_has_non_student_role?
66+
Role.where(user_id: @user.id).where.not(role: Role.roles[:student]).exists?
67+
end
68+
69+
def user_in_different_school?
70+
Role.where(user_id: @user.id).where.not(school_id: @school.id).exists?
71+
end
72+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
json.status @status.to_s
4+
json.school do
5+
json.code @school.code
6+
json.name @school.name
7+
end
8+
json.school_class do
9+
json.code @school_class.code
10+
json.name @school_class.name
11+
end

app/views/api/school_classes/_school_class.json.jbuilder

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ json.call(
99
:created_at,
1010
:updated_at,
1111
:import_origin,
12-
:import_id
12+
:import_id,
13+
:join_code
1314
)
1415

1516
json.teachers(teachers) do |teacher|

app/views/api/schools/_school.json.jbuilder

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ include_user_origin = local_assigns.fetch(:user_origin, false)
3030
json.user_origin(school.user_origin) if include_user_origin
3131

3232
json.import_in_progress school.import_in_progress?
33+
json.auto_join_enabled school.auto_join_enabled?

config/routes.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
resources :members, only: %i[index], controller: 'school_members'
7171
resources :classes, only: %i[index show create update destroy], controller: 'school_classes' do
7272
post :import, on: :collection
73+
post :regenerate_join_code, on: :member
7374
resources :members, only: %i[index create destroy], controller: 'class_members' do
7475
post :batch, on: :collection, to: 'class_members#create_batch'
7576
end
@@ -100,6 +101,9 @@
100101

101102
resources :profile_auth_check, only: %i[index]
102103
resources :subscriptions, only: %i[create]
104+
105+
get '/join/:join_code', to: 'join#show'
106+
post '/join/:join_code', to: 'join#create'
103107
end
104108

105109
resource :github_webhooks, only: :create, defaults: { formats: :json }

0 commit comments

Comments
 (0)