add crew controllers, blueprints, routes, and errors

- CrewsController: create, show, update, members, leave, transfer_captain
- CrewMembershipsController: update, destroy, promote, demote
- CrewAuthorizationConcern for member/officer/captain checks
- blueprints for serialization
- custom error classes for crew operations
This commit is contained in:
Justin Edmund 2025-12-03 22:41:25 -08:00
parent 9b01aa0ff3
commit e98e59491d
7 changed files with 359 additions and 1 deletions

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
module Api
module V1
class CrewBlueprint < ApiBlueprint
fields :name, :gamertag, :granblue_crew_id, :description, :created_at
view :minimal do
fields :name, :gamertag
end
view :full do
fields :name, :gamertag, :granblue_crew_id, :description, :created_at
field :member_count do |crew|
crew.active_memberships.count
end
field :captain do |crew|
captain = crew.captain
UserBlueprint.render_as_hash(captain, view: :minimal) if captain
end
field :vice_captains do |crew|
UserBlueprint.render_as_hash(crew.vice_captains, view: :minimal)
end
end
view :with_members do
include_view :full
field :members do |crew|
CrewMembershipBlueprint.render_as_hash(
crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc),
view: :with_user
)
end
end
end
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Api
module V1
class CrewMembershipBlueprint < ApiBlueprint
fields :role, :retired, :retired_at, :created_at
view :with_user do
fields :role, :retired, :retired_at, :created_at
field :user do |membership|
UserBlueprint.render_as_hash(membership.user, view: :minimal)
end
end
view :with_crew do
fields :role, :retired, :retired_at, :created_at
field :crew do |membership|
CrewBlueprint.render_as_hash(membership.crew, view: :minimal)
end
end
end
end
end

View file

@ -0,0 +1,69 @@
# frozen_string_literal: true
module Api
module V1
class CrewMembershipsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew
before_action :set_membership, only: %i[update destroy promote demote]
before_action :authorize_crew_officer!, only: %i[destroy]
before_action :authorize_crew_captain!, only: %i[promote demote]
# PUT /crews/:crew_id/memberships/:id
def update
# Only captain can update roles
authorize_crew_captain!
if @membership.update(membership_params)
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
else
render_validation_error_response(@membership)
end
end
# DELETE /crews/:crew_id/memberships/:id
def destroy
raise CannotRemoveCaptainError if @membership.captain?
@membership.retire!
head :no_content
end
# POST /crews/:crew_id/memberships/:id/promote
def promote
raise CannotRemoveCaptainError if @membership.captain?
# Check vice captain limit
current_vc_count = @crew.crew_memberships.where(role: :vice_captain, retired: false).count
raise ViceCaptainLimitError if current_vc_count >= 3 && !@membership.vice_captain?
@membership.update!(role: :vice_captain)
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
end
# POST /crews/:crew_id/memberships/:id/demote
def demote
raise CannotRemoveCaptainError if @membership.captain?
@membership.update!(role: :member)
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
end
private
def set_crew
@crew = Crew.find(params[:crew_id])
end
def set_membership
@membership = @crew.crew_memberships.find(params[:id])
end
def membership_params
params.require(:membership).permit(:role)
end
end
end
end

