#339 親投稿機能 (#46)

Merged
みてるぞ merged 7 commits from feature/046 into main 3 days ago
  1. +2
    -2
      backend/app/controllers/nico_tags_controller.rb
  2. +59
    -8
      backend/app/controllers/posts_controller.rb
  3. +3
    -3
      backend/app/controllers/tags_controller.rb
  4. +30
    -5
      backend/app/models/post.rb
  5. +19
    -0
      backend/app/models/post_implication.rb
  6. +3
    -3
      backend/app/models/tag.rb
  7. +2
    -1
      backend/app/representations/post_repr.rb
  8. +1
    -1
      backend/app/services/post_version_recorder.rb
  9. +24
    -0
      backend/db/migrate/20260427214800_create_post_implications.rb
  10. +13
    -7
      backend/db/schema.rb
  11. +51
    -0
      backend/spec/models/post_implication_spec.rb
  12. +1
    -1
      backend/spec/models/post_version_spec.rb
  13. +1
    -1
      backend/spec/models/tag_spec.rb
  14. +458
    -88
      backend/spec/requests/posts_spec.rb
  15. +1
    -1
      backend/spec/tasks/nico_sync_spec.rb
  16. +36
    -11
      frontend/src/components/PostEditForm.tsx
  17. +5
    -2
      frontend/src/components/PostList.tsx
  18. +29
    -2
      frontend/src/pages/posts/PostDetailPage.tsx
  19. +32
    -0
      frontend/src/pages/posts/PostHistoryPage.tsx
  20. +12
    -0
      frontend/src/pages/posts/PostNewPage.tsx
  21. +8
    -1
      frontend/src/types.ts

+ 2
- 2
backend/app/controllers/nico_tags_controller.rb View File

@@ -33,8 +33,8 @@ class NicoTagsController < ApplicationController
return head :bad_request unless tag.nico?

linked_tag_names = params[:tags].to_s.split
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.nico? }

ApplicationRecord.transaction do


+ 59
- 8
backend/app/controllers/posts_controller.rb View File

@@ -109,7 +109,7 @@ class PostsController < ApplicationController

render json: PostRepr.base(post, current_user)
.merge(tags: build_tag_tree_for(post.tags),
related: post.related(limit: 20))
related: PostRepr.many(post.related(limit: 20)))
end

def create
@@ -123,28 +123,36 @@ class PostsController < ApplicationController
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids

post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail)
post.thumbnail.attach(thumbnail) if thumbnail.present?

ApplicationRecord.transaction do
post.save!
tags = Tag.normalise_tags(tag_names)

tags = Tag.normalise_tags!(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)

tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)

sync_parent_posts!(post, parent_post_ids)

post.resized_thumbnail!

PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end

post.reload
render json: PostRepr.base(post), status: :created
rescue ActiveRecord::RecordInvalid
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError
head :bad_request
rescue ArgumentError => e
render json: { errors: [e.message] }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end

def viewed
@@ -169,6 +177,7 @@ class PostsController < ApplicationController
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids

post = Post.find(params[:id].to_i)

@@ -177,12 +186,15 @@ class PostsController < ApplicationController

post.update!(title:, original_created_from:, original_created_before:)

normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false)
normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false)
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)

tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)

sync_parent_posts!(post, parent_post_ids)

PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end

@@ -190,10 +202,12 @@ class PostsController < ApplicationController
json = post.as_json
json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok
rescue ActiveRecord::RecordInvalid
render json: post.errors, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError
head :bad_request
rescue ArgumentError => e
render json: { errors: [e.message] }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end

def changes
@@ -353,4 +367,41 @@ class PostsController < ApplicationController

root_ids.filter_map { |id| build_node.call(id, []) }
end

def parse_parent_post_ids
raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)

params[:parent_post_ids].to_s.split.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0

id
}.uniq
end

def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add(:base, '自分自身を親投稿にはできません.')
raise ActiveRecord::RecordInvalid, post
end

existing_ids = Post.where(id: parent_post_ids).pluck(:id)
missing_ids = parent_post_ids - existing_ids

if missing_ids.present?
post.errors.add(:base, "存在しない親投稿 ID があります: #{ missing_ids.join(' ') }")
raise ActiveRecord::RecordInvalid, post
end

current_ids = post.parent_posts.pluck(:id)

