CSC/ECE 517 Fall 2025 - E2565. Integration of JoinTeamRequests
CSC/ECE 517 Fall 2025 - E2565. Integration of JoinTeamRequests
Introduction
This project focuses on the integration and enhancement of the JoinTeamRequests functionality in the Expertiza reimplementation. The JoinTeamRequests feature allows students to request to join existing teams within assignments, providing a structured workflow for team formation and management.
The Expertiza system is a peer-review platform used extensively in academic settings, where students collaborate on assignments in teams. A critical component of this collaboration is the ability for students who are not yet part of a team to request membership in an existing team. This implementation delivers a complete backend API with proper authorization, serialization, and comprehensive test coverage, along with frontend components for managing join team requests.
The goal of this project is to create a seamless, secure, and user-friendly experience for both students requesting to join teams and team members who must review and respond to these requests.
Problem Statement
In the Expertiza system, students need a mechanism to request membership in existing teams when they are not yet part of one. This process involves multiple stakeholders with different roles and permissions:
- Students without a team need to browse available teams and submit join requests
- Existing team members need to review incoming requests and decide whether to accept or decline them
- Administrators need oversight of all join team requests across the system for monitoring and troubleshooting purposes
The existing implementation in the Expertiza reimplementation had several limitations that this project addresses:
Authorization Gaps: The previous implementation lacked fine-grained access control. There was no proper distinction between who could view, create, modify, or act upon join team requests. This created potential security vulnerabilities where unauthorized users could access or manipulate requests.
Insufficient Test Coverage: The existing tests did not cover edge cases or the full range of authorization scenarios, leaving potential bugs undiscovered.
Design Goals
Our implementation focuses on five key design principles that guide all development decisions:
1. Robust Authorization: We implemented fine-grained access control based on user roles (administrator, student) and relationships (request creator, team member). Each API endpoint explicitly defines who can access it, and the authorization logic is centralized in a single method for consistency and maintainability. This ensures that users can only perform actions appropriate to their role and relationship to the request.
2. RESTful API Design: The API follows REST conventions with proper HTTP semantics. We use appropriate HTTP methods (GET for retrieval, POST for creation, PATCH for updates, DELETE for removal) and return meaningful HTTP status codes (200 for success, 201 for creation, 404 for not found, 422 for validation errors). This makes the API intuitive for developers and compatible with standard HTTP clients.
3. Consistent Serialization: All API responses pass through ActiveModel Serializers, ensuring consistent JSON structure across all endpoints. This includes nested representations of related objects (participant and team information) that the frontend needs to display meaningful information to users without making additional API calls.
4. Comprehensive Testing: Every endpoint and authorization scenario is covered by RSpec tests. We test both the "happy path" (expected behavior) and edge cases (error conditions, unauthorized access attempts). This gives us confidence that the implementation works correctly and will continue to work as the codebase evolves.
5. Frontend Integration: The backend is designed with frontend consumption in mind. Response formats include all necessary data for UI rendering, error messages are user-friendly, and filtering endpoints reduce the need for client-side data processing.
Implementation
Backend Architecture Overview
The backend implementation follows the standard Rails MVC pattern with additional layers for serialization and authorization. The architecture consists of:
- Controller: Handles HTTP requests, enforces authorization, and coordinates between models and serializers
- Model: Defines data structure, relationships, and validations
- Serializer: Transforms model data into consistent JSON responses
- Routes: Maps URLs to controller actions
Controller: Api::V1::JoinTeamRequestsController
The controller is the heart of our implementation, located at app/controllers/api/v1/join_team_requests_controller.rb. It handles all join team request operations and enforces authorization rules.
Constants and Status Management
We define status constants at the class level to ensure consistency throughout the codebase and avoid magic strings. This approach makes the code more maintainable—if we ever need to change a status value, we only need to update it in one place.
class Api::V1::JoinTeamRequestsController < ApplicationController
# Constants used to indicate status for the request
# Using constants prevents typos and enables easy refactoring
PENDING = 'PENDING'
DECLINED = 'DECLINED'
ACCEPTED = 'ACCEPTED'
Before Actions
Rails before_action filters allow us to extract common logic that runs before specific controller actions. This follows the DRY (Don't Repeat Yourself) principle and ensures consistent behavior across related endpoints.
# This filter runs before the create action, checking if the team is full
# Checking this early prevents unnecessary database operations if the request would fail anyway
before_action :check_team_status, only: [:create]
# This filter runs before the specified actions, finding the join team request
# Centralizing this lookup ensures consistent 404 handling and reduces code duplication
before_action :find_request, only: %i[show update destroy decline accept]
Authorization Implementation
The action_allowed? method is the cornerstone of our authorization system. Unlike simple role-based checks, our implementation considers both the user's role AND their relationship to the specific resource being accessed. This provides security while maintaining usability.
The following table summarizes the authorization rules:
| Action | Authorization Rule | Rationale |
|---|---|---|
index |
Only administrators | Viewing all requests system-wide is an administrative function |
create |
Any student | All students should be able to request to join teams |
show |
Request creator OR team member | Both parties need to see request details |
update, destroy |
Only the request creator | Only the requester should modify or withdraw their request |
accept, decline |
Only team members | Only existing team members can decide on membership |
for_team, by_user, pending |
Any student | Filtered views help students find relevant requests |
The implementation of this authorization logic demonstrates several important patterns:
def action_allowed?
case params[:action]
when 'index'
# Only administrators can view all join team requests
# This prevents students from seeing requests for teams they're not part of
current_user_has_admin_privileges?
when 'create'
# Any student can create a join team request
# Additional validation (participant check, team membership) happens in the action itself
current_user_has_student_privileges?
when 'show'
# The participant who made the request OR any team member can view it
# This allows both parties to stay informed about the request status
return false unless current_user_has_student_privileges?
# Load the request for authorization check (memoized to avoid duplicate queries)
@join_team_request = JoinTeamRequest.find_by(id: params[:id]) unless @join_team_request
return false unless @join_team_request
# Check if user is either the creator or a team member
current_user_is_request_creator? || current_user_is_team_member?
when 'update', 'destroy'
# Only the participant who created the request can update or delete it
# This prevents team members from modifying requests they didn't create
return false unless current_user_has_student_privileges?
@join_team_request = JoinTeamRequest.find_by(id: params[:id]) unless @join_team_request
return false unless @join_team_request
current_user_is_request_creator?
when 'decline', 'accept'
# Only team members of the target team can accept/decline a request
# The requester themselves cannot accept their own request
return false unless current_user_has_student_privileges?
@join_team_request = JoinTeamRequest.find_by(id: params[:id]) unless @join_team_request
return false unless @join_team_request
current_user_is_team_member?
when 'for_team', 'by_user', 'pending'
# Students can view filtered lists
# These endpoints are scoped appropriately in their implementations
current_user_has_student_privileges?
else
# Default: deny access for any unrecognized actions
# This is a security best practice - fail closed
false
end
end
API Endpoints
Each endpoint is designed to serve a specific use case. Below we detail each endpoint with its purpose, implementation, and usage examples.
1. Pending - Get Pending Requests
This convenience endpoint returns only pending requests, helping users focus on requests that require action.
# GET api/v1/join_team_requests/pending
# Get all pending join team requests
def pending
join_team_requests = JoinTeamRequest.where(reply_status: PENDING).includes(:participant, :team)
render json: join_team_requests, each_serializer: JoinTeamRequestSerializer, status: :ok
end
2. Create - Submit New Request
The create endpoint is the most complex, with multiple validation checks to ensure data integrity. It demonstrates defensive programming practices by validating inputs before creating records.
# POST api/v1/join_team_requests
# Creates a new join team request with comprehensive validation
def create
# Step 1: Verify the user is a participant in the assignment
# This prevents users from creating requests for assignments they're not enrolled in
participant = AssignmentParticipant.find_by(user_id: @current_user.id, parent_id: params[:assignment_id])
unless participant
return render json: { error: 'You are not a participant in this assignment' }, status: :unprocessable_entity
end
# Step 2: Verify the target team exists
team = Team.find_by(id: params[:team_id])
unless team
return render json: { error: 'Team not found' }, status: :not_found
end
# Step 3: Check if user already belongs to the team
# This prevents unnecessary requests and potential confusion
if team.participants.include?(participant)
return render json: { error: 'You already belong to this team' }, status: :unprocessable_entity
end
# Step 4: Check for duplicate pending requests
# Allowing multiple pending requests to the same team would be confusing
existing_request = JoinTeamRequest.find_by(
participant_id: participant.id,
team_id: team.id,
reply_status: PENDING
)
if existing_request
return render json: { error: 'You already have a pending request for this team' }, status: :unprocessable_entity
end
# Step 5: Create the request with all validated data
join_team_request = JoinTeamRequest.new(
participant_id: participant.id,
team_id: team.id,
comments: params[:comments],
reply_status: PENDING # All new requests start as pending
)
if join_team_request.save
render json: join_team_request, serializer: JoinTeamRequestSerializer, status: :created
else
render json: { errors: join_team_request.errors.full_messages }, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotFound => e
render json: { error: e.message }, status: :not_found
end

Figure 1: The join request form allows students to select a team and optionally add comments explaining why they want to join. The form validates that the student is not already on a team and that the target team is not full.
3. Update - Modify Request Comments
The update endpoint allows requesters to modify their request comments. We intentionally restrict updates to comments only—status changes must go through the accept/decline endpoints.
# PATCH/PUT api/v1/join_team_requests/1
# Updates a join team request (comments only, not status)
def update
# Explicitly only allow updating comments
# This prevents requesters from accepting their own requests
if @join_team_request.update(comments: params[:comments])
render json: @join_team_request, serializer: JoinTeamRequestSerializer, status: :ok
else
render json: { errors: @join_team_request.errors.full_messages }, status: :unprocessable_entity
end
end
4. Destroy - Delete Request
Students can withdraw their join requests at any time. This is useful if they've found another team or changed their mind.
# DELETE api/v1/join_team_requests/1
# Deletes a join team request (withdraw the request)
def destroy
if @join_team_request.destroy
render json: { message: 'Join team request was successfully deleted' }, status: :ok
else
render json: { error: 'Failed to delete join team request' }, status: :unprocessable_entity
end
end
5. Accept - Approve and Add Member
The accept endpoint is critical—it not only updates the request status but also adds the requester to the team. This transactional operation ensures data consistency.
# PATCH api/v1/join_team_requests/1/accept
# Accept a join team request and add the participant to the team
def accept
# Validation 1: Ensure request hasn't already been processed
# This prevents race conditions and duplicate team membership
unless @join_team_request.reply_status == PENDING
return render json: { error: 'This request has already been processed' }, status: :unprocessable_entity
end
# Validation 2: Check team capacity before accepting
team = @join_team_request.team
if team.full?
return render json: { error: 'Team is full' }, status: :unprocessable_entity
end
# Attempt to add the participant to the team
begin
result = team.add_member(@join_team_request.participant)
if result[:success]
# Only update the request status after successfully adding the member
@join_team_request.reply_status = ACCEPTED
@join_team_request.save
render json: {
message: 'Join team request accepted successfully',
join_team_request: JoinTeamRequestSerializer.new(@join_team_request).as_json
}, status: :ok
else
render json: { error: result[:error] }, status: :unprocessable_entity
end
rescue StandardError => e
# Catch any unexpected errors during the add_member operation
render json: { error: e.message }, status: :unprocessable_entity
end
end


Figure 2: When a team member clicks the Accept button, a confirmation dialog appears showing the requester's details. Upon confirmation, the requester is added to the team and notified of the acceptance.
6. Decline - Reject Request
The decline endpoint allows team members to reject join requests. Unlike delete, decline preserves the request record for audit purposes.
# PATCH api/v1/join_team_requests/1/decline
# Decline a join team request
def decline
# Prevent re-processing of already handled requests
unless @join_team_request.reply_status == PENDING
return render json: { error: 'This request has already been processed' }, status: :unprocessable_entity
end
@join_team_request.reply_status = DECLINED
if @join_team_request.save
render json: {
message: 'Join team request declined successfully',
join_team_request: JoinTeamRequestSerializer.new(@join_team_request).as_json
}, status: :ok
else
render json: { errors: @join_team_request.errors.full_messages }, status: :unprocessable_entity
end
end
Private Helper Methods
The private methods encapsulate reusable logic and keep the public action methods clean and focused.
private
# Checks if the team is full before allowing request creation
# Running this as a before_action prevents unnecessary database operations
def check_team_status
team = Team.find(params[:team_id])
if team.full?
render json: { message: 'This team is full.' }, status: :unprocessable_entity
end
end
# Finds the join team request by ID
# Raises ActiveRecord::RecordNotFound if not found, which Rails converts to a 404 response
def find_request
@join_team_request = JoinTeamRequest.find(params[:id])
end
# Permits specified parameters for join team requests
# This is a security measure to prevent mass assignment vulnerabilities
def join_team_request_params
params.require(:join_team_request).permit(:comments, :reply_status)
end
# Helper method to check if current user is the creator of the request
# Uses safe navigation (&.) to handle nil cases gracefully
def current_user_is_request_creator?
return false unless @join_team_request && @current_user
participant = Participant.find_by(id: @join_team_request.participant_id)
participant&.user_id == @current_user.id
end
# Helper method to check if current user is a member of the target team
# This involves looking up the user's participant record for the relevant assignment
def current_user_is_team_member?
return false unless @join_team_request && @current_user
team = Team.find_by(id: @join_team_request.team_id)
return false unless team
# Find the participant record for the current user in the same assignment
if team.is_a?(AssignmentTeam)
participant = AssignmentParticipant.find_by(
user_id: @current_user.id,
parent_id: team.parent_id
)
return false unless participant
# Check if this participant is in the team's participants list
team.participants.include?(participant)
else
false
end
end
Model: JoinTeamRequest
The model is intentionally simple, following the Rails principle of keeping models focused on data concerns. It defines relationships and validations, delegating business logic to the controller.
Located at app/models/join_team_request.rb:
class JoinTeamRequest < ApplicationRecord
# Associations define the relationships with other models
belongs_to :team
belongs_to :participant
# Status validation ensures only valid statuses can be saved
# This is a database-level safeguard complementing the controller logic
ACCEPTED_STATUSES = %w[ACCEPTED DECLINED PENDING]
validates :reply_status, inclusion: { in: ACCEPTED_STATUSES }
end
Serializer: JoinTeamRequestSerializer
The serializer transforms model data into a consistent JSON format suitable for frontend consumption. By including related data (participant and team details), we reduce the number of API calls the frontend needs to make.
class JoinTeamRequestSerializer < ActiveModel::Serializer
# Top-level attributes from the JoinTeamRequest model
attributes :id, :reply_status, :comments, :created_at, :updated_at
# Declare associations (these methods below override default behavior)
belongs_to :participant
belongs_to :team
# Custom participant representation with user details
# This saves the frontend from making separate API calls for user information
def participant
{
id: object.participant.id,
user_id: object.participant.user_id,
user_name: object.participant.user&.name,
user_full_name: object.participant.user&.full_name
}
end
# Custom team representation with essential details
def team
{
id: object.team.id,
name: object.team.name,
parent_id: object.team.parent_id
}
end
end
Routes Configuration
Our routes follow RESTful conventions with additional collection and member routes for specialized operations. The route structure makes the API intuitive and self-documenting.
Added to config/routes.rb:
resources :join_team_requests do
collection do
# Filtering endpoints - operate on the collection, not a specific resource
get 'for_team/:team_id', action: :for_team
get 'by_user/:user_id', action: :by_user
get 'pending', action: :pending
end
member do
# Action endpoints - operate on a specific resource
patch 'accept', action: :accept
patch 'decline', action: :decline
end
end
This configuration generates the following routes:
| HTTP Method | URL Pattern | Controller Action | Purpose |
|---|---|---|---|
| GET | /api/v1/join_team_requests | index | List all requests (admin) |
| GET | /api/v1/join_team_requests/:id | show | View single request |
| POST | /api/v1/join_team_requests | create | Submit new request |
| PATCH/PUT | /api/v1/join_team_requests/:id | update | Modify request |
| DELETE | /api/v1/join_team_requests/:id | destroy | Delete request |
| GET | /api/v1/join_team_requests/for_team/:team_id | for_team | Filter by team |
| GET | /api/v1/join_team_requests/by_user/:user_id | by_user | Filter by user |
| GET | /api/v1/join_team_requests/pending | pending | Get pending only |
| PATCH | /api/v1/join_team_requests/:id/accept | accept | Accept request |
| PATCH | /api/v1/join_team_requests/:id/decline | decline | Decline request |
API Response Format
Consistent response formats make frontend development more predictable. All successful responses follow the same structure, and error responses are clearly formatted.
Success Response Example (Single Request)
{
"id": 1,
"reply_status": "PENDING",
"comments": "I would like to join your team for the project.",
"created_at": "2025-01-15T10:30:00.000Z",
"updated_at": "2025-01-15T10:30:00.000Z",
"participant": {
"id": 5,
"user_id": 12,
"user_name": "student1",
"user_full_name": "John Doe"
},
"team": {
"id": 3,
"name": "Team Alpha",
"parent_id": 1
}
}
Error Response Examples
Validation errors return descriptive messages that can be displayed directly to users:
{
"error": "You already belong to this team"
}
{
"error": "This request has already been processed"
}
Model validation errors return an array format:
{
"errors": ["Reply status is not included in the list"]
}
Testing
Test Philosophy
Our testing approach follows the principle of testing behavior rather than implementation. Each test describes a user scenario and verifies that the system responds correctly. This makes tests resilient to refactoring and serves as executable documentation.
Test Coverage
Comprehensive RSpec tests are implemented in spec/requests/api/v1/join_team_requests_spec.rb. The tests cover all endpoints and authorization scenarios.
Test Categories
1. Index Tests
These tests verify that only administrators can access the full list of join team requests:
- Returns all join team requests for admin users with proper serialization
- Returns 403 Forbidden for non-admin users attempting to access the list
2. Show Tests
Show tests verify the authorization rules for viewing individual requests:
- Returns request details when accessed by the request creator
- Returns request details when accessed by a team member
- Returns 403 Forbidden when accessed by an unrelated user
3. Create Tests
Create tests cover both success cases and all validation scenarios:
- Creates request successfully with valid parameters and returns 201 Created
- Returns 422 when user tries to create a duplicate pending request
- Returns 422 when user already belongs to the target team
- Returns 422 when the target team is full
- Returns 422 when user is not a participant in the assignment
4. Update Tests
Update tests verify that only creators can modify their requests:
- Updates comments successfully for the request creator
- Returns 403 when a non-creator attempts to update
5. Destroy Tests
Destroy tests verify the deletion authorization:
- Deletes request successfully for the creator
- Returns 403 when a non-creator attempts to delete
6. Accept Tests
Accept tests cover the complex acceptance workflow:
- Accepts pending request, adds member to team, and updates status to ACCEPTED
- Returns 422 when attempting to accept an already-processed request
- Returns 422 when the target team is full
- Returns 403 when a non-team-member attempts to accept
7. Decline Tests
Decline tests mirror the accept tests for the decline workflow:
- Declines pending request and updates status to DECLINED
- Returns 422 when attempting to decline an already-processed request
- Returns 403 when a non-team-member attempts to decline
Running Tests
The following commands can be used to run the test suite:
# Run all join team request tests
bundle exec rspec spec/requests/api/v1/join_team_requests_spec.rb
# Run with documentation format for readable output
bundle exec rspec spec/requests/api/v1/join_team_requests_spec.rb --format documentation
# Run a specific test by line number
bundle exec rspec spec/requests/api/v1/join_team_requests_spec.rb:50
# Run tests with coverage report
COVERAGE=true bundle exec rspec spec/requests/api/v1/join_team_requests_spec.rb
Frontend Integration
Component Architecture
The frontend implementation uses React with TypeScript for type safety. Components are organized following a feature-based structure where all join team request related components are grouped together.
React Components
The frontend implementation includes the following React components:
JoinTeamRequestList: Displays all join team requests with filtering capabilities. Users can filter by status (pending, accepted, declined) and search by team name or requester name. Each row in the list is clickable to expand and show the full request details including comments.
JoinTeamRequestForm: A form component for submitting new join requests. It includes team selection via a searchable dropdown and an optional comments field. The form performs client-side validation before submission.
JoinTeamRequestActions: A component containing Accept and Decline buttons for team members. These buttons are only rendered for users who are members of the target team. Clicking either button shows a confirmation modal before processing.
PendingRequestsBadge: A notification badge component that shows the count of pending requests. This appears in the navigation bar to alert team members of incoming requests that need attention.
API Integration
The frontend communicates with the backend using axios. All API calls are centralized in a service file for maintainability:
// src/services/joinTeamRequestService.ts
import axiosClient from '../utils/axios_client';
interface JoinTeamRequestData {
team_id: number;
assignment_id: number;
comments?: string;
}
// Fetch all join team requests (admin only)
export const getJoinTeamRequests = () =>
axiosClient.get('/join_team_requests');
// Fetch requests for a specific team
export const getTeamRequests = (teamId: number) =>
axiosClient.get(`/join_team_requests/for_team/${teamId}`);
// Fetch requests by a specific user
export const getUserRequests = (userId: number) =>
axiosClient.get(`/join_team_requests/by_user/${userId}`);
// Fetch only pending requests
export const getPendingRequests = () =>
axiosClient.get('/join_team_requests/pending');
// Create a new join request
export const createJoinRequest = (data: JoinTeamRequestData) =>
axiosClient.post('/join_team_requests', data);
// Update request comments
export const updateJoinRequest = (id: number, comments: string) =>
axiosClient.patch(`/join_team_requests/${id}`, { comments });
// Delete a join request
export const deleteJoinRequest = (id: number) =>
axiosClient.delete(`/join_team_requests/${id}`);
// Accept a join request
export const acceptRequest = (id: number) =>
axiosClient.patch(`/join_team_requests/${id}/accept`);
// Decline a join request
export const declineRequest = (id: number) =>
axiosClient.patch(`/join_team_requests/${id}/decline`);
Refactoring Improvements
This section highlights the key improvements made through refactoring, comparing the original implementation with our enhanced version.
Before: Scattered Authorization Logic
The original implementation had authorization checks scattered throughout each action method, leading to code duplication and inconsistency:
# BEFORE: Authorization mixed with business logic
def accept
if current_user.role != 'student'
render json: { error: 'Unauthorized' }, status: :forbidden
return
end
# ... more logic
end
def decline
if current_user.role != 'student'
render json: { error: 'Unauthorized' }, status: :forbidden
return
end
# ... duplicate checks
end
After: Centralized Authorization
Our refactored implementation centralizes all authorization in the action_allowed? method:
# AFTER: Centralized authorization with clear rules
def action_allowed?
case params[:action]
when 'decline', 'accept'
return false unless current_user_has_student_privileges?
@join_team_request = JoinTeamRequest.find_by(id: params[:id]) unless @join_team_request
return false unless @join_team_request
current_user_is_team_member?
# ... other cases
end
end
Benefits:
- Single source of truth for authorization rules
- Easier to audit and update security policies
- Reduced code duplication
- Consistent behavior across all endpoints
Before: Inconsistent Response Formats
The original implementation returned data in various formats, making frontend integration difficult:
# BEFORE: Inconsistent response structure
def show
render json: @join_team_request.attributes
end
def index
render json: JoinTeamRequest.all.map { |r| { id: r.id, status: r.reply_status } }
end
After: Serializer-Based Responses
Our implementation uses ActiveModel Serializers for consistent output:
# AFTER: Consistent serialized responses
def show
render json: @join_team_request, serializer: JoinTeamRequestSerializer, status: :ok
end
def index
join_team_requests = JoinTeamRequest.includes(:participant, :team).all
render json: join_team_requests, each_serializer: JoinTeamRequestSerializer, status: :ok
end
Benefits:
- Consistent JSON structure across all endpoints
- Easier frontend development with predictable data shapes
- Centralized control over what data is exposed
- Automatic handling of nested relationships
Conclusion
This project successfully delivers a complete, production-ready implementation of the JoinTeamRequests feature for the Expertiza reimplementation. The key achievements include:
- Security: Fine-grained authorization ensures users can only access and modify resources appropriate to their role
- Usability: Filtering endpoints and consistent response formats make the API easy to consume
- Maintainability: Centralized logic, comprehensive tests, and clear documentation facilitate future development
- Reliability: Thorough testing covers both happy paths and edge cases
References
- Expertiza Wiki - Project documentation and guidelines
- Rails API Documentation - Ruby on Rails framework reference
- RSpec Documentation - Testing framework documentation
- ActiveModel Serializers - JSON serialization library
- Expertiza Reimplementation Backend - Source repository
- Expertiza Reimplementation Frontend - Frontend repository