From e98e59491df969ba8a04cb2f510f0df233488a6a Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 22:41:25 -0800 Subject: [PATCH] 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 --- app/blueprints/api/v1/crew_blueprint.rb | 41 +++++++++ .../api/v1/crew_membership_blueprint.rb | 25 ++++++ .../api/v1/crew_memberships_controller.rb | 69 ++++++++++++++ app/controllers/api/v1/crews_controller.rb | 89 +++++++++++++++++++ .../concerns/crew_authorization_concern.rb | 20 +++++ app/errors/api/v1/crew_errors.rb | 89 +++++++++++++++++++ config/routes.rb | 27 +++++- 7 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 app/blueprints/api/v1/crew_blueprint.rb create mode 100644 app/blueprints/api/v1/crew_membership_blueprint.rb create mode 100644 app/controllers/api/v1/crew_memberships_controller.rb create mode 100644 app/controllers/api/v1/crews_controller.rb create mode 100644 app/controllers/concerns/crew_authorization_concern.rb create mode 100644 app/errors/api/v1/crew_errors.rb diff --git a/app/blueprints/api/v1/crew_blueprint.rb b/app/blueprints/api/v1/crew_blueprint.rb new file mode 100644 index 0000000..25aa390 --- /dev/null +++ b/app/blueprints/api/v1/crew_blueprint.rb @@ -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 diff --git a/app/blueprints/api/v1/crew_membership_blueprint.rb b/app/blueprints/api/v1/crew_membership_blueprint.rb new file mode 100644 index 0000000..f72fa5c --- /dev/null +++ b/app/blueprints/api/v1/crew_membership_blueprint.rb @@ -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 diff --git a/app/controllers/api/v1/crew_memberships_controller.rb b/app/controllers/api/v1/crew_memberships_controller.rb new file mode 100644 index 0000000..9aa73f4 --- /dev/null +++ b/app/controllers/api/v1/crew_memberships_controller.rb @@ -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 diff --git a/app/controllers/api/v1/crews_controller.rb b/app/controllers/api/v1/crews_controller.rb new file mode 100644 index 0000000..88b9113 --- /dev/null +++ b/app/controllers/api/v1/crews_controller.rb @@ -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 diff --git a/app/controllers/concerns/crew_authorization_concern.rb b/app/controllers/concerns/crew_authorization_concern.rb new file mode 100644 index 0000000..e0b0bea --- /dev/null +++ b/app/controllers/concerns/crew_authorization_concern.rb @@ -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 diff --git a/app/errors/api/v1/crew_errors.rb b/app/errors/api/v1/crew_errors.rb new file mode 100644 index 0000000..918ad43 --- /dev/null +++ b/app/errors/api/v1/crew_errors.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 7a8d8d4..983a7ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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',