ids_to_add = parent_post_ids - current_ids
ids_to_remove = current_ids - parent_post_ids

PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all

ids_to_add.each do |parent_post_id|
PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
end
end
end

+ 3
- 3
backend/app/controllers/tags_controller.rb View File

@@ -374,9 +374,9 @@ class TagsController < ApplicationController
end

def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)

old_parent_tags = tag.parents.to_a



+ 30
- 5
backend/app/models/post.rb View File

@@ -1,7 +1,6 @@
class Post < ApplicationRecord
require 'mini_magick'

belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
belongs_to :uploaded_user, class_name: 'User', optional: true

has_many :post_tags, dependent: :destroy, inverse_of: :post
@@ -13,6 +12,20 @@ class Post < ApplicationRecord
has_many :post_similarities, dependent: :delete_all
has_many :post_versions

has_many :parent_post_implications,
class_name: 'PostImplication',
foreign_key: :post_id,
dependent: :destroy,
inverse_of: :post
has_many :parents, through: :parent_post_implications, source: :parent_post

has_many :child_post_implications,
class_name: 'PostImplication',
foreign_key: :parent_post_id,
dependent: :destroy,
inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :post

has_one_attached :thumbnail

before_validation :normalise_url
@@ -22,17 +35,29 @@ class Post < ApplicationRecord
validate :validate_original_created_range
validate :url_must_be_http_url

def parent_posts = parents

def child_posts = children

def sibling_posts
parent_post_ids = parent_posts.order(:id).pluck(:id)

parent_post_ids.to_h { [_1, PostImplication.where(parent_post: _1).map(&:post)] }
end

def as_json options = { }
super(options).merge({ thumbnail: thumbnail.attached? ?
Rails.application.routes.url_helpers.rails_blob_url(
thumbnail, only_path: false) :
nil })
super(options).merge(thumbnail: thumbnail.attached? ?
Rails.application.routes.url_helpers.rails_blob_url(
thumbnail, only_path: false) :
nil)
rescue
super(options).merge(thumbnail: nil)
end

def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')

def snapshot_parent_post_ids = parents.order(:id).pluck(:id)

def related limit: nil
ids = post_similarities.order(cos: :desc)
ids = ids.limit(limit) if limit


+ 19
- 0
backend/app/models/post_implication.rb View File

@@ -0,0 +1,19 @@
class PostImplication < ApplicationRecord
self.primary_key = :post_id, :parent_post_id

belongs_to :post, inverse_of: :parent_post_implications
belongs_to :parent_post, class_name: 'Post', inverse_of: :child_post_implications

validates :post_id, presence: true, uniqueness: { scope: :parent_post_id }
validates :parent_post_id, presence: true

validate :parent_post_mustnt_be_itself

private

def parent_post_mustnt_be_itself
if parent_post_id == post_id
errors.add :parent_post_id, '親投稿に同じ投稿を設定することはできません.'
end
end
end

+ 3
- 3
backend/app/models/tag.rb View File

@@ -86,9 +86,9 @@ class Tag < ApplicationRecord
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)

def self.normalise_tags tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end


+ 2
- 1
backend/app/representations/post_repr.rb View File

@@ -2,7 +2,8 @@


module PostRepr
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE },
methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze

module_function



+ 1
- 1
backend/app/services/post_version_recorder.rb View File

@@ -24,7 +24,7 @@ class PostVersionRecorder < VersionRecorder
url: @record.url,
thumbnail_base: @record.thumbnail_base,
tags: @record.snapshot_tag_names.join(' '),
parent_id: @record.parent_id,
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before }
end


+ 24
- 0
backend/db/migrate/20260427214800_create_post_implications.rb View File

@@ -0,0 +1,24 @@
class CreatePostImplications < ActiveRecord::Migration[8.0]
def up
create_table :post_implications, primary_key: [:post_id, :parent_post_id] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :parent_post, null: false, foreign_key: { to_table: :posts }
t.timestamps

t.check_constraint 'post_id <> parent_post_id',
name: 'chk_post_implications_no_self'
end

add_column :post_versions, :parent_post_ids, :text, null: false, after: :parent_id
remove_column :post_versions, :parent_id, :bigint
remove_reference :posts, :parent, foreign_key: { to_table: :posts }
end

