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

1466 lines
No EOL
38 KiB
Markdown

# 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
```bash
rails generate migration CreateCrews
```
```ruby
# 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
```bash
rails generate migration CreateCrewMemberships
```
```ruby
# 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
```bash
rails generate migration CreateCrewInvitations
```
```ruby
# 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
```bash
rails generate migration CreateUniteAndFights
```
```ruby
# 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
```bash
rails generate migration CreateUnfScores
```
```ruby
# 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
```bash
rails generate migration AddCrewIdToUsers
```
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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**
```bash
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"}}'
```
2. **Generate invitation**
```bash
curl -X POST http://localhost:3000/api/v1/crews/CREW_ID/invitations \
-H "Authorization: Bearer YOUR_TOKEN"
```
3. **Join crew**
```bash
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"}'
```
4. **Promote to subcaptain**
```bash
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"}'
```
5. **Record UnF score**
```bash
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**
```ruby
def members_cache_key
"crew_#{id}_members_#{updated_at}"
end
```
2. **Background job for expired invitations cleanup**
```ruby
class CleanupExpiredInvitationsJob < ApplicationJob
def perform
CrewInvitation.expired.destroy_all
end
end
```
3. **Optimize UnF score queries**
```ruby
# 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