| @@ -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 | |||||
| @@ -86,6 +86,27 @@ class TagsController < ApplicationController | |||||
| end | end | ||||
| 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 | def update | ||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless current_user.member? | return head :forbidden unless current_user.member? | ||||
| @@ -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 | |||||
| @@ -24,6 +24,8 @@ class Tag < ApplicationRecord | |||||
| has_many :tag_similarities, dependent: :delete_all | has_many :tag_similarities, dependent: :delete_all | ||||
| has_many :deerjikists, dependent: :delete_all | |||||
| belongs_to :tag_name | belongs_to :tag_name | ||||
| delegate :wiki_page, 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 :nico_tag_name_must_start_with_nico | ||||
| validate :tag_name_must_be_canonical | validate :tag_name_must_be_canonical | ||||
| validate :category_must_be_deerjikist_with_deerjikists | |||||
| scope :nico_tags, -> { where(category: :nico) } | scope :nico_tags, -> { where(category: :nico) } | ||||
| @@ -154,4 +157,10 @@ class Tag < ApplicationRecord | |||||
| errors.add :tag_name, 'tag_names へは実体を示す必要があります.' | errors.add :tag_name, 'tag_names へは実体を示す必要があります.' | ||||
| end | end | ||||
| end | end | ||||
| def category_must_be_deerjikist_with_deerjikists | |||||
| if !(deerjikist?) && deerjikists.exists? | |||||
| errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' | |||||
| end | |||||
| end | |||||
| end | end | ||||
| @@ -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 | |||||
| @@ -9,7 +9,15 @@ Rails.application.routes.draw do | |||||
| resources :tags, only: [:index, :show, :update] do | resources :tags, only: [:index, :show, :update] do | ||||
| collection do | collection do | ||||
| get :autocomplete | 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 | ||||
| end | end | ||||
| @@ -54,4 +62,14 @@ Rails.application.routes.draw do | |||||
| post 'code/renew', action: :renew | post 'code/renew', action: :renew | ||||
| end | end | ||||
| 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 | end | ||||
| @@ -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 | |||||
| @@ -10,7 +10,7 @@ | |||||
| # | # | ||||
| # It's strongly recommended that you check this file into your version control system. | # 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| | create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "name", null: false | t.string "name", null: false | ||||
| t.string "record_type", 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 | t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true | ||||
| end | 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| | create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.binary "ip_address", limit: 16, null: false | t.binary "ip_address", limit: 16, null: false | ||||
| t.boolean "banned", default: false, null: false | t.boolean "banned", default: false, null: false | ||||
| @@ -47,7 +47,6 @@ namespace :nico do | |||||
| data = JSON.parse(stdout) | data = JSON.parse(stdout) | ||||
| data.each do |datum| | data.each do |datum| | ||||
| code = datum['code'] | code = datum['code'] | ||||
| post = | post = | ||||
| Post | Post | ||||
| .where('url REGEXP ?', "nicovideo\\.jp/watch/#{ Regexp.escape(code) }([^0-9]|$)") | .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]) | sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id]) | ||||
| end | 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_nico_ids = [] | ||||
| desired_non_nico_ids = [] | desired_non_nico_ids = [] | ||||
| datum['tags'].each do |raw| | datum['tags'].each do |raw| | ||||
| name = "nico:#{ 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 | desired_nico_ids << tag.id | ||||
| unless tag.id.in?(kept_ids) | unless tag.id.in?(kept_ids) | ||||
| linked_ids = tag.linked_tags.pluck(:id) | linked_ids = tag.linked_tags.pluck(:id) | ||||
| @@ -109,6 +109,13 @@ namespace :nico do | |||||
| desired_nico_ids.concat(linked_ids) | desired_nico_ids.concat(linked_ids) | ||||
| end | end | ||||
| 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_nico_ids.uniq! | ||||
| desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids | desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids | ||||
| @@ -1,11 +1,11 @@ | |||||
| FactoryBot.define do | FactoryBot.define do | ||||
| factory :tag do | factory :tag do | ||||
| category { 'general' } | |||||
| category { :general } | |||||
| post_count { 0 } | post_count { 0 } | ||||
| association :tag_name | association :tag_name | ||||
| trait :nico do | trait :nico do | ||||
| category { 'nico' } | |||||
| category { :nico } | |||||
| tag_name { association(:tag_name, name: "nico:#{ SecureRandom.hex(4) }") } | tag_name { association(:tag_name, name: "nico:#{ SecureRandom.hex(4) }") } | ||||
| end | end | ||||
| end | end | ||||
| @@ -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 | |||||
| @@ -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 | |||||