View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
module Api
module V1
class CrewsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, only: %i[show update members leave transfer_captain]
before_action :authorize_crew_member!, only: %i[show members]
before_action :authorize_crew_officer!, only: %i[update]
before_action :authorize_crew_captain!, only: %i[transfer_captain]
# GET /crew or GET /crews/:id
def show
render json: CrewBlueprint.render(@crew, view: :full, root: :crew)
end
# POST /crews
def create
raise AlreadyInCrewError if current_user.crew.present?
@crew = Crew.new(crew_params)
ActiveRecord::Base.transaction do
@crew.save!
CrewMembership.create!(crew: @crew, user: current_user, role: :captain)
end
render json: CrewBlueprint.render(@crew, view: :full, root: :crew), status: :created
end
# PUT /crew
def update
if @crew.update(crew_params)
render json: CrewBlueprint.render(@crew, view: :full, root: :crew)
else
render_validation_error_response(@crew)
end
end
# GET /crew/members
def members
members = @crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc)
render json: CrewMembershipBlueprint.render(members, view: :with_user, root: :members)
end
# POST /crew/leave
def leave
raise NotInCrewError unless @crew
membership = current_user.active_crew_membership
raise CaptainCannotLeaveError if membership.captain?
membership.retire!
head :no_content
end
# POST /crews/:id/transfer_captain
def transfer_captain
new_captain_id = params[:user_id]
new_captain_membership = @crew.active_memberships.find_by(user_id: new_captain_id)
raise MemberNotFoundError unless new_captain_membership
ActiveRecord::Base.transaction do
current_user.active_crew_membership.update!(role: :vice_captain)
new_captain_membership.update!(role: :captain)
end
render json: CrewBlueprint.render(@crew.reload, view: :full, root: :crew)
end
private
def set_crew
@crew = if params[:id]
Crew.find(params[:id])
else
current_user&.crew
end
end
def crew_params
params.require(:crew).permit(:name, :gamertag, :granblue_crew_id, :description)
end
end
end
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
module CrewAuthorizationConcern
extend ActiveSupport::Concern
# Checks whether the current user is a member of the crew
def authorize_crew_member!
render_unauthorized_response unless current_user&.crew == @crew
end
# Checks whether the current user is an officer (captain or vice captain) of the crew
def authorize_crew_officer!
render_unauthorized_response unless current_user&.crew == @crew && current_user.crew_officer?
end
# Checks whether the current user is the captain of the crew
def authorize_crew_captain!
render_unauthorized_response unless current_user&.crew == @crew && current_user.crew_captain?
end
end

View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
module Api
module V1
class AlreadyInCrewError < GranblueError
def http_status
422
end
def code
'already_in_crew'
end
def message
'You are already in a crew'
end
end
class CaptainCannotLeaveError < GranblueError
def http_status
422
end
def code
'captain_cannot_leave'
end
def message
'Captain must transfer ownership before leaving'
end
end
class CannotRemoveCaptainError < GranblueError
def http_status
422
end
def code
'cannot_remove_captain'
end
def message
'Cannot remove the captain from the crew'
end
end
class ViceCaptainLimitError < GranblueError
def http_status
422
end
def code
'vice_captain_limit'
end
def message
'Crew can only have up to 3 vice captains'
end
end
class NotInCrewError < GranblueError
def http_status
422
end
def code
'not_in_crew'
end
def message
'You are not in a crew'
end
end
class MemberNotFoundError < GranblueError
def http_status
404
end
def code
'member_not_found'
end
def message
'Member not found in this crew'
end
end
end
end

View file

@ -147,7 +147,29 @@ Rails.application.routes.draw do
post 'parties/:id/grid_update', to: 'parties#grid_update'
delete 'favorites', to: 'favorites#destroy'
# Crews - current user's crew (no ID needed)
resource :crew, only: %i[show update], controller: 'crews' do
member do
get :members
post :leave
end
end
# Crews - create and manage by ID
resources :crews, only: %i[create] do
member do
post :transfer_captain
end
resources :memberships, controller: 'crew_memberships', only: %i[update destroy] do
member do
post :promote
post :demote
end
end
end
# Reading collections - works for any user with privacy check
scope 'users/:user_id' do
namespace :collection do
@ -163,16 +185,19 @@ Rails.application.routes.draw do
resources :characters, only: [:create, :update, :destroy], controller: '/api/v1/collection_characters' do
collection do
post :batch
post :import
end
end
resources :weapons, only: [:create, :update, :destroy], controller: '/api/v1/collection_weapons' do
collection do
post :batch
post :import
end
end
resources :summons, only: [:create, :update, :destroy], controller: '/api/v1/collection_summons' do
collection do
post :batch
post :import
end
end
resources :job_accessories, controller: '/api/v1/collection_job_accessories',