hensei-api/docs/implementation/crew-feature-implementation.md

38 KiB

Crew Feature Implementation Guide

Prerequisites

  • Rails 8.0.1 environment
  • PostgreSQL database
  • Existing user authentication system
  • Collection tracking feature implemented (for privacy integration)

Step-by-Step Implementation

Step 1: Database Migrations

1.1 Create Crews table

rails generate migration CreateCrews
# db/migrate/xxx_create_crews.rb
class CreateCrews < ActiveRecord::Migration[8.0]
  def change
    create_table :crews, id: :uuid do |t|
      t.string :name, null: false
      t.references :captain, type: :uuid, null: false, foreign_key: { to_table: :users }
      t.string :gamertag, limit: 4
      t.text :rules
      t.integer :member_count, default: 1, null: false

      t.timestamps
    end

    add_index :crews, :name, unique: true
    add_index :crews, :gamertag, unique: true, where: "gamertag IS NOT NULL"
    add_index :crews, :created_at
  end
end

1.2 Create CrewMemberships table

rails generate migration CreateCrewMemberships
# db/migrate/xxx_create_crew_memberships.rb
class CreateCrewMemberships < ActiveRecord::Migration[8.0]
  def change
    create_table :crew_memberships, id: :uuid do |t|
      t.references :crew, type: :uuid, null: false, foreign_key: true
      t.references :user, type: :uuid, null: false, foreign_key: true
      t.integer :role, default: 0, null: false # 0=member, 1=subcaptain, 2=captain
      t.boolean :display_gamertag, default: true, null: false
      t.datetime :joined_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false

      t.timestamps
    end

    add_index :crew_memberships, [:crew_id, :user_id], unique: true
    add_index :crew_memberships, :role
    add_index :crew_memberships, :joined_at

    # Add constraint to limit subcaptains to 3 per crew
    execute <<-SQL
      CREATE OR REPLACE FUNCTION check_subcaptain_limit() RETURNS TRIGGER AS $$
      BEGIN
        IF NEW.role = 1 THEN
          IF (SELECT COUNT(*) FROM crew_memberships
              WHERE crew_id = NEW.crew_id AND role = 1 AND id != NEW.id) >= 3 THEN
            RAISE EXCEPTION 'Maximum 3 subcaptains allowed per crew';
          END IF;
        END IF;
        RETURN NEW;
      END;
      $$ LANGUAGE plpgsql;

      CREATE TRIGGER enforce_subcaptain_limit
      BEFORE INSERT OR UPDATE ON crew_memberships
      FOR EACH ROW EXECUTE FUNCTION check_subcaptain_limit();
    SQL

    # Add constraint to limit crew size to 30 members
    execute <<-SQL
      CREATE OR REPLACE FUNCTION check_crew_member_limit() RETURNS TRIGGER AS $$
      BEGIN
        IF (SELECT COUNT(*) FROM crew_memberships WHERE crew_id = NEW.crew_id) >= 30 THEN
          RAISE EXCEPTION 'Maximum 30 members allowed per crew';
        END IF;
        RETURN NEW;
      END;
      $$ LANGUAGE plpgsql;

      CREATE TRIGGER enforce_crew_member_limit
      BEFORE INSERT ON crew_memberships
      FOR EACH ROW EXECUTE FUNCTION check_crew_member_limit();
    SQL
  end
end

1.3 Create CrewInvitations table

rails generate migration CreateCrewInvitations
# db/migrate/xxx_create_crew_invitations.rb
class CreateCrewInvitations < ActiveRecord::Migration[8.0]
  def change
    create_table :crew_invitations, id: :uuid do |t|
      t.references :crew, type: :uuid, null: false, foreign_key: true
      t.references :invited_by, type: :uuid, null: false, foreign_key: { to_table: :users }
      t.string :token, null: false
      t.datetime :expires_at, default: -> { "CURRENT_TIMESTAMP + INTERVAL '7 days'" }, null: false
      t.datetime :used_at
      t.references :used_by, type: :uuid, foreign_key: { to_table: :users }

      t.timestamps
    end

    add_index :crew_invitations, :token, unique: true
    add_index :crew_invitations, :expires_at
    add_index :crew_invitations, [:crew_id, :used_at]
  end
end

1.4 Create UniteAndFights table