def down
add_reference :posts, :parent, foreign_key: { to_table: :posts }, after: :thumbnail_base
add_column :post_versions, :parent_id, :bigint, after: :post_id
remove_column :post_versions, :parent_post_ids, :text

drop_table :post_implications
end
end

+ 13
- 7
backend/db/schema.rb View File

@@ -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_04_26_120600) do
ActiveRecord::Schema[8.0].define(version: 2026_04_27_214800) 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
@@ -119,6 +119,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end

create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end

create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "target_post_id", null: false
@@ -155,13 +164,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000
t.text "tags", null: false
t.bigint "parent_id"
t.text "parent_post_ids", null: false
t.datetime "original_created_from"
t.datetime "original_created_before"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["parent_id"], name: "index_post_versions_on_parent_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
@@ -172,13 +180,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
t.string "title"
t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000
t.bigint "parent_id"
t.bigint "uploaded_user_id"
t.datetime "created_at", null: false
t.datetime "original_created_from"
t.datetime "original_created_before"
t.datetime "updated_at", null: false
t.index ["parent_id"], name: "index_posts_on_parent_id"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true
end
@@ -428,6 +434,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tags", "posts"
@@ -435,9 +443,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do
add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "posts", column: "parent_id"
add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "posts", column: "parent_id"
add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags"


+ 51
- 0
backend/spec/models/post_implication_spec.rb View File

@@ -0,0 +1,51 @@
require 'rails_helper'

RSpec.describe PostImplication, type: :model do
let!(:post_record) do
Post.create!(
title: 'post',
url: 'https://example.com/post-implication-post'
)
end

let!(:parent_post) do
Post.create!(
title: 'parent post',
url: 'https://example.com/post-implication-parent'
)
end

it 'is valid with post and parent_post' do
implication = described_class.new(
post: post_record,
parent_post:
)

expect(implication).to be_valid
end

it 'does not allow same post as parent_post' do
implication = described_class.new(
post: post_record,
parent_post: post_record
)

expect(implication).not_to be_valid
expect(implication.errors[:parent_post_id]).to be_present
end

it 'does not allow duplicate pair' do
described_class.create!(
post: post_record,
parent_post:
)

duplicate = described_class.new(
post: post_record,
parent_post:
)

expect(duplicate).not_to be_valid
expect(duplicate.errors[:post_id]).to be_present
end
end

+ 1
- 1
backend/spec/models/post_version_spec.rb View File

@@ -19,7 +19,7 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url,
thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '),
parent: post_record.parent,
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before,
created_at: Time.current,


+ 1
- 1
backend/spec/models/tag_spec.rb View File

@@ -161,7 +161,7 @@ RSpec.describe Tag, type: :model do
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,


+ 458
- 88
backend/spec/requests/posts_spec.rb View File

@@ -15,6 +15,31 @@ RSpec.describe 'Posts API', type: :request do
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
end

def post_write_params params = { }
{ parent_post_ids: '' }.merge(params)
end

def create_parent_post! title:, url:
Post.create!(title:, url:)
end

def create_post_version_for! post
PostVersion.create!(
post:,
version_no: 1,
event_type: 'create',
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: post.snapshot_tag_names.join(' '),
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: post.created_at,
created_by_user: post.uploaded_user
)
end

let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }

@@ -457,6 +482,65 @@ RSpec.describe 'Posts API', type: :request do
expect(json).to have_key('viewed')
expect([true, false]).to include(json['viewed'])
end

context 'when post has parent, child, and sibling posts' do
let!(:parent_post) do
create_parent_post!(
title: 'shared parent post',
url: 'https://example.com/shared-parent-post'
)
end

let!(:child_post) do
Post.create!(
title: 'child post',
url: 'https://example.com/show-child-post'
)
end

let!(:sibling_post) do
Post.create!(
title: 'sibling post',
url: 'https://example.com/show-sibling-post'
)
end

before do
PostImplication.create!(
post: post_record,
parent_post:
)

PostImplication.create!(
post: child_post,
parent_post: post_record
)

PostImplication.create!(
post: sibling_post,
parent_post:
)
end

it 'returns parent_posts, child_posts, and sibling_posts' do
get "/posts/#{post_record.id}"

expect(response).to have_http_status(:ok)

parent_ids = json.fetch('parent_posts').map { |p| p.fetch('id') }
child_ids = json.fetch('child_posts').map { |p| p.fetch('id') }

