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
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ gem 'paper_trail'
gem 'pg', '~> 1.6'
gem 'postmark-rails'
gem 'propshaft'
gem 'public_suffix', '~> 7.0'
gem 'puma', '~> 7.2'
gem 'rack_content_type_default', '~> 1.1'
gem 'rack-cors'
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ DEPENDENCIES
postmark-rails
propshaft
pry-byebug
public_suffix (~> 7.0)
puma (~> 7.2)
rack-cors
rack_content_type_default (~> 1.1)
Expand Down
78 changes: 78 additions & 0 deletions app/controllers/api/join_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 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
render json: { redirect_url: class_redirect_path }, status: :ok
else
add_user_to_school_and_class
render json: { redirect_url: class_redirect_path }, status: :ok
end
end

private

def find_school_and_class
normalized = params[:join_code].to_s.upcase.gsub(/[^A-Z0-9]/, '')
@school_class = SchoolClass.find_by!(join_code: normalized)
@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 :joinable if user_is_student_of_school?
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)
end

def user_is_student_of_school?
Role.exists?(school: @school, user_id: current_user.id, role: Role.roles[:student])
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_user_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
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
14 changes: 14 additions & 0 deletions app/models/school.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class School < ApplicationRecord
has_many :projects, dependent: :nullify
has_many :roles, dependent: :nullify
has_many :school_projects, dependent: :nullify
has_many :school_email_domains, dependent: :destroy

VALID_URL_REGEX = %r{\A(?:https?://)?(?:www.)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,63}(\.[a-z]{2,63})*(/.*)?\z}ix

Expand Down Expand Up @@ -116,6 +117,19 @@ def import_in_progress?
.exists?(description: id)
end

def valid_domain?(candidate_domain)
school_email_domains.exists?(domain: candidate_domain)
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_count
return 0 if lessons.empty?

Expand Down Expand Up @@ -88,4 +105,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
42 changes: 42 additions & 0 deletions app/models/school_email_domain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

class SchoolEmailDomain < ApplicationRecord
belongs_to :school

validates :domain, presence: true
validates :domain, uniqueness: { scope: :school_id }

before_validation :validate_domain

private

def validate_domain
return if domain.blank?

value = domain.strip.downcase
# Add a scheme unless it already has one, so URI can parse it
value = "http://#{value}" unless %r{\A[a-z][a-z0-9+\-.]*://}i.match?(value)
uri = URI.parse(value)
host = uri.host&.delete_suffix('.')

validate_host(host)
rescue URI::InvalidURIError
errors.add(:domain, :invalid)
end

def validate_host(host)
accounts_host_format =
/\A\s*(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}\s*\z/i

unless host&.match?(accounts_host_format)
errors.add(:domain, :invalid)
return
end

if PublicSuffix.valid?(host)
self.domain = host
else
errors.add(:domain, :invalid)
end
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
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
14 changes: 14 additions & 0 deletions db/migrate/20260420104937_create_school_email_domains.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

class CreateSchoolEmailDomains < ActiveRecord::Migration[7.2]
def change
create_table :school_email_domains, id: :uuid do |t|
t.references :school, null: false, foreign_key: true, type: :uuid
t.string :domain, null: false

t.timestamps
end

add_index :school_email_domains, %i[school_id domain], unique: true
end
end
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
14 changes: 13 additions & 1 deletion db/schema.rb

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

Loading
Loading