rails generate migration CreateUniteAndFights
# db/migrate/xxx_create_unite_and_fights.rb
class CreateUniteAndFights < ActiveRecord::Migration[8.0]
  def change
    create_table :unite_and_fights, id: :uuid do |t|
      t.string :name, null: false
      t.integer :event_number, null: false
      t.datetime :starts_at, null: false
      t.datetime :ends_at, null: false
      t.references :created_by, type: :uuid, null: false, foreign_key: { to_table: :users }

      t.timestamps
    end

    add_index :unite_and_fights, :event_number, unique: true
    add_index :unite_and_fights, :starts_at
    add_index :unite_and_fights, :ends_at
    add_index :unite_and_fights, [:starts_at, :ends_at]
  end
end

1.5 Create UnfScores table

rails generate migration CreateUnfScores
# db/migrate/xxx_create_unf_scores.rb
class CreateUnfScores < ActiveRecord::Migration[8.0]
  def change
    create_table :unf_scores, id: :uuid do |t|
      t.references :unite_and_fight, type: :uuid, null: false, foreign_key: true
      t.references :crew, type: :uuid, null: false, foreign_key: true
      t.references :user, type: :uuid, null: false, foreign_key: true
      t.bigint :honors, default: 0, null: false
      t.references :recorded_by, type: :uuid, null: false, foreign_key: { to_table: :users }
      t.integer :day_number, null: false # 1-7 for each day of the event

      t.timestamps
    end

    add_index :unf_scores, [:unite_and_fight_id, :crew_id, :user_id, :day_number],
              unique: true, name: 'idx_unf_scores_unique'
    add_index :unf_scores, [:crew_id, :unite_and_fight_id]
    add_index :unf_scores, :honors

    # Validate day_number is between 1 and 7
    execute <<-SQL
      ALTER TABLE unf_scores
      ADD CONSTRAINT check_day_number
      CHECK (day_number >= 1 AND day_number <= 7);
    SQL
  end
end

1.6 Update Users table for crew association

rails generate migration AddCrewIdToUsers
# db/migrate/xxx_add_crew_id_to_users.rb
class AddCrewIdToUsers < ActiveRecord::Migration[8.0]
  def change
    # Note: We don't add crew_id directly to users table
    # The relationship is through crew_memberships table
    # This migration is for updating collection_viewable_by? logic

    # Update the collection_viewable_by method in User model to check crew membership
  end
end

Step 2: Create Models

2.1 Crew model

# app/models/crew.rb
class Crew < ApplicationRecord
  # Associations
  belongs_to :captain, class_name: 'User'
  has_many :crew_memberships, dependent: :destroy
  has_many :members, through: :crew_memberships, source: :user
  has_many :crew_invitations, dependent: :destroy
  has_many :unf_scores, dependent: :destroy

  # Scopes for specific roles
  has_many :subcaptains, -> { where(crew_memberships: { role: 1 }) },
           through: :crew_memberships, source: :user

  # Validations
  validates :name, presence: true, uniqueness: true,
            length: { minimum: 2, maximum: 30 }
  validates :gamertag, length: { is: 4 }, allow_blank: true,
            uniqueness: { case_sensitive: false },
            format: { with: /\A[A-Z0-9]+\z/i, message: "only alphanumeric characters allowed" }
  validates :rules, length: { maximum: 5000 }
  validates :member_count, numericality: { greater_than: 0, less_than_or_equal_to: 30 }

  # Callbacks
  after_create :create_captain_membership

  # Methods
  def full?
    member_count >= 30
  end

  def has_subcaptain_slots?
    crew_memberships.where(role: 1).count < 3
  end

  def active_invitations
    crew_invitations.where(used_at: nil).where('expires_at > ?', Time.current)
  end

  def subcaptain_count
    crew_memberships.where(role: 1).count
  end

  def blueprint
    CrewBlueprint
  end

  private

  def create_captain_membership
    crew_memberships.create!(
      user: captain,
      role: 2, # captain role
      display_gamertag: true
    )
  end
end

2.2 CrewMembership model

# app/models/crew_membership.rb
class CrewMembership < ApplicationRecord
  # Associations
  belongs_to :crew, counter_cache: :member_count
  belongs_to :user

  # Enums
  enum role: {
    member: 0,
    subcaptain: 1,
    captain: 2
  }

  # Validations
  validates :user_id, uniqueness: { scope: :crew_id,
    message: "is already a member of this crew" }
  validate :validate_subcaptain_limit, if: :subcaptain?
  validate :validate_single_crew_membership, on: :create

  # Scopes
  scope :officers, -> { where(role: [1, 2]) } # subcaptains and captain
  scope :by_join_date, -> { order(joined_at: :asc) }
  scope :displaying_gamertag, -> { where(display_gamertag: true) }

  # Callbacks
  before_validation :set_joined_at, on: :create

  def blueprint
    CrewMembershipBlueprint
  end

  private

  def validate_subcaptain_limit
    return unless role_changed? && subcaptain?

    if crew.subcaptain_count >= 3
      errors.add(:role, "Maximum 3 subcaptains allowed per crew")
    end
  end

  def validate_single_crew_membership
    if user.crew_membership.present?
      errors.add(:user, "is already a member of another crew")
    end
  end

  def set_joined_at
    self.joined_at ||= Time.current
  end
