Skip to content

Commit aec7f84

Browse files
committed
Add /api/join endpoint for student class enrollment
Add the student-facing half of the join-code flow: a JSON API the frontend can call to look up a class from its join code and enroll the current user. Mirrors the teacher_invitations pattern — backend is JSON-only, the frontend owns the public URL the user lands on. GET /api/join/:join_code is unauthenticated and returns { status, school, school_class } where status is one of unauthenticated, joinable, already_member, wrong_school, or domain_mismatch. The frontend uses status to decide what to show. POST /api/join/:join_code requires auth and performs enrollment. On success or for already_member it returns { redirect_url } (a locale-less path so the frontend can prepend the user's current locale); on a forbidden status it returns 403 with the status as the error. Membership creation is idempotent. The endpoint normalises join codes by uppercasing and stripping non-alphanumerics, so hyphenated forms like BAFA-1234 from copy-and-pasted share links resolve to the canonical BAFA1234.
1 parent 38732d3 commit aec7f84

4 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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
18+
render json: { redirect_url: class_redirect_path }, status: :ok
19+
else
20+
add_user_to_school_and_class
21+
render json: { redirect_url: class_redirect_path }, status: :ok
22+
end
23+
end
24+
25+
private
26+
27+
def find_school_and_class
28+
normalized = params[:join_code].to_s.upcase.gsub(/[^A-Z0-9]/, '')
29+
@school_class = SchoolClass.find_by!(join_code: normalized)
30+
@school = @school_class.school
31+
end
32+
33+
def show_status
34+
return :unauthenticated unless current_user
35+
36+
action_status
37+
end
38+
39+
def action_status
40+
@action_status ||= compute_action_status
41+
end
42+
43+
def compute_action_status
44+
return :already_member if user_is_member_of_class?
45+
return :joinable if user_is_student_of_school?
46+
return :not_a_student if user_has_non_student_role?
47+
return :wrong_school if user_in_different_school?
48+
return :domain_mismatch unless @school.valid_email?(current_user.email)
49+
50+
:joinable
51+
end
52+
53+
def class_redirect_path
54+
"/school/#{@school.code}/class/#{@school_class.code}"
55+
end
56+
57+
def user_is_member_of_class?
58+
ClassStudent.exists?(school_class: @school_class, student_id: current_user.id)
59+
end
60+
61+
def user_is_student_of_school?
62+
Role.exists?(school: @school, user_id: current_user.id, role: Role.roles[:student])
63+
end
64+
65+
def user_has_non_student_role?
66+
Role.where(user_id: current_user.id).where.not(role: Role.roles[:student]).exists?
67+
end
68+
69+
def user_in_different_school?
70+
Role.where(user_id: current_user.id).where.not(school_id: @school.id).exists?
71+
end
72+
73+
def add_user_to_school_and_class
74+
Role.create!(school: @school, user_id: current_user.id, role: :student) unless Role.exists?(school: @school, user_id: current_user.id)
75+
ClassStudent.create!(school_class: @school_class, student_id: current_user.id)
76+
end
77+
end
78+
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

config/routes.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@
101101

102102
resources :profile_auth_check, only: %i[index]
103103
resources :subscriptions, only: %i[create]
104+
105+
get '/join/:join_code', to: 'join#show'
106+
post '/join/:join_code', to: 'join#create'
104107
end
105108

106109
resource :github_webhooks, only: :create, defaults: { formats: :json }
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Join endpoint' do
6+
let(:school) { create(:school, code: '12-34-56') }
7+
let(:school_class) { create(:school_class, school:, join_code: 'BAFA2345') }
8+
let(:student) { create(:user, email: 'student@example.edu') }
9+
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
10+
11+
before do
12+
SchoolEmailDomain.create!(school:, domain: 'example.edu')
13+
end
14+
15+
describe 'GET /api/join/:join_code' do
16+
it 'responds with 404 when the join code does not exist' do
17+
get '/api/join/INVALID123'
18+
expect(response).to have_http_status(:not_found)
19+
end
20+
21+
it 'normalizes the join code by stripping non-alphanumerics' do
22+
school_class # force creation before the request
23+
24+
get '/api/join/BAFA-2345'
25+
26+
expect(response).to have_http_status(:ok)
27+
data = JSON.parse(response.body, symbolize_names: true)
28+
expect(data[:school_class][:code]).to eq(school_class.code)
29+
end
30+
31+
context 'when the user is not authenticated' do
32+
it 'returns status: unauthenticated with school and class details' do
33+
get "/api/join/#{school_class.join_code}"
34+
35+
expect(response).to have_http_status(:ok)
36+
data = JSON.parse(response.body, symbolize_names: true)
37+
expect(data[:status]).to eq('unauthenticated')
38+
expect(data[:school]).to include(code: school.code, name: school.name)
39+
expect(data[:school_class]).to include(code: school_class.code, name: school_class.name)
40+
end
41+
end
42+
43+
context 'when the user is authenticated' do
44+
before { authenticated_in_hydra_as(student) }
45+
46+
it 'returns status: joinable when the user can join' do
47+
get "/api/join/#{school_class.join_code}", headers: headers
48+
49+
data = JSON.parse(response.body, symbolize_names: true)
50+
expect(data[:status]).to eq('joinable')
51+
end
52+
53+
it 'returns status: already_member when the user is already in the class' do
54+
create(:student_role, school:, user_id: student.id)
55+
ClassStudent.create!(school_class:, student_id: student.id)
56+
57+
get "/api/join/#{school_class.join_code}", headers: headers
58+
59+
data = JSON.parse(response.body, symbolize_names: true)
60+
expect(data[:status]).to eq('already_member')
61+
end
62+
63+
it 'returns status: wrong_school when the user belongs to a different school' do
64+
other_school = create(:school)
65+
create(:student_role, school: other_school, user_id: student.id)
66+
67+
get "/api/join/#{school_class.join_code}", headers: headers
68+
69+
data = JSON.parse(response.body, symbolize_names: true)
70+
expect(data[:status]).to eq('wrong_school')
71+
end
72+
73+
it 'returns status: not_a_student when the user is a teacher of this school' do
74+
create(:teacher_role, school:, user_id: student.id)
75+
76+
get "/api/join/#{school_class.join_code}", headers: headers
77+
78+
data = JSON.parse(response.body, symbolize_names: true)
79+
expect(data[:status]).to eq('not_a_student')
80+
end
81+
82+
it 'returns status: not_a_student when the user is an owner of this school' do
83+
create(:owner_role, school:, user_id: student.id)
84+
85+
get "/api/join/#{school_class.join_code}", headers: headers
86+
87+
data = JSON.parse(response.body, symbolize_names: true)
88+
expect(data[:status]).to eq('not_a_student')
89+
end
90+
91+
it 'returns status: not_a_student for a teacher of a different school (not wrong_school)' do
92+
other_school = create(:school)
93+
create(:teacher_role, school: other_school, user_id: student.id)
94+
95+
get "/api/join/#{school_class.join_code}", headers: headers
96+
97+
data = JSON.parse(response.body, symbolize_names: true)
98+
expect(data[:status]).to eq('not_a_student')
99+
end
100+
101+
context 'when the email domain is not registered for the school' do
102+
let(:student) { create(:user, email: 'student@other.edu') }
103+
104+
it 'returns status: domain_mismatch' do
105+
get "/api/join/#{school_class.join_code}", headers: headers
106+
107+
data = JSON.parse(response.body, symbolize_names: true)
108+
expect(data[:status]).to eq('domain_mismatch')
109+
end
110+
111+
it 'returns status: joinable when the user is already a student of the school' do
112+
create(:student_role, school:, user_id: student.id)
113+
114+
get "/api/join/#{school_class.join_code}", headers: headers
115+
116+
data = JSON.parse(response.body, symbolize_names: true)
117+
expect(data[:status]).to eq('joinable')
118+
end
119+
end
120+
end
121+
end
122+
123+
describe 'POST /api/join/:join_code' do
124+
context 'when the user is not authenticated' do
125+
it 'responds with 401' do
126+
post "/api/join/#{school_class.join_code}"
127+
expect(response).to have_http_status(:unauthorized)
128+
end
129+
end
130+
131+
context 'when the user is authenticated' do
132+
before { authenticated_in_hydra_as(student) }
133+
134+
it 'adds the user to the school and class and returns a redirect URL' do
135+
expect do
136+
post "/api/join/#{school_class.join_code}", headers: headers
137+
end.to change(ClassStudent, :count).by(1).and change(Role, :count).by(1)
138+
139+
expect(response).to have_http_status(:ok)
140+
data = JSON.parse(response.body, symbolize_names: true)
141+
expect(data[:redirect_url]).to eq("/school/#{school.code}/class/#{school_class.code}")
142+
143+
created_role = Role.find_by(user_id: student.id, school:)
144+
expect(created_role.role).to eq('student')
145+
end
146+
147+
it 'is idempotent when the user is already in the class' do
148+
create(:student_role, school:, user_id: student.id)
149+
ClassStudent.create!(school_class:, student_id: student.id)
150+
151+
expect do
152+
post "/api/join/#{school_class.join_code}", headers: headers
153+
end.not_to change(ClassStudent, :count)
154+
155+
expect(response).to have_http_status(:ok)
156+
data = JSON.parse(response.body, symbolize_names: true)
157+
expect(data[:redirect_url]).to eq("/school/#{school.code}/class/#{school_class.code}")
158+
end
159+
160+
it 'does not duplicate the school role if the user is already in the school' do
161+
create(:student_role, school:, user_id: student.id)
162+
163+
expect do
164+
post "/api/join/#{school_class.join_code}", headers: headers
165+
end.to change(ClassStudent, :count).by(1)
166+
167+
expect(Role.where(user_id: student.id, school:).count).to eq(1)
168+
end
169+
170+
it 'responds with 403 wrong_school when the user belongs to a different school' do
171+
other_school = create(:school)
172+
create(:student_role, school: other_school, user_id: student.id)
173+
174+
post "/api/join/#{school_class.join_code}", headers: headers
175+
176+
expect(response).to have_http_status(:forbidden)
177+
data = JSON.parse(response.body, symbolize_names: true)
178+
expect(data[:error]).to eq('wrong_school')
179+
end
180+
181+
context 'when the email domain is not registered for the school' do
182+
let(:student) { create(:user, email: 'student@other.edu') }
183+
184+
it 'responds with 403 domain_mismatch and does not enroll the user' do
185+
expect do
186+
post "/api/join/#{school_class.join_code}", headers: headers
187+
end.not_to change(ClassStudent, :count)
188+
189+
expect(response).to have_http_status(:forbidden)
190+
data = JSON.parse(response.body, symbolize_names: true)
191+
expect(data[:error]).to eq('domain_mismatch')
192+
end
193+
194+
it 'enrolls the user when they are already a student of the school' do
195+
create(:student_role, school:, user_id: student.id)
196+
197+
expect do
198+
post "/api/join/#{school_class.join_code}", headers: headers
199+
end.to change(ClassStudent, :count).by(1)
200+
201+
expect(response).to have_http_status(:ok)
202+
data = JSON.parse(response.body, symbolize_names: true)
203+
expect(data[:redirect_url]).to eq("/school/#{school.code}/class/#{school_class.code}")
204+
end
205+
end
206+
207+
it 'responds with 403 not_a_student when the user is a teacher of the school' do
208+
create(:teacher_role, school:, user_id: student.id)
209+
210+
expect do
211+
post "/api/join/#{school_class.join_code}", headers: headers
212+
end.not_to change(ClassStudent, :count)
213+
214+
expect(response).to have_http_status(:forbidden)
215+
data = JSON.parse(response.body, symbolize_names: true)
216+
expect(data[:error]).to eq('not_a_student')
217+
end
218+
219+
it 'responds with 403 not_a_student when the user is an owner of the school' do
220+
create(:owner_role, school:, user_id: student.id)
221+
222+
post "/api/join/#{school_class.join_code}", headers: headers
223+
224+
expect(response).to have_http_status(:forbidden)
225+
data = JSON.parse(response.body, symbolize_names: true)
226+
expect(data[:error]).to eq('not_a_student')
227+
end
228+
229+
it 'responds with 404 when the join code does not exist' do
230+
post '/api/join/INVALID123', headers: headers
231+
expect(response).to have_http_status(:not_found)
232+
end
233+
end
234+
end
235+
end

0 commit comments

Comments
 (0)