diff --git a/backend/app/controllers/deerjikists_controller.rb b/backend/app/controllers/deerjikists_controller.rb new file mode 100644 index 0000000..b04eaf6 --- /dev/null +++ b/backend/app/controllers/deerjikists_controller.rb @@ -0,0 +1,47 @@ +class DeerjikistsController < ApplicationController + def show + platform = params[:platform].to_s.strip + code = params[:code].to_s.strip + return head :bad_request if platform.blank? || code.blank? + + deerjikist = Deerjikist + .joins(:tag) + .includes(tag: :tag_name) + .find_by(platform:, code:) + if deerjikist + render json: DeerjikistRepr.base(deerjikist) + else + head :not_found + end + end + + def update + return head :unauthorized unless current_user + return head :forbidden unless current_user.member? + + platform = params[:platform].to_s.strip + code = params[:code].to_s.strip + tag_id = params[:tag_id].to_i + return head :bad_request if platform.blank? || code.blank? || tag_id <= 0 + + deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d| + d.tag_id = tag_id + d.save! + end + + render json: DeerjikistRepr.base(deerjikist) + end + + def destroy + return head :unauthorized unless current_user + return head :forbidden unless current_user.member? + + platform = params[:platform].to_s.strip + code = params[:code].to_s.strip + return head :bad_request if platform.blank? || code.blank? + + Deerjikist.find([platform, code]).destroy! + + head :no_content + end +end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index c4d5b0d..186b6b9 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -86,6 +86,27 @@ class TagsController < ApplicationController end end + def deerjikists + tag = Tag.joins(:tag_name) + .includes(:tag_name, tag_name: :wiki_page) + .find_by(id: params[:id]) + return head :not_found unless tag + + render json: DeerjikistRepr.many(tag.deerjikists) + end + + def deerjikists_by_name + name = params[:name].to_s.strip + return head :bad_request if name.blank? + + tag = Tag.joins(:tag_name) + .includes(:tag_name, tag_name: :wiki_page) + .find_by(tag_names: { name: }) + return head :not_found unless tag + + render json: DeerjikistRepr.many(tag.deerjikists) + end + def update return head :unauthorized unless current_user return head :forbidden unless current_user.member? diff --git a/backend/app/models/deerjikist.rb b/backend/app/models/deerjikist.rb new file mode 100644 index 0000000..8447ef4 --- /dev/null +++ b/backend/app/models/deerjikist.rb @@ -0,0 +1,21 @@ +class Deerjikist < ApplicationRecord + self.primary_key = :platform, :code + + belongs_to :tag + + validates :platform, presence: true + validates :code, presence: true + validates :tag_id, presence: true + + validate :tag_must_be_deerjikist + + enum :platform, nico: 'nico', youtube: 'youtube' + + private + + def tag_must_be_deerjikist + if tag && !(tag.deerjikist?) + errors.add :tag, 'タグはニジラー・カテゴリである必要があります.' + end + end +end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index fa66f5c..83afbd3 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -24,6 +24,8 @@ class Tag < ApplicationRecord has_many :tag_similarities, dependent: :delete_all + has_many :deerjikists, dependent: :delete_all + belongs_to :tag_name delegate :wiki_page, to: :tag_name @@ -42,6 +44,7 @@ class Tag < ApplicationRecord validate :nico_tag_name_must_start_with_nico validate :tag_name_must_be_canonical + validate :category_must_be_deerjikist_with_deerjikists scope :nico_tags, -> { where(category: :nico) } @@ -154,4 +157,10 @@ class Tag < ApplicationRecord errors.add :tag_name, 'tag_names へは実体を示す必要があります.' end end + + def category_must_be_deerjikist_with_deerjikists + if !(deerjikist?) && deerjikists.exists? + errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' + end + end end diff --git a/backend/app/representations/deerjikist_repr.rb b/backend/app/representations/deerjikist_repr.rb new file mode 100644 index 0000000..fbb73c9 --- /dev/null +++ b/backend/app/representations/deerjikist_repr.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + + +module DeerjikistRepr + BASE = { only: [:platform, :code], include: { tag: TagRepr::BASE } }.freeze + + module_function + + def base deerjikist + deerjikist.as_json(BASE) + end + + def many deerjikists + deerjikists.map { |d| base(d) } + end +end diff --git a/backend/app/representations/WikiPageRepr.rb b/backend/app/representations/wiki_page_repr.rb similarity index 100% rename from backend/app/representations/WikiPageRepr.rb rename to backend/app/representations/wiki_page_repr.rb diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 0a52bf9..ed7b4ac 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -9,7 +9,15 @@ Rails.application.routes.draw do resources :tags, only: [:index, :show, :update] do collection do get :autocomplete - get 'name/:name', action: :show_by_name + + scope :name do + get ':name/deerjikists', action: :deerjikists_by_name + get ':name', action: :show_by_name + end + end + + member do + get :deerjikists end end @@ -54,4 +62,14 @@ Rails.application.routes.draw do post 'code/renew', action: :renew end end + + resources :deerjikists, only: [] do + collection do + scope ':platform/:code' do + get '', action: :show + put '', action: :update + delete '', action: :destroy + end + end + end end diff --git a/backend/db/migrate/20260303122700_create_deerjikists.rb b/backend/db/migrate/20260303122700_create_deerjikists.rb new file mode 100644 index 0000000..a87a963 --- /dev/null +++ b/backend/db/migrate/20260303122700_create_deerjikists.rb @@ -0,0 +1,10 @@ +class CreateDeerjikists < ActiveRecord::Migration[8.0] + def change + create_table :deerjikists, primary_key: [:platform, :code] do |t| + t.string :platform, null: false, limit: 16 + t.string :code, null: false + t.references :tag, null: false + t.timestamps + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 7a53c81..8077fac 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -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_01_27_005300) do +ActiveRecord::Schema[8.0].define(version: 2026_03_03_122700) 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 @@ -39,6 +39,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_27_005300) do t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "deerjikists", primary_key: ["platform", "code"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "platform", limit: 16, null: false + t.string "code", null: false + t.bigint "tag_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["tag_id"], name: "index_deerjikists_on_tag_id" + end + create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.binary "ip_address", limit: 16, null: false t.boolean "banned", default: false, null: false diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index ef37d06..e6b5893 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -47,7 +47,6 @@ namespace :nico do data = JSON.parse(stdout) data.each do |datum| code = datum['code'] - post = Post .where('url REGEXP ?', "nicovideo\\.jp/watch/#{ Regexp.escape(code) }([^0-9]|$)") @@ -94,14 +93,15 @@ namespace :nico do sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id]) end - kept_ids = PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set - kept_non_nico_ids = post.tags.where.not(category: 'nico').pluck(:id).to_set + kept_ids = post.tags.pluck(:id).to_set + kept_non_nico_ids = post.tags.not_nico.pluck(:id).to_set desired_nico_ids = [] desired_non_nico_ids = [] + datum['tags'].each do |raw| name = "nico:#{ raw }" - tag = Tag.find_or_create_by_tag_name!(name, category: 'nico') + tag = Tag.find_or_create_by_tag_name!(name, category: :nico) desired_nico_ids << tag.id unless tag.id.in?(kept_ids) linked_ids = tag.linked_tags.pluck(:id) @@ -109,6 +109,13 @@ namespace :nico do desired_nico_ids.concat(linked_ids) end end + + deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user']) + if deerjikist + desired_non_nico_ids << deerjikist.tag_id + desired_nico_ids << deerjikist.tag_id + end + desired_nico_ids.uniq! desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index 5d9530e..e2d1120 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -1,11 +1,11 @@ FactoryBot.define do factory :tag do - category { 'general' } + category { :general } post_count { 0 } association :tag_name trait :nico do - category { 'nico' } + category { :nico } tag_name { association(:tag_name, name: "nico:#{ SecureRandom.hex(4) }") } end end diff --git a/backend/spec/requests/deerjikists_spec.rb b/backend/spec/requests/deerjikists_spec.rb new file mode 100644 index 0000000..136fc2d --- /dev/null +++ b/backend/spec/requests/deerjikists_spec.rb @@ -0,0 +1,181 @@ +require 'rails_helper' + +RSpec.describe 'Deerjikists API', type: :request do + let(:platform) { 'nico' } + let(:code) { 'deerjika-bot' } + + let!(:tag1) { create(:tag, category: :deerjikist) } + let!(:tag2) { create(:tag, category: :deerjikist) } + + let(:member) { create(:user, :member) } + let(:guest) { create(:user, role: :guest) } + + describe 'GET /deerjikists/:platform/:code' do + subject(:do_request) do + get "/deerjikists/#{ platform }/#{ code }" + end + + context 'when deerjikist exists' do + before do + Deerjikist.create!(platform:, code:, tag: tag1) + end + + it 'returns 200 and deerjikist json' do + do_request + expect(response).to have_http_status(:ok) + + expect(json).to be_a(Hash) + expect(json['platform']).to eq(platform) + expect(json['code']).to eq(code) + + expect(json['tag']).to be_a(Hash) + expect(json['tag']['id']).to eq(tag1.id) + expect(json['tag']['name']).to eq(tag1.name) + end + end + + context 'when deerjikist does not exist' do + it 'returns 404' do + do_request + expect(response).to have_http_status(:not_found) + end + end + + context 'when platform or code become blank after strip' do + it 'returns 400' do + get '/deerjikists/%20/%20' + expect(response).to have_http_status(:bad_request) + end + end + end + + describe 'PUT /deerjikists/:platform/:code' do + subject(:do_request) do + put "/deerjikists/#{ platform }/#{ code }", params: payload + end + + let(:payload) { { tag_id: tag1.id } } + + context 'when not legged in' do + it 'returns 401' do + sign_out + do_request + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when logged in but not member' do + it 'returns 403' do + sign_in_as guest + do_request + expect(response).to have_http_status(:forbidden) + end + end + + context 'when member' do + before do + sign_in_as member + end + + context 'when params invalid' do + it 'returns 400 when tag_id is missing or invalid' do + put "/deerjikists/#{ platform }/#{ code }", params: { tag_id: 0 } + expect(response).to have_http_status(:bad_request) + end + + it 'returns 400 when platform or code blank after strip' do + put '/deerjikists/%20/%20', params: { tag_id: tag1.id } + expect(response).to have_http_status(:bad_request) + end + end + + context 'when creating new deerjikist' do + it 'creates and returns 200 with json' do + expect { do_request }.to change { Deerjikist.count }.by(1) + expect(response).to have_http_status(:ok) + + d = Deerjikist.find_by(platform:, code:) + expect(d).to be_present + expect(d.tag_id).to eq(tag1.id) + + expect(json['platform']).to eq(platform) + expect(json['code']).to eq(code) + end + end + + context 'when updating existing deerjikist' do + before do + Deerjikist.create!(platform:, code:, tag: tag1) + end + + let(:payload) { { tag_id: tag2.id } } + + it 'updates tag_id and returns 200' do + expect { do_request }.not_to change { Deerjikist.count } + expect(response).to have_http_status(:ok) + + d = Deerjikist.find_by(platform:, code:) + expect(d.tag_id).to eq(tag2.id) + + expect(json['platform']).to eq(platform) + expect(json['code']).to eq(code) + end + end + end + end + + describe 'DELETE /deerjikists/:platform/:code' do + subject(:do_request) do + delete "/deerjikists/#{ platform }/#{ code }" + end + + context 'when not logged in' do + it 'returns 401' do + sign_out + do_request + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when logged in but not member' do + it 'returns 403' do + sign_in_as guest + do_request + expect(response).to have_http_status(:forbidden) + end + end + + context 'when member' do + before do + sign_in_as member + end + + it 'returns 400 when platform/code blank after strip' do + delete '/deerjikists/%20/%20' + expect(response).to have_http_status(:bad_request) + end + + context 'when deerjikist exists' do + before do + Deerjikist.create!(platform: platform, code: code, tag: tag1) + end + + it 'destroys and returns 204' do + expect { + do_request + }.to change { Deerjikist.exists?(platform: platform, code: code) } + .from(true).to(false) + + expect(response).to have_http_status(:no_content) + end + end + + context 'when deerjikist does not exist' do + it 'returns 404' do + do_request + expect(response).to have_http_status(:not_found) + end + end + end + end +end diff --git a/backend/spec/requests/tags_deerjikists_spec.rb b/backend/spec/requests/tags_deerjikists_spec.rb new file mode 100644 index 0000000..4825f36 --- /dev/null +++ b/backend/spec/requests/tags_deerjikists_spec.rb @@ -0,0 +1,102 @@ +require 'rails_helper' + +RSpec.describe 'Tags deerjikists API', type: :request do + let(:platform1) { 'nico' } + let(:code1) { 'deerjika-bot' } + let(:platform2) { 'youtube' } + let(:code2) { 'deerjika-bot.bsky.social' } + + let!(:tag) { create(:tag, category: :deerjikist) } + + before do + # show_by_name / deerjikists_by_name 用に名前を固定 + tag.tag_name.update!(name: 'deerjika') + end + + describe 'GET /tags/:id/deerjikists' do + subject(:do_request) do + get "/tags/#{ tag_id }/deerjikists" + end + + let(:tag_id) { tag.id } + + context 'when tag exists and has no deerjikists' do + it 'returns 200 and empty array' do + do_request + expect(response).to have_http_status(:ok) + expect(json).to eq([]) + end + end + + context 'when tag exists and has deerjikists' do + before do + Deerjikist.create!(platform: platform1, code: code1, tag: tag) + Deerjikist.create!(platform: platform2, code: code2, tag: tag) + end + + it 'returns 200 and deerjikists array' do + do_request + expect(response).to have_http_status(:ok) + + expect(json).to be_a(Array) + expect(json.size).to eq(2) + + expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly( + [platform1, code1], + [platform2, code2], + ) + end + end + + context 'when tag does not exist' do + let(:tag_id) { 9_999_999 } + + it 'returns 404' do + do_request + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'GET /tags/name/:name/deerjikists' do + subject(:do_request) do + get "/tags/name/#{ name }/deerjikists" + end + + let(:name) { 'deerjika' } + + context 'when name is blank after strip' do + let(:name) { '%20' } + + it 'returns 400' do + do_request + expect(response).to have_http_status(:bad_request) + end + end + + context 'when tag does not exist for name' do + let(:name) { 'no-such-tag' } + + it 'returns 404' do + do_request + expect(response).to have_http_status(:not_found) + end + end + + context 'when tag exists and has deerjikists' do + before do + Deerjikist.create!(platform: platform1, code: code1, tag: tag) + end + + it 'returns 200 and deerjikists array' do + do_request + expect(response).to have_http_status(:ok) + + expect(json).to be_a(Array) + expect(json.size).to eq(1) + expect(json[0]['platform']).to eq(platform1) + expect(json[0]['code']).to eq(code1) + end + end + end +end