end

2.3 CrewInvitation model

# app/models/crew_invitation.rb
class CrewInvitation < ApplicationRecord
  # Associations
  belongs_to :crew
  belongs_to :invited_by, class_name: 'User'
  belongs_to :used_by, class_name: 'User', optional: true

  # Validations
  validates :token, presence: true, uniqueness: true
  validate :crew_not_full, on: :create

  # Callbacks
  before_validation :generate_token, on: :create
  before_create :set_expiration

  # Scopes
  scope :active, -> { where(used_at: nil).where('expires_at > ?', Time.current) }
  scope :expired, -> { where(used_at: nil).where('expires_at <= ?', Time.current) }
  scope :used, -> { where.not(used_at: nil) }

  def expired?
    expires_at < Time.current
  end

  def used?
    used_at.present?
  end

  def valid_for_use?
    !expired? && !used? && !crew.full?
  end

  def use_by!(user)
    return false unless valid_for_use?
    return false if user.crew_membership.present?

    transaction do
      update!(used_at: Time.current, used_by: user)
      crew.crew_memberships.create!(user: user, role: :member)
    end
    true
  rescue ActiveRecord::RecordInvalid
    false
  end

  def invitation_url
    "#{Rails.application.config.frontend_url}/crews/join?token=#{token}"
  end

  def blueprint
    CrewInvitationBlueprint
  end

  private

  def generate_token
    self.token ||= SecureRandom.urlsafe_base64(32)
  end

  def set_expiration
    self.expires_at ||= 7.days.from_now
  end

  def crew_not_full
    errors.add(:crew, "is already full") if crew.full?
  end
end

2.4 UniteAndFight model

# app/models/unite_and_fight.rb
class UniteAndFight < ApplicationRecord
  # Associations
  has_many :unf_scores, dependent: :destroy
  belongs_to :created_by, class_name: 'User'

  # Validations
  validates :name, presence: true
  validates :event_number, presence: true, uniqueness: true,
            numericality: { greater_than: 0 }
  validates :starts_at, presence: true
  validates :ends_at, presence: true
  validate :end_after_start
  validate :duration_is_one_week

  # Scopes
  scope :current, -> { where('starts_at <= ? AND ends_at >= ?', Time.current, Time.current) }
  scope :upcoming, -> { where('starts_at > ?', Time.current).order(starts_at: :asc) }
  scope :past, -> { where('ends_at < ?', Time.current).order(ends_at: :desc) }

  def active?
    starts_at <= Time.current && ends_at >= Time.current
  end

  def upcoming?
    starts_at > Time.current
  end

  def past?
    ends_at < Time.current
  end

  def day_number_for(date = Date.current)
    return nil unless date.between?(starts_at.to_date, ends_at.to_date)
    (date - starts_at.to_date).to_i + 1
  end

  def blueprint
    UniteAndFightBlueprint
  end

  private

  def end_after_start
    return unless starts_at && ends_at
    errors.add(:ends_at, "must be after start date") if ends_at <= starts_at
  end

  def duration_is_one_week
    return unless starts_at && ends_at
    duration = (ends_at - starts_at).to_i / 1.day
    errors.add(:base, "Event must last exactly 7 days") unless duration == 7
  end
end

2.5 UnfScore model

