Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions app/controllers/api/join_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# 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
else
add_student_to_school_and_class
render json: { redirect_url: class_redirect_path }, status: :ok
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 ||= compute_action_status
end

def compute_action_status
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

# 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.valid_email?(current_user.email)

:joinable
end

def class_redirect_path
"/school/#{@school.code}/class/#{@school_class.code}"
end

def user_is_member_of_class?
ClassStudent.exists?(school_class: @school_class, student_id: current_user.id) ||
ClassTeacher.exists?(school_class: @school_class, teacher_id: current_user.id)
end

def user_is_owner_of_school?
Role.exists?(school: @school, user_id: current_user.id, role: Role.roles[:owner])
end

def user_is_teacher_of_school?
Role.exists?(school: @school, user_id: current_user.id, role: Role.roles[:teacher])
end

def user_has_role_in_school?
Role.exists?(school: @school, user_id: current_user.id)
end

def user_has_non_student_role?
Role.where(user_id: current_user.id).where.not(role: Role.roles[:student]).exists?
end

def user_in_different_school?
Role.where(user_id: current_user.id).where.not(school_id: @school.id).exists?
end

def add_student_to_school_and_class
Role.create!(school: @school, user_id: current_user.id, role: :student) unless Role.exists?(school: @school, user_id: current_user.id)
ClassStudent.create!(school_class: @school_class, student_id: current_user.id)
end

def add_user_to_class_as_teacher
class_teacher = @school_class.teachers.build(teacher_id: current_user.id)
class_teacher.teacher = current_user
class_teacher.save!
end
end
end
8 changes: 8 additions & 0 deletions app/controllers/api/school_classes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ def destroy
end
end

def regenerate_join_code
@school_class.regenerate_join_code!
@school_class_with_teachers = @school_class.with_teachers
render :show, formats: [:json], status: :ok
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.message }, status: :unprocessable_content
end

private

def render_student_index(school_classes)
Expand Down
4 changes: 2 additions & 2 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def define_authenticated_non_student_abilities(user)
def define_school_owner_abilities(school:)
can(%i[read update destroy], School, id: school.id)
can(%i[read], :school_member)
can(%i[read create import update destroy], SchoolClass, school: { id: school.id })
can(%i[read create import update destroy regenerate_join_code], SchoolClass, school: { id: school.id })
can(%i[read show_context], Project, school_id: school.id, lesson: { visibility: %w[teachers students] })
can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } })
can(%i[read create destroy], :school_owner)
Expand All @@ -78,7 +78,7 @@ def define_school_teacher_abilities(user:, school:)
can(%i[read], School, id: school.id)
can(%i[read], :school_member)
can(%i[create import], SchoolClass, school: { id: school.id })
can(%i[read update destroy], SchoolClass, school: { id: school.id }, teachers: { teacher_id: user.id })
can(%i[read update destroy regenerate_join_code], SchoolClass, school: { id: school.id }, teachers: { teacher_id: user.id })
can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id }, teachers: { teacher_id: user.id } })
can(%i[read], :school_owner)
can(%i[read], :school_teacher)
Expand Down
13 changes: 13 additions & 0 deletions app/models/school.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,26 @@ def import_in_progress?
.exists?(description: id)
end

def auto_join_enabled?
school_email_domains.present?
end

def valid_domain?(candidate_domain)
validated_domain = SchoolEmailDomainValidator.call(candidate_domain)
school_email_domains.exists?(domain: validated_domain)
rescue ::SchoolEmailDomainValidator::Error
false
end

def valid_email?(email)
return false if email.blank?

local, separator, domain = email.to_s.rpartition('@')
return false if separator.empty? || local.blank? || domain.blank?

valid_domain?(domain.strip.downcase)
end

private

# Ensure the reference is nil, not an empty string
Expand Down
21 changes: 21 additions & 0 deletions app/models/school_class.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ class SchoolClass < ApplicationRecord
scope :with_teachers, ->(user_id) { joins(:teachers).where(teachers: { id: user_id }) }

before_validation :assign_class_code, on: %i[create import]
before_validation :assign_join_code, on: %i[create import]