expect(parent_ids).to include(parent_post.id)
expect(child_ids).to include(child_post.id)

sibling_posts_by_parent = json.fetch('sibling_posts')
siblings = sibling_posts_by_parent.fetch(parent_post.id.to_s)

sibling_ids = siblings.map { |p| p.fetch('id') }
expect(sibling_ids).to include(post_record.id)
expect(sibling_ids).to include(sibling_post.id)
end
end
end

context 'when post does not exist' do
@@ -475,25 +559,28 @@ RSpec.describe 'Posts API', type: :request do

it '401 when not logged in' do
sign_out
post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload }
post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a',
thumbnail: dummy_upload)

expect(response).to have_http_status(:unauthorized)
end

it '403 when not member' do
sign_in_as(create(:user, role: 'guest'))
post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload }
post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a',
thumbnail: dummy_upload)
expect(response).to have_http_status(:forbidden)
end

it '201 and creates post + tags when member' do
sign_in_as(member)

post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post',
url: 'https://example.com/new',
tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload
}
)

expect(response).to have_http_status(:created)
expect(json).to include('id', 'title', 'url')
@@ -507,12 +594,12 @@ RSpec.describe 'Posts API', type: :request do
it '201 and creates post + tags when member and tags have aliases' do
sign_in_as(member)

post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post',
url: 'https://example.com/new',
tags: 'manko', # 既存タグ名を投げる
thumbnail: dummy_upload
}
)

expect(response).to have_http_status(:created)
expect(json).to include('id', 'title', 'url')
@@ -533,13 +620,14 @@ RSpec.describe 'Posts API', type: :request do
it 'return 400' do
sign_in_as(member)

post '/posts', params: {
title: 'new post',
url: 'https://example.com/nico_tag',
tags: 'nico:nico_tag',
thumbnail: dummy_upload }
post '/posts', params: post_write_params(
title: 'new post',
url: 'https://example.com/nico-tag-post',
tags: 'nico:nico_tag',
thumbnail: dummy_upload
)

expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:bad_request), response.body
end
end

@@ -547,11 +635,11 @@ RSpec.describe 'Posts API', type: :request do
it 'returns 422' do
sign_in_as(member)

post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post',
url: ' ',
tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload }
thumbnail: dummy_upload)

expect(response).to have_http_status(:unprocessable_entity)
end
@@ -561,14 +649,154 @@ RSpec.describe 'Posts API', type: :request do
it 'returns 422' do
sign_in_as(member)

post '/posts', params: {
title: 'new post',
url: 'ぼざクリタグ広場',
tags: 'spec_tag', # 既存タグ名を投げる
post '/posts', params: post_write_params(
title: 'new post',
url: 'ぼざクリタグ広場',
tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload)

expect(response).to have_http_status(:unprocessable_entity)
end
end

context 'when parent_post_ids is provided' do
let!(:parent_post_1) do
create_parent_post!(
title: 'parent post 1',
url: 'https://example.com/parent-post-1'
)
end

let!(:parent_post_2) do
create_parent_post!(
title: 'parent post 2',
url: 'https://example.com/parent-post-2'
)
end

it 'creates post implications for parent posts' do
sign_in_as(member)

expect {
post '/posts', params: {
title: 'child post',
url: 'https://example.com/child-post',
tags: 'spec_tag',
parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}",
thumbnail: dummy_upload }
}.to change(PostImplication, :count).by(2)

expect(response).to have_http_status(:created)

created_post = Post.find(json.fetch('id'))
expect(created_post.parent_posts.order(:id).pluck(:id)).to eq(
[parent_post_1.id, parent_post_2.id].sort
)

expect(PostImplication.exists?(
post_id: created_post.id,
parent_post_id: parent_post_1.id
)).to be(true)

expect(PostImplication.exists?(
post_id: created_post.id,
parent_post_id: parent_post_2.id
)).to be(true)
end

it 'deduplicates parent_post_ids' do
sign_in_as(member)

expect {
post '/posts', params: post_write_params(
title: 'dedup child post',
url: 'https://example.com/dedup-child-post',
tags: 'spec_tag',
parent_post_ids: "#{parent_post_1.id} #{parent_post_1.id}",
thumbnail: dummy_upload
)
}.to change(PostImplication, :count).by(1)

expect(response).to have_http_status(:created)