# app/models/unf_score.rb
class UnfScore < ApplicationRecord
  # Associations
  belongs_to :unite_and_fight
  belongs_to :crew
  belongs_to :user
  belongs_to :recorded_by, class_name: 'User'

  # Validations
  validates :honors, presence: true,
            numericality: { greater_than_or_equal_to: 0 }
  validates :day_number, presence: true,
            inclusion: { in: 1..7 }
  validates :user_id, uniqueness: {
    scope: [:unite_and_fight_id, :crew_id, :day_number],
    message: "already has a score for this day"
  }
  validate :user_is_crew_member
  validate :day_within_event

  # Scopes
  scope :for_event, ->(event) { where(unite_and_fight: event) }
  scope :for_crew, ->(crew) { where(crew: crew) }
  scope :for_user, ->(user) { where(user: user) }
  scope :by_day, ->(day) { where(day_number: day) }
  scope :total_honors, -> { sum(:honors) }

  # Class methods for aggregation
  def self.user_totals_for_event(event, crew)
    for_event(event)
      .for_crew(crew)
      .group(:user_id)
      .sum(:honors)
      .sort_by { |_user_id, honors| -honors }
  end

  def self.daily_totals_for_crew(event, crew)
    for_event(event)
      .for_crew(crew)
      .group(:day_number)
      .sum(:honors)
  end

  def blueprint
    UnfScoreBlueprint
  end

  private

  def user_is_crew_member
    return unless user && crew
    unless user.member_of?(crew)
      errors.add(:user, "must be a member of the crew")
    end
  end

  def day_within_event
    return unless unite_and_fight && day_number
    max_day = unite_and_fight.day_number_for(unite_and_fight.ends_at.to_date)
    if day_number > max_day
      errors.add(:day_number, "exceeds event duration")
    end
  end
end

2.6 Update User model

# app/models/user.rb - Add these associations and methods

# Associations
has_one :crew_membership, dependent: :destroy
has_one :crew, through: :crew_membership
has_many :captained_crews, class_name: 'Crew', foreign_key: :captain_id
has_many :crew_invitations_sent, class_name: 'CrewInvitation', foreign_key: :invited_by_id
has_many :unf_scores
has_many :recorded_unf_scores, class_name: 'UnfScore', foreign_key: :recorded_by_id

# Crew role checking methods
def captain_of?(crew)
  crew.captain_id == id
end

def subcaptain_of?(crew)
  crew_membership&.subcaptain? && crew_membership.crew_id == crew.id
end

def member_of?(crew)
  crew_membership&.crew_id == crew.id
end

def can_manage_crew?(crew)
  captain_of?(crew) || subcaptain_of?(crew)
end

def can_invite_to_crew?(crew)
  can_manage_crew?(crew)
end

def can_remove_from_crew?(crew)
  captain_of?(crew)
end

def can_record_unf_scores?(crew)
  can_manage_crew?(crew)
end

def in_same_crew_as?(other_user)
  return false unless other_user.present? && crew_membership.present?
  crew_membership.crew_id == other_user.crew_membership&.crew_id
end

# Update collection_viewable_by? to support crew_only privacy
def collection_viewable_by?(viewer)
  return true if self == viewer

  case collection_privacy
  when 'public'
    true
  when 'crew_only'
    viewer.present? && in_same_crew_as?(viewer)
  when 'private'
    false
  else
    false
  end
end

Step 3: Create Blueprints

3.1 CrewBlueprint

# app/blueprints/api/v1/crew_blueprint.rb
module Api
  module V1
    class CrewBlueprint < ApiBlueprint
      identifier :id

      fields :name, :gamertag, :rules, :member_count,
             :created_at, :updated_at

      association :captain, blueprint: UserBlueprint, view: :basic

      view :with_members do
        association :members, blueprint: UserBlueprint, view: :basic do |crew, options|
          crew.crew_memberships.includes(:user).map do |membership|
            {
              user: UserBlueprint.render_as_hash(membership.user, view: :basic),
              role: membership.role,
              joined_at: membership.joined_at,
              display_gamertag: membership.display_gamertag
            }
          end
        end
      end

      view :full do
        include_view :with_members
        field :subcaptain_slots_available do |crew|
          3 - crew.subcaptain_count
        end
        field :is_full do |crew|
          crew.full?
        end
      end
    end
  end
end

3.2 CrewMembershipBlueprint

# app/blueprints/api/v1/crew_membership_blueprint.rb
module Api
  module V1
    class CrewMembershipBlueprint < ApiBlueprint
      identifier :id

      fields :role, :display_gamertag, :joined_at,
             :created_at, :updated_at

      association :user, blueprint: UserBlueprint, view: :basic
      association :crew, blueprint: CrewBlueprint

      view :full do
        association :crew, blueprint: CrewBlueprint, view: :with_members
      end
    end
  end
end

3.3 CrewInvitationBlueprint

# app/blueprints/api/v1/crew_invitation_blueprint.rb
module Api
  module V1
    class CrewInvitationBlueprint < ApiBlueprint
      identifier :id

      fields :token, :expires_at, :used_at, :created_at

      field :invitation_url do |invitation|
        invitation.invitation_url
      end

      field :is_expired do |invitation|
        invitation.expired?
      end

      field :is_used do |invitation|
        invitation.used?
      end

      association :invited_by, blueprint: UserBlueprint, view: :basic
      association :used_by, blueprint: UserBlueprint, view: :basic,
                  if: ->(_, invitation, _) { invitation.used_by.present? }

      view :full do
        association :crew, blueprint: CrewBlueprint
      end
    end
  end
