add character series lookup table

This commit is contained in:
Justin Edmund 2025-12-14 11:58:10 -08:00
parent 1caffecdad
commit e7e9bd0f86
9 changed files with 267 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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