created_post = Post.find(json.fetch('id'))
expect(created_post.parent_posts.pluck(:id)).to eq([parent_post_1.id])
end

it 'records parent_post_ids in post version' do
sign_in_as(member)

post '/posts', params: post_write_params(
title: 'versioned child post',
url: 'https://example.com/versioned-child-post',
tags: 'spec_tag',
parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}",
thumbnail: dummy_upload
}
)

expect(response).to have_http_status(:created)

created_post = Post.find(json.fetch('id'))
version = PostVersion.find_by!(post: created_post, version_no: 1)

expect(version.parent_post_ids.split.map(&:to_i)).to eq(
[parent_post_1.id, parent_post_2.id].sort
)
end
end

context 'when parent_post_ids is missing' do
it 'returns 422' do
sign_in_as(member)

expect {
post '/posts', params: {
title: 'missing parent_post_ids',
url: 'https://example.com/missing-parent-post-ids',
tags: 'spec_tag',
thumbnail: dummy_upload }
}.not_to change(Post, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end

context 'when parent_post_ids includes invalid token' do
it 'returns 422 and does not create post' do
sign_in_as(member)

expect {
post '/posts', params: post_write_params(
title: 'invalid parent ids',
url: 'https://example.com/invalid-parent-ids',
tags: 'spec_tag',
parent_post_ids: 'abc',
thumbnail: dummy_upload
)
}.not_to change(Post, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end

context 'when parent_post_ids includes nonexistent post id' do
it 'returns 422 and does not create post implication' do
sign_in_as(member)

expect {
post '/posts', params: post_write_params(
title: 'missing parent post',
url: 'https://example.com/missing-parent-post',
tags: 'spec_tag',
parent_post_ids: '999999999',
thumbnail: dummy_upload
)
}.not_to change(PostImplication, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end
end
@@ -578,13 +806,13 @@ RSpec.describe 'Posts API', type: :request do

it '401 when not logged in' do
sign_out
put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' }
put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag')
expect(response).to have_http_status(:unauthorized)
end

it '403 when not member' do
sign_in_as(create(:user, role: 'guest'))
put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' }
put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag')
expect(response).to have_http_status(:forbidden)
end

@@ -595,10 +823,9 @@ RSpec.describe 'Posts API', type: :request do
tn2 = TagName.create!(name: 'spec_tag_2')
Tag.create!(tag_name: tn2, category: :general)

put "/posts/#{post_record.id}", params: {
title: 'updated title',
tags: 'spec_tag_2'
}
put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag_2')

expect(response).to have_http_status(:ok)
expect(json).to have_key('tags')
@@ -619,11 +846,178 @@ RSpec.describe 'Posts API', type: :request do
it 'return 400' do
sign_in_as(member)

put "/posts/#{ post_record.id }", params: {
put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'nico:nico_tag'
)

expect(response).to have_http_status(:bad_request), response.body
end
end

context 'when parent_post_ids is provided' do
let!(:old_parent_post) do
create_parent_post!(
title: 'old parent post',
url: 'https://example.com/old-parent-post'
)
end

let!(:new_parent_post_1) do
create_parent_post!(
title: 'new parent post 1',
url: 'https://example.com/new-parent-post-1'
)
end

let!(:new_parent_post_2) do
create_parent_post!(
title: 'new parent post 2',
url: 'https://example.com/new-parent-post-2'
)
end

before do
PostImplication.create!(
post: post_record,
parent_post: old_parent_post
)
end

it 'replaces parent posts' do
sign_in_as(member)

put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
)

expect(response).to have_http_status(:ok)

expect(post_record.reload.parent_posts.order(:id).pluck(:id)).to eq(
[new_parent_post_1.id, new_parent_post_2.id].sort
)

expect(PostImplication.exists?(
post_id: post_record.id,
parent_post_id: old_parent_post.id
)).to be(false)
end

it 'clears parent posts when parent_post_ids is blank' do
sign_in_as(member)

put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: ''
)

expect(response).to have_http_status(:ok)
expect(post_record.reload.parent_posts).to be_empty
end

it 'records changed parent_post_ids in post version' do
sign_in_as(member)
create_post_version_for!(post_record.reload)

put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
)

expect(response).to have_http_status(:ok)

version = post_record.reload.post_versions.order(:version_no).last

expect(version.version_no).to eq(2)
expect(version.parent_post_ids.split.map(&:to_i)).to eq(
[new_parent_post_1.id, new_parent_post_2.id].sort
)
end
end