end

3.4 UniteAndFightBlueprint

# app/blueprints/api/v1/unite_and_fight_blueprint.rb
module Api
  module V1
    class UniteAndFightBlueprint < ApiBlueprint
      identifier :id

      fields :name, :event_number, :starts_at, :ends_at,
             :created_at, :updated_at

      field :status do |unf|
        if unf.active?
          'active'
        elsif unf.upcoming?
          'upcoming'
        else
          'past'
        end
      end

      field :current_day do |unf|
        unf.day_number_for(Date.current) if unf.active?
      end

      association :created_by, blueprint: UserBlueprint, view: :basic
    end
  end
end

3.5 UnfScoreBlueprint

# app/blueprints/api/v1/unf_score_blueprint.rb
module Api
  module V1
    class UnfScoreBlueprint < ApiBlueprint
      identifier :id

      fields :honors, :day_number, :created_at, :updated_at

      association :user, blueprint: UserBlueprint, view: :basic
      association :recorded_by, blueprint: UserBlueprint, view: :basic

      view :with_event do
        association :unite_and_fight, blueprint: UniteAndFightBlueprint
      end

      view :with_crew do
        association :crew, blueprint: CrewBlueprint
      end

      view :full do
        include_view :with_event
        include_view :with_crew
      end
    end
  end
end

Step 4: Create Controllers

4.1 CrewsController

# app/controllers/api/v1/crews_controller.rb
module Api
  module V1
    class CrewsController < ApiController
      before_action :authenticate_user!, except: [:show]
      before_action :set_crew, only: [:show, :update, :destroy]
      before_action :authorize_captain!, only: [:destroy]
      before_action :authorize_manager!, only: [:update]

      def create
        @crew = Crew.new(crew_params)
        @crew.captain = current_user

        if current_user.crew_membership.present?
          render json: { error: "You are already a member of a crew" },
                 status: :unprocessable_entity
          return
        end

        if @crew.save
          render json: CrewBlueprint.render(@crew, view: :full), status: :created
        else
          render_errors(@crew.errors)
        end
      end

      def show
        render json: CrewBlueprint.render(@crew, view: :full)
      end

      def update
        if @crew.update(crew_params)
          render json: CrewBlueprint.render(@crew, view: :full)
        else
          render_errors(@crew.errors)
        end
      end

      def destroy
        @crew.destroy
        head :no_content
      end

      def my
        authenticate_user!

        if current_user.crew
          render json: CrewBlueprint.render(current_user.crew, view: :full)
        else
          render json: { error: "You are not a member of any crew" },
                 status: :not_found
        end
      end

      private

      def set_crew
        @crew = Crew.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: "Crew not found" }, status: :not_found
      end

      def crew_params
        params.require(:crew).permit(:name, :rules, :gamertag)
      end

      def authorize_captain!
        unless current_user.captain_of?(@crew)
          render json: { error: "Only the captain can perform this action" },
                 status: :forbidden
        end
      end

      def authorize_manager!
        unless current_user.can_manage_crew?(@crew)
          render json: { error: "You don't have permission to manage this crew" },
                 status: :forbidden
        end
      end
    end
  end
end

4.2 CrewMembersController

