diff --git a/app/blueprints/api/v1/character_series_blueprint.rb b/app/blueprints/api/v1/character_series_blueprint.rb new file mode 100644 index 0000000..486af5c --- /dev/null +++ b/app/blueprints/api/v1/character_series_blueprint.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Api + module V1 + class CharacterSeriesBlueprint < ApiBlueprint + field :name do |cs| + { + en: cs.name_en, + ja: cs.name_jp + } + end + + fields :slug, :order + + view :full do + field :character_count do |cs| + cs.characters.count + end + end + end + end +end diff --git a/app/controllers/api/v1/character_series_controller.rb b/app/controllers/api/v1/character_series_controller.rb new file mode 100644 index 0000000..444f274 --- /dev/null +++ b/app/controllers/api/v1/character_series_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Api + module V1 + class CharacterSeriesController < Api::V1::ApiController + before_action :set_character_series, only: %i[show update destroy] + before_action :ensure_editor_role, only: %i[create update destroy] + + # GET /character_series + def index + character_series = CharacterSeries.ordered + render json: CharacterSeriesBlueprint.render(character_series) + end + + # GET /character_series/:id + def show + render json: CharacterSeriesBlueprint.render(@character_series, view: :full) + end + + # POST /character_series + def create + character_series = CharacterSeries.new(character_series_params) + + if character_series.save + render json: CharacterSeriesBlueprint.render(character_series, view: :full), status: :created + else + render_validation_error_response(character_series) + end + end + + # PATCH/PUT /character_series/:id + def update + if @character_series.update(character_series_params) + render json: CharacterSeriesBlueprint.render(@character_series, view: :full) + else + render_validation_error_response(@character_series) + end + end + + # DELETE /character_series/:id + def destroy + if @character_series.characters.exists? + render json: ErrorBlueprint.render(nil, error: { + message: 'Cannot delete series with associated characters', + code: 'has_dependencies' + }), status: :unprocessable_entity + else + @character_series.destroy! + head :no_content + end + end + + private + + def set_character_series + # Support lookup by slug or UUID + @character_series = CharacterSeries.find_by(slug: params[:id]) || CharacterSeries.find(params[:id]) + end + + def ensure_editor_role + return if current_user&.role && current_user.role >= 7 + + Rails.logger.warn "[CHARACTER_SERIES] Unauthorized access attempt by user #{current_user&.id}" + render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized + end + + def character_series_params + params.require(:character_series).permit(:name_en, :name_jp, :slug, :order) + end + end + end +end diff --git a/app/models/character_series.rb b/app/models/character_series.rb new file mode 100644 index 0000000..f3bc365 --- /dev/null +++ b/app/models/character_series.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CharacterSeries < ApplicationRecord + has_many :character_series_memberships, dependent: :destroy + has_many :characters, through: :character_series_memberships + + validates :name_en, presence: true + validates :name_jp, presence: true + validates :slug, presence: true, uniqueness: true + validates :order, numericality: { only_integer: true } + + scope :ordered, -> { order(:order) } + + # Slug constants for commonly referenced series + STANDARD = 'standard' + GRAND = 'grand' + ZODIAC = 'zodiac' + ETERNAL = 'eternal' + EVOKER = 'evoker' + SAINT = 'saint' + + def blueprint + CharacterSeriesBlueprint + end +end diff --git a/app/models/character_series_membership.rb b/app/models/character_series_membership.rb new file mode 100644 index 0000000..c46c910 --- /dev/null +++ b/app/models/character_series_membership.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class CharacterSeriesMembership < ApplicationRecord + belongs_to :character + belongs_to :character_series + + validates :character_id, uniqueness: { scope: :character_series_id } +end diff --git a/db/data/20251214104718_create_character_series_records.rb b/db/data/20251214104718_create_character_series_records.rb new file mode 100644 index 0000000..a29397e --- /dev/null +++ b/db/data/20251214104718_create_character_series_records.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class CreateCharacterSeriesRecords < ActiveRecord::Migration[8.0] + def up + character_series_data = [ + { order: 0, slug: 'standard', name_en: 'Standard', name_jp: 'スタンダード' }, + { order: 1, slug: 'grand', name_en: 'Grand', name_jp: 'リミテッド' }, + { order: 2, slug: 'zodiac', name_en: 'Zodiac', name_jp: '十二神将' }, + { order: 3, slug: 'promo', name_en: 'Promo', name_jp: 'プロモ' }, + { order: 4, slug: 'collab', name_en: 'Collab', name_jp: 'コラボ' }, + { order: 5, slug: 'eternal', name_en: 'Eternal', name_jp: '十天衆' }, + { order: 6, slug: 'evoker', name_en: 'Evoker', name_jp: '賢者' }, + { order: 7, slug: 'saint', name_en: 'Saint', name_jp: '六竜の使徒' }, + { order: 8, slug: 'fantasy', name_en: 'Fantasy', name_jp: 'ファンタジー' }, + { order: 9, slug: 'summer', name_en: 'Summer', name_jp: '水着' }, + { order: 10, slug: 'yukata', name_en: 'Yukata', name_jp: '浴衣' }, + { order: 11, slug: 'valentine', name_en: 'Valentine', name_jp: 'バレンタイン' }, + { order: 12, slug: 'halloween', name_en: 'Halloween', name_jp: 'ハロウィン' }, + { order: 13, slug: 'formal', name_en: 'Formal', name_jp: 'フォーマル' }, + { order: 14, slug: 'event', name_en: 'Event', name_jp: 'イベント' } + ] + + puts 'Creating character series records...' + character_series_data.each do |data| + cs = CharacterSeries.find_or_initialize_by(slug: data[:slug]) + cs.assign_attributes(data) + cs.save! + puts " #{cs.slug}: #{cs.name_en}" + end + + puts "\nCreated #{CharacterSeries.count} character series records" + end + + def down + CharacterSeries.delete_all + end +end diff --git a/db/data/20251214104719_migrate_character_series_to_memberships.rb b/db/data/20251214104719_migrate_character_series_to_memberships.rb new file mode 100644 index 0000000..8a8b740 --- /dev/null +++ b/db/data/20251214104719_migrate_character_series_to_memberships.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class MigrateCharacterSeriesToMemberships < ActiveRecord::Migration[8.0] + # Mapping from legacy integer values to slugs + LEGACY_TO_SLUG = { + 1 => 'standard', + 2 => 'grand', + 3 => 'zodiac', + 4 => 'promo', + 5 => 'collab', + 6 => 'eternal', + 7 => 'evoker', + 8 => 'saint', + 9 => 'fantasy', + 10 => 'summer', + 11 => 'yukata', + 12 => 'valentine', + 13 => 'halloween', + 14 => 'formal', + 15 => 'event' + }.freeze + + def up + # Build lookup hash: slug -> UUID + slug_to_uuid = CharacterSeries.pluck(:slug, :id).to_h + + migrated = 0 + memberships_created = 0 + + Character.where.not(series: []).find_each do |character| + character.series.each do |series_int| + slug = LEGACY_TO_SLUG[series_int] + next unless slug + + series_id = slug_to_uuid[slug] + next unless series_id + + CharacterSeriesMembership.find_or_create_by!( + character_id: character.id, + character_series_id: series_id + ) + memberships_created += 1 + end + migrated += 1 + end + + puts "Migrated #{migrated} characters, created #{memberships_created} memberships" + end + + def down + CharacterSeriesMembership.delete_all + end +end diff --git a/db/data/20251214193836_add_holiday_character_series.rb b/db/data/20251214193836_add_holiday_character_series.rb new file mode 100644 index 0000000..d87ef82 --- /dev/null +++ b/db/data/20251214193836_add_holiday_character_series.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddHolidayCharacterSeries < ActiveRecord::Migration[8.0] + def up + CharacterSeries.find_or_create_by!(slug: 'holiday') do |cs| + cs.order = 15 + cs.name_en = 'Holiday' + cs.name_jp = 'クリスマス' + end + puts 'Created holiday character series' + end + + def down + CharacterSeries.find_by(slug: 'holiday')&.destroy + end +end diff --git a/db/migrate/20251214104716_create_character_series.rb b/db/migrate/20251214104716_create_character_series.rb new file mode 100644 index 0000000..1f5e39a --- /dev/null +++ b/db/migrate/20251214104716_create_character_series.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateCharacterSeries < ActiveRecord::Migration[8.0] + def change + create_table :character_series, id: :uuid do |t| + t.string :name_en, null: false + t.string :name_jp, null: false + t.string :slug, null: false + t.integer :order, default: 0, null: false + end + + add_index :character_series, :slug, unique: true + add_index :character_series, :order + end +end diff --git a/db/migrate/20251214104717_create_character_series_memberships.rb b/db/migrate/20251214104717_create_character_series_memberships.rb new file mode 100644 index 0000000..da07681 --- /dev/null +++ b/db/migrate/20251214104717_create_character_series_memberships.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateCharacterSeriesMemberships < ActiveRecord::Migration[8.0] + def change + create_table :character_series_memberships, id: :uuid do |t| + t.uuid :character_id, null: false + t.uuid :character_series_id, null: false + t.timestamps + end + + add_index :character_series_memberships, :character_id + add_index :character_series_memberships, :character_series_id + add_index :character_series_memberships, %i[character_id character_series_id], + unique: true, name: 'idx_char_series_membership_unique' + + add_foreign_key :character_series_memberships, :characters + add_foreign_key :character_series_memberships, :character_series + end +end