context 'when parent_post_ids is missing' do
it 'returns 422' do
sign_in_as(member)

put "/posts/#{post_record.id}", params: {
title: 'updated title',
tags: 'nico:nico_tag' }
tags: 'spec_tag' }

expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end

context 'when parent_post_ids includes invalid token' do
it 'returns 422 and does not change parent posts' do
sign_in_as(member)

parent_post = create_parent_post!(
title: 'valid parent post',
url: 'https://example.com/valid-parent-post'
)

PostImplication.create!(
post: post_record,
parent_post:
)

put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: 'abc'
)

expect(response).to have_http_status(:unprocessable_entity)
expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
end
end

context 'when parent_post_ids includes nonexistent post id' do
it 'returns 422 and does not change parent posts' do
sign_in_as(member)

parent_post = create_parent_post!(
title: 'existing parent post',
url: 'https://example.com/existing-parent-post'
)

PostImplication.create!(
post: post_record,
parent_post:
)

put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: '999999999'
)

expect(response).to have_http_status(:unprocessable_entity)
expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
end
end

context 'when parent_post_ids includes self id' do
it 'returns 422 and does not create self implication' do
sign_in_as(member)

put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag',
parent_post_ids: post_record.id.to_s
)

expect(response).to have_http_status(:unprocessable_entity)

expect(PostImplication.exists?(
post_id: post_record.id,
parent_post_id: post_record.id
)).to be(false)
end
end
end
@@ -773,20 +1167,20 @@ RSpec.describe 'Posts API', type: :request do
post.snapshot_tag_names.join(' ')
end

def create_post_version!(post, version_no:, event_type:, created_by_user:, created_at:)
def create_post_version! post, version_no:, event_type:, created_by_user:, created_at:
PostVersion.create!(
post: post,
version_no: version_no,
event_type: event_type,
post:,
version_no:,
event_type:,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: created_at,
created_by_user: created_by_user
created_at:,
created_by_user:
)
end

@@ -1015,33 +1409,15 @@ RSpec.describe 'Posts API', type: :request do
post.snapshot_tag_names.join(' ')
end

def create_post_version_for!(post)
PostVersion.create!(
post: post,
version_no: 1,
event_type: 'create',
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: post.created_at,
created_by_user: post.uploaded_user
)
end

it 'creates version 1 on POST /posts' do
sign_in_as(member)

expect do
post '/posts', params: {
title: 'versioned post',
url: 'https://example.com/versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload
}
post '/posts', params: post_write_params(
title: 'versioned post',
url: 'https://example.com/versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload)
end.to change(PostVersion, :count).by(1)

expect(response).to have_http_status(:created)
@@ -1064,10 +1440,9 @@ RSpec.describe 'Posts API', type: :request do
Tag.create!(tag_name: tag_name2, category: :general)

expect do
put "/posts/#{post_record.id}", params: {
title: 'updated title',
tags: 'spec_tag_2'
}
put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag_2')
end.to change(PostVersion, :count).by(1)

expect(response).to have_http_status(:ok)
@@ -1087,10 +1462,9 @@ RSpec.describe 'Posts API', type: :request do
create_post_version_for!(post_record.reload)

expect {
put "/posts/#{post_record.id}", params: {
title: post_record.title,
tags: 'spec_tag'
}
put "/posts/#{post_record.id}", params: post_write_params(
title: post_record.title,
tags: 'spec_tag')
}.not_to change(PostVersion, :count)
expect(response).to have_http_status(:ok)

@@ -1104,12 +1478,11 @@ RSpec.describe 'Posts API', type: :request do
sign_in_as(member)

expect do
post '/posts', params: {
title: 'invalid post',
url: 'ぼざクリタグ広場',
tags: 'spec_tag',
thumbnail: dummy_upload
}
post '/posts', params: post_write_params(
title: 'invalid post',
url: 'ぼざクリタグ広場',
tags: 'spec_tag',
thumbnail: dummy_upload)
end.not_to change(PostVersion, :count)

expect(response).to have_http_status(:unprocessable_entity)
@@ -1120,12 +1493,11 @@ RSpec.describe 'Posts API', type: :request do
create_post_version_for!(post_record)

expect do
put "/posts/#{post_record.id}", params: {
title: 'updated title',
tags: 'spec_tag',
original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601
}
put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag',
original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601)
end.not_to change(PostVersion, :count)