# app/controllers/api/v1/crew_members_controller.rb
module Api
  module V1
    class CrewMembersController < ApiController
      before_action :authenticate_user!
      before_action :set_crew
      before_action :set_member, only: [:destroy]
      before_action :authorize_captain!, only: [:promote, :destroy]

      def index
        @memberships = @crew.crew_memberships
                           .includes(:user)
                           .by_join_date
                           .page(params[:page])
                           .per(params[:limit] || 30)

        render json: CrewMembershipBlueprint.render(
          @memberships,
          root: :members,
          meta: pagination_meta(@memberships)
        )
      end

      def promote
        @member = @crew.members.find(params[:user_id])
        @membership = @crew.crew_memberships.find_by(user: @member)

        if params[:role] == 'subcaptain'
          unless @crew.has_subcaptain_slots?
            render json: { error: "Maximum subcaptains reached" },
                   status: :unprocessable_entity
            return
          end

          @membership.subcaptain!
          render json: CrewMembershipBlueprint.render(@membership)
        elsif params[:role] == 'member'
          @membership.member!
          render json: CrewMembershipBlueprint.render(@membership)
        else
          render json: { error: "Invalid role" }, status: :unprocessable_entity
        end
      end

      def destroy
        if @member == @crew.captain
          render json: { error: "Cannot remove the captain" },
                 status: :unprocessable_entity
          return
        end

        @membership = @crew.crew_memberships.find_by(user: @member)
        @membership.destroy
        head :no_content
      end

      def update_me
        @membership = current_user.crew_membership

        unless @membership && @membership.crew_id == @crew.id
          render json: { error: "You are not a member of this crew" },
                 status: :forbidden
          return
        end

        if @membership.update(my_membership_params)
          render json: CrewMembershipBlueprint.render(@membership)
        else
          render_errors(@membership.errors)
        end
      end

      def leave
        @membership = current_user.crew_membership

        unless @membership && @membership.crew_id == @crew.id
          render json: { error: "You are not a member of this crew" },
                 status: :forbidden
          return
        end

        if @membership.captain?
          render json: { error: "Captain cannot leave the crew. Transfer ownership or disband the crew." },
                 status: :unprocessable_entity
          return
        end

        @membership.destroy
        head :no_content
      end

      private

      def set_crew
        @crew = Crew.find(params[:crew_id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: "Crew not found" }, status: :not_found
      end

      def set_member
        @member = User.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: "Member not found" }, status: :not_found
      end

      def my_membership_params
        params.permit(:display_gamertag)
      end

      def authorize_captain!
        unless current_user.captain_of?(@crew)
          render json: { error: "Only the captain can perform this action" },
                 status: :forbidden
        end
      end
    end
  end
end

4.3 CrewInvitationsController

# app/controllers/api/v1/crew_invitations_controller.rb
module Api
  module V1
    class CrewInvitationsController < ApiController
      before_action :authenticate_user!
      before_action :set_crew, except: [:join]
      before_action :authorize_inviter!, except: [:join]

      def create
        if @crew.full?
          render json: { error: "Crew is full" }, status: :unprocessable_entity
          return
        end

        @invitation = @crew.crew_invitations.build(invited_by: current_user)

        if @invitation.save
          render json: CrewInvitationBlueprint.render(@invitation, view: :full),
                 status: :created
        else
          render_errors(@invitation.errors)
        end
      end

      def index
        @invitations = @crew.active_invitations
                           .includes(:invited_by, :used_by)
                           .page(params[:page])
                           .per(params[:limit] || 20)

        render json: CrewInvitationBlueprint.render(
          @invitations,
          root: :invitations,
          meta: pagination_meta(@invitations)
        )
      end

      def destroy
        @invitation = @crew.crew_invitations.find(params[:id])

        if @invitation.used?
          render json: { error: "Cannot revoke a used invitation" },
                 status: :unprocessable_entity
          return
        end

        @invitation.destroy
        head :no_content
      end

      def join
        @invitation = CrewInvitation.find_by(token: params[:token])

        unless @invitation
          render json: { error: "Invalid invitation" }, status: :not_found
          return
        end

        unless @invitation.valid_for_use?
          error = if @invitation.expired?
                    "Invitation has expired"
                  elsif @invitation.used?
                    "Invitation has already been used"
                  else
                    "Crew is full"
                  end
          render json: { error: error }, status: :unprocessable_entity
          return
        end

        if current_user.crew_membership.present?
          render json: { error: "You are already a member of a crew" },
                 status: :unprocessable_entity
          return
        end

        if @invitation.use_by!(current_user)
          render json: CrewBlueprint.render(@invitation.crew, view: :full)
        else
          render json: { error: "Failed to join crew" },
                 status: :unprocessable_entity
        end
      end

      private

      def set_crew
        @crew = Crew.find(params[:crew_id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: "Crew not found" }, status: :not_found
      end

      def authorize_inviter!
        unless current_user.can_invite_to_crew?(@crew)
          render json: { error: "You don't have permission to manage invitations" },
                 status: :forbidden
        end
      end
    end
  end
end

4.4 UniteAndFightsController

# app/controllers/api/v1/unite_and_fights_controller.rb
module Api
  module V1
    class UniteAndFightsController < ApiController
      before_action :authenticate_user!, except: [:index, :show]
      before_action :require_admin!, only: [:create, :update, :destroy]
      before_action :set_unite_and_fight, only: [:show, :update, :destroy]

      def index
        @unite_and_fights = UniteAndFight.all.order(event_number: :desc)

        @unite_and_fights = case params[:status]
        when 'current'
          @unite_and_fights.current
        when 'upcoming'
          @unite_and_fights.upcoming
        when 'past'
          @unite_and_fights.past
        else
          @unite_and_fights
        end

        @unite_and_fights = @unite_and_fights.page(params[:page]).per(params[:limit] || 20)

        render json: UniteAndFightBlueprint.render(
          @unite_and_fights,
          root: :unite_and_fights,
          meta: pagination_meta(@unite_and_fights)
        )
      end

      def show
        render json: UniteAndFightBlueprint.render(@unite_and_fight)
      end

      def create
        @unite_and_fight = UniteAndFight.new(unite_and_fight_params)
        @unite_and_fight.created_by = current_user

        if @unite_and_fight.save
          render json: UniteAndFightBlueprint.render(@unite_and_fight),
                 status: :created
        else
          render_errors(@unite_and_fight.errors)
        end
      end

      def update
        if @unite_and_fight.update(unite_and_fight_params)
          render json: UniteAndFightBlueprint.render(@unite_and_fight)
        else
          render_errors(@unite_and_fight.errors)
        end
      end

      def destroy
        @unite_and_fight.destroy
        head :no_content
      end

      private

      def set_unite_and_fight
        @unite_and_fight = UniteAndFight.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: "Unite and Fight event not found" }, status: :not_found
      end

      def unite_and_fight_params
        params.require(:unite_and_fight).permit(:name, :event_number, :starts_at, :ends_at)
      end

      def require_admin!
        unless current_user.role >= 7
          render json: { error: "Admin access required" }, status: :forbidden
        end
      end
    end
  end
