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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ HYDRA_CLIENT_ID=editor-dashboard-dev
HYDRA_CLIENT_SECRET=secret

IDENTITY_URL=http://host.docker.internal:3002 # Internal docker address
PROFILE_URL=http://localhost:3002 # External address for browser redirects

SMEE_TUNNEL=https://smee.io/MLq0n9kvAes2vydX

Expand Down
68 changes: 68 additions & 0 deletions app/controllers/api/join_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

module Api
class JoinController < ApiController
before_action :find_school_and_class
before_action :authorize_user, only: [:create]

rescue_from ActiveRecord::RecordNotFound, with: :not_found

def show
# Public endpoint to get school code for a join code (for login autofill)
render json: { school_code: @school.code }, status: :ok
end

def create
# If already a member of this class, just return success
if ClassStudent.exists?(school_class: @school_class, student_id: current_user.id)
render json: { redirect_url: class_page_url, school_code: @school.code }, status: :ok
return
end

if user_in_different_school?
render json: { error: 'You are already a member of a different school' }, status: :forbidden
return
end

unless @school.valid_email?(current_user.email)
render json: { error: 'Your email domain does not match the school' }, status: :forbidden
return
end

add_user_to_school_and_class

render json: { redirect_url: class_page_url, school_code: @school.code }, status: :created
end

private

def find_school_and_class
@normalized_join_code = params[:join_code].to_s.upcase.gsub(/[^A-Z0-9]/, '')
@school_class = SchoolClass.find_by!(join_code: @normalized_join_code)
@school = @school_class.school
end

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

def user_in_different_school?
existing_school = Role.find_by(user_id: current_user.id)&.school
existing_school.present? && existing_school.id != @school.id
end

def add_user_to_school_and_class
# Add to school if not already a member
Role.create!(school: @school, user_id: current_user.id, role: :student) unless Role.exists?(school: @school, user_id: current_user.id)

# Add to class if not already a member
return if ClassStudent.exists?(school_class: @school_class, student_id: current_user.id)

ClassStudent.create!(school_class: @school_class, student_id: current_user.id)
end

def not_found
render json: { error: 'Join code not found' }, status: :not_found
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
6 changes: 6 additions & 0 deletions app/models/school.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ def import_in_progress?
.exists?(description: id)
end

def valid_email?(_email)
# TODO: Implement actual domain verification
# This stub always returns true for now
true
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: /\A[A-Z][A-Z0-9]{7}\z/, 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
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
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@

post '/test/reseed', to: 'test_utilities#reseed'

get '/join/:join_code', to: 'join#show', as: 'join'

post '/graphql', to: 'graphql#execute'
mount GraphiQL::Rails::Engine, at: '/graphql', graphql_path: '/graphql#execute' unless Rails.env.production?

Expand Down Expand Up @@ -70,6 +72,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 @@ -99,6 +102,9 @@
resources :features, only: %i[index]

resources :profile_auth_check, only: %i[index]

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/20260416124302_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/20260416124324_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
4 changes: 3 additions & 1 deletion db/schema.rb

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

45 changes: 45 additions & 0 deletions lib/join_code_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

class JoinCodeGenerator
# Removed letters that commonly form offensive words:
# Removed vowels: None (need all 5 for readability)
# Removed consonants: K (dick, fuck), X (sex), Z (rarely used anyway)
CONSONANTS = %w[B C D F G H J L M N P Q R S T V W Y].freeze
VOWELS = %w[A E I O U].freeze

cattr_accessor :random

self.random ||= Random.new

# List of offensive letter patterns to avoid (consonant-vowel pairs)
OFFENSIVE_PATTERNS = %w[
AS BA BO BU DA DI FU HO PO SH TA TI VA
].freeze

def self.generate
max_attempts = 100
max_attempts.times do
code = [
CONSONANTS.sample(random: random),
VOWELS.sample(random: random),
format('%02d', random.rand(100)),
CONSONANTS.sample(random: random),
VOWELS.sample(random: random),
format('%02d', random.rand(100))
].join

# Extract the CV patterns (positions 0-1 and 4-5)
first_cv = code[0, 2]
second_cv = code[4, 2]

# Check if either CV pair matches offensive patterns
next if OFFENSIVE_PATTERNS.include?(first_cv)
next if OFFENSIVE_PATTERNS.include?(second_cv)

return code
end

# Fallback if we couldn't generate a clean code after max_attempts
raise 'Unable to generate non-offensive join code'
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
84 changes: 84 additions & 0 deletions spec/features/school_class/regenerating_join_code_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Regenerating a school class join code', type: :request do
before do
authenticated_in_hydra_as(teacher)
end

let(:headers) { { Authorization: UserProfileMock::TOKEN } }
let(:school) { create(:school) }
let(:teacher) { create(:teacher, school:, name: 'School Teacher') }
let!(:school_class) { create(:school_class, name: 'Test School Class', school:, teacher_ids: [teacher.id]) }

it 'responds 200 OK' do
post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:)
expect(response).to have_http_status(:ok)
end

it 'responds 200 OK when the user is the school-teacher for the class' do
authenticated_in_hydra_as(teacher)

post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:)
expect(response).to have_http_status(:ok)
end

it 'generates a new join code' do
old_join_code = school_class.join_code

post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:)
data = JSON.parse(response.body, symbolize_names: true)

expect(data[:join_code]).not_to eq(old_join_code)
expect(data[:join_code]).to match(/\A[A-Z]{4}\d{4}\z/)
end

it 'responds with the updated school class JSON including the new join code' do
old_join_code = school_class.join_code

post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:)
data = JSON.parse(response.body, symbolize_names: true)

expect(data[:id]).to eq(school_class.id)
expect(data[:name]).to eq('Test School Class')
expect(data[:join_code]).to be_present
expect(data[:join_code]).not_to eq(old_join_code)
end

it 'responds with the teacher JSON' do
post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:)
data = JSON.parse(response.body, symbolize_names: true)

expect(data[:teachers].first[:name]).to eq('School Teacher')
end

it 'responds 401 Unauthorized when no token is given' do
post "/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code"
expect(response).to have_http_status(:unauthorized)
end

it 'responds 403 Forbidden when the user is a school-owner for a different school' do
other_school = create(:school)
other_class = create(:school_class, school: other_school)

post("/api/schools/#{other_school.id}/classes/#{other_class.id}/regenerate_join_code", headers:)
expect(response).to have_http_status(:forbidden)
end

it 'responds 403 Forbidden when the user is not the school-teacher for the class' do
other_teacher = create(:teacher, school:)
authenticated_in_hydra_as(other_teacher)

post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:)
expect(response).to have_http_status(:forbidden)
end

it 'responds 403 Forbidden when the user is a school-student' do
student = create(:student, school:)
authenticated_in_hydra_as(student)

post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:)
expect(response).to have_http_status(:forbidden)
end
end
Loading
Loading