Merge branch 'main' into feature/386

このコミットが含まれているのは:
2026-07-07 23:59:02 +09:00
コミット d6c29a21ab
74個のファイルの変更8392行の追加686行の削除
+61
ファイルの表示
@@ -0,0 +1,61 @@
class UserSettingsController < ApplicationController
wrap_parameters false
def show
return head :unauthorized unless current_user
render json: current_setting.serializable_hash
end
def update
return head :unauthorized unless current_user
raw_attributes = editable_raw_attributes
field_errors = validate_raw_attributes(raw_attributes)
return render_validation_error fields: field_errors if field_errors.present?
setting = current_setting
setting.assign_attributes(raw_attributes)
if setting.save
render json: setting.serializable_hash, status: :ok
else
render_validation_error setting
end
end
private
def current_setting
Setting.find_or_create_by!(user: current_user) do |setting|
setting.assign_attributes(Setting.defaults)
end
rescue ActiveRecord::RecordNotUnique
Setting.find_by!(user: current_user)
end
def editable_raw_attributes
request.request_parameters.slice(*Setting::EDITABLE_ATTRIBUTES)
end
def validate_raw_attributes raw_attributes
raw_attributes.each_with_object({ }) do |(key, value), errors|
next if value_matches_type?(key, value)
errors[key.to_sym] = ['値の型が不正です.']
end
end
def value_matches_type? key, value
case Setting::TYPE_BY_ATTRIBUTE.fetch(key)
when :string
value.is_a?(String)
when :integer
value.is_a?(Integer)
when :boolean
value == true || value == false
else
false
end
end
end
+38
ファイルの表示
@@ -0,0 +1,38 @@
class UserThemeSlotsController < ApplicationController
wrap_parameters false
def index
return head :unauthorized unless current_user
render json: current_user
.theme_slots
.order(:base_theme, :slot_no)
.map { |slot| slot.serializable_hash }
end
def update
return head :unauthorized unless current_user
base_theme = params[:base_theme].to_s
slot_no = params[:slot_no].to_i
tokens = params[:tokens]
unless UserThemeSlot::BASE_THEMES.include?(base_theme)
return render_validation_error fields: { base_theme: ['値が不正です.'] }
end
unless UserThemeSlot::SLOT_NOS.include?(slot_no)
return render_validation_error fields: { slot_no: ['値が不正です.'] }
end
unless tokens.is_a?(ActionController::Parameters) || tokens.is_a?(Hash)
return render_validation_error fields: { tokens: ['JSON object で指定してください.'] }
end
slot = UserThemeSlot.find_or_initialize_by(user: current_user,
base_theme:,
slot_no:)
slot.tokens = tokens.is_a?(ActionController::Parameters) ? tokens.to_unsafe_h : tokens
slot.save!
render json: slot.serializable_hash, status: :ok
end
end
+47 -2
ファイルの表示
@@ -1,7 +1,52 @@
class Setting < ApplicationRecord
THEMES = ['system', 'light', 'dark'].freeze
# These remain in the typed settings schema to preserve existing backend
# work. The common `/users/settings` page currently surfaces only `theme`.
AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze
WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze
STRING_ATTRIBUTES = [
'theme',
'auto_fetch_title',
'auto_fetch_thumbnail',
'wiki_editor_mode',
].freeze
INTEGER_ATTRIBUTES = [].freeze
BOOLEAN_ATTRIBUTES = [].freeze
EDITABLE_ATTRIBUTES =
(STRING_ATTRIBUTES + INTEGER_ATTRIBUTES + BOOLEAN_ATTRIBUTES).freeze
TYPE_BY_ATTRIBUTE = {
'theme' => :string,
'auto_fetch_title' => :string,
'auto_fetch_thumbnail' => :string,
'wiki_editor_mode' => :string,
}.freeze
belongs_to :user
validates :user_id, presence: true
validates :key, presence: true, length: { maximum: 255 }
validates :value, presence: true
validates :user_id, uniqueness: true
validates :theme, inclusion: { in: THEMES }
validates :auto_fetch_title, inclusion: { in: AUTO_FETCH_MODES }
validates :auto_fetch_thumbnail, inclusion: { in: AUTO_FETCH_MODES }
validates :wiki_editor_mode, inclusion: { in: WIKI_EDITOR_MODES }
def self.defaults
{
theme: 'system',
auto_fetch_title: 'manual',
auto_fetch_thumbnail: 'manual',
wiki_editor_mode: 'split',
}
end
def self.serializable_attributes
EDITABLE_ATTRIBUTES.map(&:to_sym)
end
def serializable_hash(options = nil)
super({ only: self.class.serializable_attributes }.merge(options || { }))
end
end
+2 -1
ファイルの表示
@@ -7,7 +7,8 @@ class User < ApplicationRecord
has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
has_many :settings
has_one :setting, dependent: :destroy
has_many :theme_slots, class_name: 'UserThemeSlot', dependent: :destroy
has_many :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy
+27
ファイルの表示
@@ -0,0 +1,27 @@
class UserThemeSlot < ApplicationRecord
BASE_THEMES = ['light', 'dark'].freeze
SLOT_NOS = [1, 2, 3].freeze
belongs_to :user
validates :user_id, presence: true
validates :base_theme, presence: true, inclusion: { in: BASE_THEMES }
validates :slot_no, presence: true, inclusion: { in: SLOT_NOS }
validates :tokens, presence: true
validates :user_id, uniqueness: { scope: [:base_theme, :slot_no] }
validate :tokens_must_be_object
def serializable_hash(options = nil)
hash = { only: [:base_theme, :slot_no, :tokens, :created_at, :updated_at] }
super(hash.merge(options || {}))
end
private
def tokens_must_be_object
return if tokens.is_a?(Hash)
errors.add(:tokens, 'は JSON object で指定してください.')
end
end
+5
ファイルの表示
@@ -81,6 +81,11 @@ Rails.application.routes.draw do
end
end
get 'users/settings', to: 'user_settings#show'
patch 'users/settings', to: 'user_settings#update'
get 'users/theme_slots', to: 'user_theme_slots#index'
put 'users/theme_slots/:base_theme/:slot_no', to: 'user_theme_slots#update'
resources :users, only: [:create, :update] do
collection do
post :verify
+31
ファイルの表示
@@ -0,0 +1,31 @@
class RebuildSettingsAsTypedUserSettings < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :settings, :users if foreign_key_exists?(:settings, :users)
remove_index :settings, :user_id if index_exists?(:settings, :user_id)
remove_column :settings, :key, :string if column_exists?(:settings, :key)
remove_column :settings, :value, :json if column_exists?(:settings, :value)
change_column_null :settings, :user_id, false
add_column :settings, :theme, :string, null: false, default: 'system'
add_column :settings,
:auto_fetch_title,
:string,
null: false,
default: 'manual'
add_column :settings,
:auto_fetch_thumbnail,
:string,
null: false,
default: 'manual'
add_column :settings,
:wiki_editor_mode,
:string,
null: false,
default: 'split'
add_index :settings, :user_id, unique: true
add_foreign_key :settings, :users
end
end
+22
ファイルの表示
@@ -0,0 +1,22 @@
class CreateUserThemeSlots < ActiveRecord::Migration[8.0]
def change
create_table :user_theme_slots do |t|
t.references :user, null: false, foreign_key: true
t.string :base_theme, null: false
t.integer :slot_no, null: false
t.json :tokens, null: false
t.timestamps
end
add_index :user_theme_slots,
[:user_id, :base_theme, :slot_no],
unique: true,
name: 'index_user_theme_slots_on_user_theme_and_slot'
add_check_constraint :user_theme_slots,
"base_theme IN ('light', 'dark')",
name: 'user_theme_slots_base_theme_valid'
add_check_constraint :user_theme_slots,
'slot_no BETWEEN 1 AND 3',
name: 'user_theme_slots_slot_no_valid'
end
end
生成ファイル
+35 -19
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -374,11 +374,26 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "key", null: false
t.json "value", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_settings_on_user_id"
t.string "theme", default: "system", null: false
t.string "auto_fetch_title", default: "manual", null: false
t.string "auto_fetch_thumbnail", default: "manual", null: false
t.string "wiki_editor_mode", default: "split", null: false
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -426,9 +441,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.string "event_type", null: false
t.string "name", null: false
t.string "category", null: false
t.datetime "deprecated_at"
t.text "aliases", null: false
t.text "parent_tag_ids", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at"
@@ -441,10 +456,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false
t.datetime "deprecated_at"
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
@@ -566,6 +581,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.index ["post_id"], name: "index_user_post_views_on_post_id"
end
create_table "user_theme_slots", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "base_theme", null: false
t.integer "slot_no", null: false
t.json "tokens", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "base_theme", "slot_no"], name: "index_user_theme_slots_on_user_theme_and_slot", unique: true
t.index ["user_id"], name: "index_user_theme_slots_on_user_id"
t.check_constraint "`base_theme` in (_utf8mb4'light',_utf8mb4'dark')", name: "user_theme_slots_base_theme_valid"
t.check_constraint "`slot_no` between 1 and 3", name: "user_theme_slots_slot_no_valid"
end
create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name"
t.string "inheritance_code", limit: 64, null: false
@@ -576,19 +604,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
@@ -738,6 +753,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
add_foreign_key "user_ips", "users"
add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names"
+34
ファイルの表示
@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe Setting, type: :model do
it 'accepts the default typed user settings' do
setting = described_class.new({ user: create(:user) }.merge(described_class.defaults))
expect(setting).to be_valid
end
it 'requires one settings row per user' do
user = create(:user)
described_class.create!({ user: }.merge(described_class.defaults))
duplicate = described_class.new({ user: }.merge(described_class.defaults))
expect(duplicate).not_to be_valid
expect(duplicate.errors[:user_id]).to be_present
end
it 'validates enum-like settings columns' do
setting = described_class.new(
{ user: create(:user),
theme: 'neon',
auto_fetch_title: 'sometimes',
auto_fetch_thumbnail: 'sometimes',
wiki_editor_mode: 'sideways' })
expect(setting).not_to be_valid
expect(setting.errors[:theme]).to be_present
expect(setting.errors[:auto_fetch_title]).to be_present
expect(setting.errors[:auto_fetch_thumbnail]).to be_present
expect(setting.errors[:wiki_editor_mode]).to be_present
end
end
+40
ファイルの表示
@@ -0,0 +1,40 @@
require 'rails_helper'
RSpec.describe UserThemeSlot, type: :model do
it 'accepts a theme slot for one user, base theme, and slot number' do
slot = described_class.new(user: create(:user),
base_theme: 'light',
slot_no: 1,
tokens: { 'background' => '0 0% 100%' })
expect(slot).to be_valid
end
it 'requires unique slot numbers per user and base theme' do
user = create(:user)
described_class.create!(user:,
base_theme: 'dark',
slot_no: 2,
tokens: { 'background' => '222.2 84% 4.9%' })
duplicate = described_class.new(user:,
base_theme: 'dark',
slot_no: 2,
tokens: { 'background' => '0 0% 100%' })
expect(duplicate).not_to be_valid
expect(duplicate.errors[:user_id]).to be_present
end
it 'validates base theme, slot number, and token object shape' do
slot = described_class.new(user: create(:user),
base_theme: 'system',
slot_no: 4,
tokens: 'not-object')
expect(slot).not_to be_valid
expect(slot.errors[:base_theme]).to be_present
expect(slot.errors[:slot_no]).to be_present
expect(slot.errors[:tokens]).to be_present
end
end
+98
ファイルの表示
@@ -0,0 +1,98 @@
require 'rails_helper'
RSpec.describe 'user settings', type: :request do
describe 'GET /users/settings' do
it 'requires a current user' do
sign_out
get '/users/settings'
expect(response).to have_http_status(:unauthorized)
end
it 'returns defaults and creates a missing settings row' do
user = create(:user)
sign_in_as(user)
expect {
get '/users/settings'
}.to change(Setting, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json).to eq(
'theme' => 'system',
'auto_fetch_title' => 'manual',
'auto_fetch_thumbnail' => 'manual',
'wiki_editor_mode' => 'split')
expect(user.reload.setting).to be_present
end
end
describe 'PATCH /users/settings' do
it 'requires a current user' do
sign_out
patch '/users/settings', params: { theme: 'dark' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates multiple typed settings at once' do
user = create(:user)
sign_in_as(user)
patch '/users/settings',
params: {
theme: 'dark',
auto_fetch_title: 'auto',
auto_fetch_thumbnail: 'off',
wiki_editor_mode: 'preview' },
as: :json
expect(response).to have_http_status(:ok)
expect(json).to include(
'theme' => 'dark',
'auto_fetch_title' => 'auto',
'auto_fetch_thumbnail' => 'off',
'wiki_editor_mode' => 'preview')
expect(user.reload.setting).to have_attributes(
theme: 'dark',
auto_fetch_title: 'auto',
auto_fetch_thumbnail: 'off',
wiki_editor_mode: 'preview')
end
it 'does not treat Rails parameter wrapping as an editable setting' do
user = create(:user)
sign_in_as(user)
patch '/users/settings',
params: { theme: 'light', user_setting: { theme: 'dark' } },
as: :json
expect(response).to have_http_status(:ok)
expect(user.reload.setting.theme).to eq('light')
end
it 'returns validation_error for type mismatches' do
sign_in_as(create(:user))
patch '/users/settings', params: { theme: 1 }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors')).to include(
'theme' => ['値の型が不正です.'])
end
it 'returns validation_error for invalid enum values' do
sign_in_as(create(:user))
patch '/users/settings', params: { theme: 'neon' }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors').fetch('theme')).to be_present
end
end
end
+104
ファイルの表示
@@ -0,0 +1,104 @@
require 'rails_helper'
RSpec.describe 'user theme slots', type: :request do
describe 'GET /users/theme_slots' do
it 'requires a current user' do
sign_out
get '/users/theme_slots'
expect(response).to have_http_status(:unauthorized)
end
it 'returns only the current user slots in stable order' do
user = create(:user)
other_user = create(:user)
sign_in_as(user)
UserThemeSlot.create!(user:, base_theme: 'light', slot_no: 2, tokens: { 'a' => 1 })
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 1, tokens: { 'b' => 2 })
UserThemeSlot.create!(user: other_user,
base_theme: 'dark',
slot_no: 3,
tokens: { 'c' => 3 })
get '/users/theme_slots'
expect(response).to have_http_status(:ok)
expect(json.map { |slot| [slot.fetch('base_theme'), slot.fetch('slot_no')] }).to eq(
[['dark', 1], ['light', 2]])
expect(json.map { |slot| slot.fetch('tokens') }).to eq(
[{ 'b' => 2 }, { 'a' => 1 }])
end
end
describe 'PUT /users/theme_slots/:base_theme/:slot_no' do
it 'requires a current user' do
sign_out
put '/users/theme_slots/light/1',
params: { tokens: { background: '0 0% 100%' } },
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates a theme slot' do
user = create(:user)
sign_in_as(user)
expect {
put '/users/theme_slots/light/1',
params: { tokens: { background: '0 0% 100%', tagColours: { nico: '#ffffff' } } },
as: :json
}.to change(UserThemeSlot, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json).to include('base_theme' => 'light', 'slot_no' => 1)
expect(json.fetch('tokens')).to include(
'background' => '0 0% 100%',
'tagColours' => { 'nico' => '#ffffff' })
expect(user.theme_slots.find_by!(base_theme: 'light', slot_no: 1).tokens).to include(
'background' => '0 0% 100%')
end
it 'updates an existing theme slot instead of creating a duplicate' do
user = create(:user)
sign_in_as(user)
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 2, tokens: { 'old' => true })
expect {
put '/users/theme_slots/dark/2',
params: { tokens: { background: '222.2 84% 4.9%' } },
as: :json
}.not_to change(UserThemeSlot, :count)
expect(response).to have_http_status(:ok)
expect(user.theme_slots.find_by!(base_theme: 'dark', slot_no: 2).tokens).to eq(
'background' => '222.2 84% 4.9%')
end
it 'rejects invalid path parameters and token shapes' do
sign_in_as(create(:user))
put '/users/theme_slots/system/4',
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'base_theme' => ['値が不正です.'])
end
it 'rejects non-object tokens for valid slots' do
sign_in_as(create(:user))
put '/users/theme_slots/light/1',
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tokens' => ['JSON object で指定してください.'])
end
end
end