end

4.5 UnfScoresController

# app/controllers/api/v1/unf_scores_controller.rb
module Api
  module V1
    class UnfScoresController < ApiController
      before_action :authenticate_user!
      before_action :set_crew, except: [:performance]
      before_action :authorize_scorer!, only: [:create, :update]

      def create
        @unf_score = UnfScore.find_or_initialize_by(
          unite_and_fight_id: params[:unite_and_fight_id],
          crew_id: @crew.id,
          user_id: params[:user_id],
          day_number: params[:day_number]
        )

        @unf_score.honors = params[:honors]
        @unf_score.recorded_by = current_user

        if @unf_score.save
          render json: UnfScoreBlueprint.render(@unf_score, view: :full),
                 status: :created
        else
          render_errors(@unf_score.errors)
        end
      end

      def index
        @scores = @crew.unf_scores.includes(:user, :unite_and_fight, :recorded_by)

        if params[:unite_and_fight_id]
          @scores = @scores.where(unite_and_fight_id: params[:unite_and_fight_id])
        end

        if params[:user_id]
          @scores = @scores.where(user_id: params[:user_id])
        end

        if params[:day_number]
          @scores = @scores.where(day_number: params[:day_number])
        end

        @scores = @scores.order(day_number: :asc, honors: :desc)
                        .page(params[:page])
                        .per(params[:limit] || 50)

        render json: UnfScoreBlueprint.render(
          @scores,
          root: :scores,
          meta: pagination_meta(@scores)
        )
      end

      def performance
        authenticate_user!

        crew_id = params[:crew_id]
        unless crew_id
          render json: { error: "crew_id is required" }, status: :bad_request
          return
        end

        @crew = Crew.find(crew_id)

        # Check if user can view crew scores
        unless current_user.member_of?(@crew)
          render json: { error: "You must be a crew member to view scores" },
                 status: :forbidden
          return
        end

        # Build performance query
        scores = UnfScore.for_crew(@crew)

        if params[:user_id]
          scores = scores.for_user(params[:user_id])
        end

        if params[:from_date]
          from_date = Date.parse(params[:from_date])
          events = UniteAndFight.where('ends_at >= ?', from_date)
          scores = scores.where(unite_and_fight: events)
        end

        if params[:to_date]
          to_date = Date.parse(params[:to_date])
          events = UniteAndFight.where('starts_at <= ?', to_date)
          scores = scores.where(unite_and_fight: events)
        end

        # Group by event and aggregate
        performance_data = scores.includes(:unite_and_fight, :user)
                                 .group_by(&:unite_and_fight)
                                 .map do |event, event_scores|
          {
            event: UniteAndFightBlueprint.render_as_hash(event),
            total_honors: event_scores.sum(&:honors),
            daily_totals: event_scores.group_by(&:day_number)
                                     .transform_values { |s| s.sum(&:honors) },
            user_totals: event_scores.group_by(&:user)
                                    .transform_keys { |u| u.id }
                                    .transform_values { |s| s.sum(&:honors) }
          }
        end

        render json: { performance: performance_data }
      end

      private

      def set_crew
        @crew = Crew.find(params[:crew_id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: "Crew not found" }, status: :not_found
      end

      def authorize_scorer!
        unless current_user.can_record_unf_scores?(@crew)
          render json: { error: "You don't have permission to record scores" },
                 status: :forbidden
        end
      end
    end
  end
end

Step 5: Update Routes

# config/routes.rb - Add these routes within the API scope

# Crew management
resources :crews, only: [:create, :show, :update, :destroy] do
  collection do
    get 'my', to: 'crews#my'
  end

  # Crew members
  resources :members, controller: 'crew_members', only: [:index, :destroy] do
    collection do
      post 'promote'
      put 'me', to: 'crew_members#update_me'
      delete 'leave', to: 'crew_members#leave'
    end
  end

  # Invitations
  resources :invitations, controller: 'crew_invitations', only: [:create, :index, :destroy]

  # UnF scores for this crew
  resources :unf_scores, only: [:create, :index]
end

# Join crew via invitation
post 'crews/join', to: 'crew_invitations#join'

# Unite and Fight events
resources :unite_and_fights

# UnF score performance analytics
get 'unf_scores/performance', to: 'unf_scores#performance'

Step 6: Add Authorization Concerns

# app/controllers/concerns/crew_authorization_concern.rb
module CrewAuthorizationConcern
  extend ActiveSupport::Concern

  private

  def require_crew_membership
    unless current_user.crew_membership.present?
      render json: { error: "You must be a member of a crew" }, status: :forbidden
      false
    end
  end

  def require_crew_captain
    return false unless require_crew_membership

    unless current_user.crew_membership.captain?
      render json: { error: "Only the captain can perform this action" },
             status: :forbidden
      false
    end
  end

  def require_crew_manager
    return false unless require_crew_membership

    unless current_user.crew_membership.captain? || current_user.crew_membership.subcaptain?
      render json: { error: "Only captains and subcaptains can perform this action" },
             status: :forbidden
      false
    end
  end
end

Testing the Implementation

Manual Testing Steps

  1. Create a crew
curl -X POST http://localhost:3000/api/v1/crews \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"crew": {"name": "Test Crew", "gamertag": "TEST"}}'
  1. Generate invitation
curl -X POST http://localhost:3000/api/v1/crews/CREW_ID/invitations \
  -H "Authorization: Bearer YOUR_TOKEN"
  1. Join crew
curl -X POST http://localhost:3000/api/v1/crews/join \
  -H "Authorization: Bearer OTHER_USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"token": "INVITATION_TOKEN"}'
  1. Promote to subcaptain
curl -X POST http://localhost:3000/api/v1/crews/CREW_ID/members/promote \
  -H "Authorization: Bearer CAPTAIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "USER_ID", "role": "subcaptain"}'
  1. Record UnF score
curl -X POST http://localhost:3000/api/v1/crews/CREW_ID/unf_scores \
  -H "Authorization: Bearer CAPTAIN_OR_SUBCAPTAIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "unite_and_fight_id": "UNF_ID",
    "user_id": "MEMBER_ID",
    "honors": 1000000,
    "day_number": 1
  }'