validates :name, presence: true
validates :code, uniqueness: { scope: :school_id }, presence: true, format: { with: /\d\d-\d\d-\d\d/, allow_nil: false }
validates :join_code, uniqueness: true, presence: true, format: { with: JoinCodeGenerator::FORMAT_REGEX, allow_nil: false }
validate :code_cannot_be_changed
validate :school_class_has_at_least_one_teacher

Expand Down Expand Up @@ -58,6 +60,21 @@ def assign_class_code
errors.add(:code, 'could not be generated')
end

def assign_join_code
return if join_code.present?

loop do
self.join_code = JoinCodeGenerator.generate
break if join_code_is_unique?
end
end

def regenerate_join_code!
self.join_code = nil
assign_join_code
save!
end

def submitted_projects_count
lessons.to_a.sum(&:submitted_projects_count)
end
Expand All @@ -77,4 +94,8 @@ def code_cannot_be_changed
def code_is_unique_within_school?
code.present? && SchoolClass.where(code:, school:).none?
end

def join_code_is_unique?
join_code.present? && SchoolClass.where(join_code:).where.not(id:).none?
end
end
11 changes: 11 additions & 0 deletions app/views/api/join/show.json.jbuilder
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
3 changes: 2 additions & 1 deletion app/views/api/school_classes/_school_class.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ json.call(
:created_at,
:updated_at,
:import_origin,
:import_id
:import_id,
:join_code
)

json.teachers(teachers) do |teacher|
Expand Down
1 change: 1 addition & 0 deletions app/views/api/schools/_school.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ include_user_origin = local_assigns.fetch(:user_origin, false)
json.user_origin(school.user_origin) if include_user_origin

json.import_in_progress school.import_in_progress?
json.auto_join_enabled school.auto_join_enabled?
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
resources :members, only: %i[index], controller: 'school_members'
resources :classes, only: %i[index show create update destroy], controller: 'school_classes' do
post :import, on: :collection
post :regenerate_join_code, on: :member
resources :members, only: %i[index create destroy], controller: 'class_members' do
post :batch, on: :collection, to: 'class_members#create_batch'
end
Expand Down Expand Up @@ -100,6 +101,9 @@

resources :profile_auth_check, only: %i[index]
resources :subscriptions, only: %i[create]

get '/join/:join_code', to: 'join#show'
post '/join/:join_code', to: 'join#create'
end

resource :github_webhooks, only: :create, defaults: { formats: :json }
Expand Down
6 changes: 6 additions & 0 deletions db/migrate/20260420104938_add_join_code_to_school_classes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddJoinCodeToSchoolClasses < ActiveRecord::Migration[7.2]
def change
add_column :school_classes, :join_code, :string
add_index :school_classes, :join_code, unique: true
end
end
12 changes: 12 additions & 0 deletions db/migrate/20260420104939_backfill_join_code_for_school_classes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class BackfillJoinCodeForSchoolClasses < ActiveRecord::Migration[7.2]
def up
SchoolClass.find_each do |school_class|
school_class.assign_join_code
school_class.save!(validate: false)
end
end

def down
# No need to revert - join codes can stay
end
end
2 changes: 2 additions & 0 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions lib/join_code_generator.rb
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

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
1 change: 1 addition & 0 deletions spec/factories/school_class.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
factory :school_class do
sequence(:name) { |n| "Class #{n}" }
code { ForEducationCodeGenerator.generate }
join_code { JoinCodeGenerator.generate }

transient do
teacher_ids { [SecureRandom.uuid] }
Expand Down
2 changes: 1 addition & 1 deletion spec/features/my_school/showing_my_school_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
school_json = school.to_json(only: %i[
id name website reference address_line_1 address_line_2 municipality administrative_area postal_code country_code code verified_at created_at updated_at district_name district_nces_id school_roll_number
])
expected_data = JSON.parse(school_json, symbolize_names: true).merge(roles: ['owner'], import_in_progress: school.import_in_progress?)
expected_data = JSON.parse(school_json, symbolize_names: true).merge(roles: ['owner'], import_in_progress: school.import_in_progress?, auto_join_enabled: school.auto_join_enabled?)

get('/api/school', headers:)
data = JSON.parse(response.body, symbolize_names: true)
Expand Down
Loading
Loading