expect(response).to have_http_status(:unprocessable_entity)
@@ -1139,12 +1511,11 @@ RSpec.describe 'Posts API', type: :request do
sign_in_as(member)

expect {
post '/posts', params: {
title: 'tag versioned post',
url: 'https://example.com/tag-versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload
}
post '/posts', params: post_write_params(
title: 'tag versioned post',
url: 'https://example.com/tag-versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload)
}.to change { tag.reload.tag_versions.count }.by(1)

expect(response).to have_http_status(:created)
@@ -1164,10 +1535,9 @@ RSpec.describe 'Posts API', type: :request do
tag2 = Tag.create!(tag_name: tag_name2, category: :general)

expect {
put "/posts/#{post_record.id}", params: {
title: 'updated title',
tags: 'spec_tag_2'
}
put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag_2')
}.to change { tag2.reload.tag_versions.count }.by(1)

expect(response).to have_http_status(:ok)


+ 1
- 1
backend/spec/tasks/nico_sync_spec.rb View File

@@ -104,7 +104,7 @@ RSpec.describe "nico:sync" do
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,


+ 36
- 11
frontend/src/components/PostEditForm.tsx View File

@@ -4,6 +4,7 @@ import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { apiPut } from '@/lib/api'

import type { FC } from 'react'
@@ -35,20 +36,34 @@ export default (({ post, onSave }: Props) => {
useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (post.originalCreatedFrom)
const [title, setTitle] = useState (post.title)
const [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title)

const handleSubmit = async () => {
const data = await apiPut<Post> (
`/posts/${ post.id }`,
{ title, tags, original_created_from: originalCreatedFrom,
original_created_before: originalCreatedBefore },
{ headers: { 'Content-Type': 'multipart/form-data' } })
onSave ({ ...post,
title: data.title,
tags: data.tags,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post)
try
{
const data = await apiPut<Post> (
`/posts/${ post.id }`,
{ title, tags, parent_post_ids: parentPostIds,
original_created_from: originalCreatedFrom,
original_created_before: originalCreatedBefore },
{ headers: { 'Content-Type': 'multipart/form-data' } })
onSave ({ ...post,
title: data.title,
tags: data.tags,
parentPosts: data.parentPosts,
childPosts: data.childPosts,
siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' })
}
catch
{
toast ({ description: '更新はできなかったよ……' })
}
}

useEffect (() => {
@@ -66,6 +81,16 @@ export default (({ post, onSave }: Props) => {
onChange={ev => setTitle (ev.target.value)}/>
</div>

{/* 親投稿 */}
<div>
<Label>親投稿</Label>
<input
type="text"
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

{/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/>



+ 5
- 2
frontend/src/components/PostList.tsx View File

@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom'

import PrefetchLink from '@/components/PrefetchLink'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'

import type { FC, MouseEvent } from 'react'
@@ -39,8 +40,10 @@ export default (({ posts, onClick }: Props) => {
<motion.div
ref={cardRef}
layoutId={layoutId}
className="w-full h-full overflow-hidden rounded-xl shadow
transform-gpu will-change-transform"
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'outline-4 outline-green-500',
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
whileHover={{ scale: 1.02 }}
onLayoutAnimationStart={() => {
if (!(cardRef.current))


+ 29
- 2
frontend/src/pages/posts/PostDetailPage.tsx View File

@@ -21,7 +21,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable'

import type { FC } from 'react'

import type { NiconicoViewerHandle, User } from '@/types'
import type { NiconicoViewerHandle, Post, User } from '@/types'

type Props = { user: User | null }

@@ -108,6 +108,34 @@ export default (({ user }: Props) => {
{post
? (
<>
{(post.childPosts ?? []).length > 0 && (
<div className="mb-4 bg-green-200 dark:bg-green-800 text-sm p-2 rounded-md">
<p>この投稿には {post.childPosts!.length} 件の子投稿があります.</p>
<PostList posts={[{ ...post, childPosts: [{ } as Post] },
...post.childPosts!.map (p => ({
...p, parentPosts: [{ } as Post] }))]}/>
</div>
)}
{(post.parentPosts ?? []).map (pp => {
const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`]
if (!(siblings))
return

return (
<div
key={pp.id}
className="mb-4 bg-yellow-200 dark:bg-yellow-800 text-sm p-2 rounded-md">
<p>
この投稿には 1 件の親投稿{
siblings.length > 1
&& `と ${ siblings.length - 1 } 件の姉妹投稿`}があります.
</p>
<PostList posts={[{ ...pp, childPosts: [{ } as Post] },
...siblings.map (p => ({
...p, parentPosts: [{ } as Post] }))]}/>
</div>)
})}

{(post.thumbnail || post.thumbnailBase) && (
<motion.div
layoutId={`page-${ id }`}
@@ -146,7 +174,6 @@ export default (({ user }: Props) => {
(prev: any) => newPost ?? prev)
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' })
}}/>
</Tab>)}
</TabGroup>


+ 32
- 0
frontend/src/pages/posts/PostHistoryPage.tsx View File

@@ -95,6 +95,8 @@ export default (() => {
<col className="w-96"/>
{/* タグ */}
<col className="w-[48rem]"/>
{/* TODO: 親投稿 */}
{/* <col className="w-[48rem]"/> */}
{/* オリジナルの投稿日時 */}
<col className="w-96"/>
{/* 更新日時 */}
@@ -110,6 +112,8 @@ export default (() => {
<th className="p-2 text-left">タイトル</th>
<th className="p-2 text-left">URL</th>
<th className="p-2 text-left">タグ</th>
{/* TODO: 親投稿の履歴 */}
{/* <th className="p-2 text-left">親投稿</th> */}
<th className="p-2 text-left">オリジナルの投稿日時</th>
<th className="p-2 text-left">更新日時</th>
<th className="p-2"/>
@@ -180,6 +184,29 @@ export default (() => {
{tag.name}
</span>))))}
</td>
{/* TODO: 親投稿の履歴 */}
{/* <td className="p-2">
{change.parentPosts.map ((pp, i) => (
pp.type === 'added'
? (
<ins
key={i}
className="mr-2 text-green-600 dark:text-green-400">
{pp.title}
</ins>)
: (
pp.type === 'removed'
? (
<del
key={i}
className="mr-2 text-red-600 dark:text-red-400">
{pp.title}
</del>)
: (
<span key={i} className="mr-2">
{pp.title}
</span>))))}
</td> */}
<td className="p-2">
{change.versionNo === 1
? originalCreatedAtString (change.originalCreatedFrom.current,
@@ -225,6 +252,11 @@ export default (() => {
.map (t => t.name)
.filter (t => t.slice (0, 5) !== 'nico:')
.join (' '),
parent_post_ids:
(change.parentPosts ?? [])
.filter (p => p.type !== 'removed')
.map (p => p.id)
.join (' '),
original_created_from:
change.originalCreatedFrom.current,
original_created_before:


+ 12
- 0
frontend/src/pages/posts/PostNewPage.tsx View File

@@ -29,6 +29,7 @@ export default (({ user }: Props) => {

const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
@@ -46,6 +47,7 @@ export default (({ user }: Props) => {
formData.append ('title', title)
formData.append ('url', url)
formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds)
if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom)
@@ -177,6 +179,16 @@ export default (({ user }: Props) => {
className="mt-2 max-h-48 rounded border"/>)}
</div>

{/* 親投稿 */}
<div>
<Label>親投稿</Label>
<input
type="text"
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

{/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/>



+ 8
- 1
frontend/src/types.ts View File

@@ -121,6 +121,9 @@ export type Post = {
thumbnail: string | null
thumbnailBase: string | null
tags: Tag[]
parentPosts?: Post[]
childPosts?: Post[]
siblingPosts?: Record<`${ number }`, Post[]>
viewed: boolean
related: Post[]
originalCreatedFrom: string | null
@@ -144,7 +147,11 @@ export type PostVersion = {
url: { current: string; prev: string | null }
thumbnail: { current: string | null; prev: string | null }
thumbnailBase: { current: string | null; prev: string | null }
tags: { name: string; type: 'context' | 'added' | 'removed' }[]
tags: { name: string
type: 'context' | 'added' | 'removed' }[]
parentPosts: { id: number
title: string
type: 'context' | 'added' | 'removed' }[]
originalCreatedFrom: { current: string | null; prev: string | null }
originalCreatedBefore: { current: string | null; prev: string | null }
createdAt: string


Loading…
Cancel
Save