Deployment Checklist

  • Run all migrations in order
  • Verify database constraints are created
  • Test crew size limits (30 members)
  • Test subcaptain limits (3 per crew)
  • Verify invitation expiration
  • Test UnF score recording
  • Verify collection privacy integration
  • Set up background job for invitation cleanup
  • Configure rate limiting for invitations
  • Update API documentation
  • Deploy frontend changes
  • Monitor for performance issues
  • Prepare rollback plan

Performance Optimizations

  1. Add caching for crew member lists
def members_cache_key
  "crew_#{id}_members_#{updated_at}"
end
  1. Background job for expired invitations cleanup
class CleanupExpiredInvitationsJob < ApplicationJob
  def perform
    CrewInvitation.expired.destroy_all
  end
end
  1. Optimize UnF score queries
# Add composite indexes for common query patterns
add_index :unf_scores, [:crew_id, :unite_and_fight_id, :user_id]
add_index :unf_scores, [:unite_and_fight_id, :honors]

Next Steps

  1. Implement crew feed functionality
  2. Add real-time notifications for crew events
  3. Create crew chat system
  4. Build crew discovery and recruitment features
  5. Add crew achievements and milestones
  6. Implement crew-vs-crew competitions
  7. Create mobile push notifications
  8. Add crew resource sharing system