diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 363b926..272db8b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -173,15 +173,41 @@ class PostsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + base_version_no = parse_base_version_no + force = truthy_param?(params[:force]) + title = params[:title].presence 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) + post = nil + conflict_json = nil ApplicationRecord.transaction do + post = Post.find(params[:id].to_i) + + base_version = post.post_versions.find_by!(version_no: base_version_no) + + base_snapshot = post_snapshot_from_version(base_version) + current_snapshot = post_snapshot_from_record(post) + incoming_snapshot = post_incoming_snapshot(post, + title:, + original_created_from:, + original_created_before:, + tag_names:, + parent_post_ids:) + + if !(force) && post.version_no != base_version_no + conflict_json = post_conflict_json(post:, + base_version_no:, + base_snapshot:, + current_snapshot:, + incoming_snapshot:) + raise ActiveRecord::Rollback + end + PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) post.update!(title:, original_created_from:, original_created_before:) @@ -198,8 +224,10 @@ class PostsController < ApplicationController PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) end + return render json: conflict_json, status: :conflict if conflict_json + post.reload - json = post.as_json + json = PostRepr.base(post, current_user) json['tags'] = build_tag_tree_for(post.tags) render json:, status: :ok rescue Tag::NicoTagNormalisationError @@ -404,4 +432,178 @@ class PostsController < ApplicationController PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:) end end + + def parse_base_version_no + version_no = Integer(params[:base_version_no], exception: false) + raise ArgumentError, 'base_version_no は必須です.' unless version_no&.positive? + + version_no + end + + def truthy_param?(value) = ActiveModel::Type::Boolean.new.cast(value) + + def post_snapshot_from_version version + { title: version.title, + original_created_from: snapshot_time(version.original_created_from), + original_created_before: snapshot_time(version.original_created_before), + tag_names: version.tags.to_s.split.sort, + parent_post_ids: snapshot_parent_post_ids_from_version(version) } + end + + def post_snapshot_form_record post + { title: post.title, + original_created_from: snapshot_time(post.original_created_from), + original_created_before: snapshot_time(post.original_created_before), + tag_names: post.tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name'), + parent_post_ids: post.parent_posts.order(:id).pluck(:id) } + end + + def post_incoming_snapshot post, title:, original_created_from:, original_created_before:, + tag_names:, parent_post_ids: + { title: + original_created_from: snapshot_time(original_created_from), + original_created_before: snapshot_time(original_created_before), + tag_names: incoming_tag_names_for_snapshot(post, tag_names), + parent_post_ids: parent_post_ids.sort } + end + + def snapshot_parent_post_ids_from_version version + if version.respond_to?(:parent_post_ids) + version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort + elsif version.respond_to?(:parent_id) && version.parent_id + [version.parent_id] + else + [] + end + end + + def snapshot_time value + return nil if value.blank? + + value = Time.zone.parse(value.to_s) if value in String + value&.in_time_zone&.iso8601(6) + rescue ArgumentError, TypeError + value.to_s + end + + def incoming_tag_names_for_snapshot post, raw_tag_names + manual_names = normalised_manual_tag_names_for_snapshot(raw_tag_names) + nico_names = post.tags.nico.joins(:tag_name).pluck('tag_names.name') + + existing_tags = + Tag + .joins(:tag_name) + .where(tag_names: { name: manual_names + nico_names }) + .to_a + + expanded_names = Tag.expand_parent_tags(existing_tags).map(&:name) + + (manual_names + nico_names + expanded_names).uniq.sort + end + + def normalised_manual_tag_names_for_snapshot raw_tag_names + if raw_tag_names.any? { |name| name.downcase.start_with?('nico:') } + raise Tag::NicoTagNormalisationError + end + + pairs = raw_tag_names.map do |raw_name| + prefix, category = + Tag::CATEGORY_PREFIXES.find { |p, _| raw_name.downcase.start_with?(p) } || ['', nil] + + name = TagName.canonicalise(raw_name.sub(/\A#{ Regexp.escape(prefix) }/i, '')).first + + [name, category] + end + + names = pairs.map(&:first) + + has_deerjikist = pairs.any? do |name, category| + category == :deerjikist || + Tag.joins(:tag_name).where(category: :deerjikist, tag_names: { name: }).exists? + end + + names << Tag.no_deerjikist.name unless has_deerjikist + + names.uniq.sort + end + + def post_conflict_json post:, base_version_no:, base_snapshot:, + current_snapshot:, incoming_snapshot: + changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot) + conflicts = changes.select { |change| change[:conflict] } + + { error: 'conflict', + message: '競合が発生しました.', + post_id: post.id, + base_version_no:, + current_version_no: post.version_no, + base: base_snapshot, + current: current_snapshot, + mine: incoming_snapshot, + changes:, + conflicts:, + mergeable: conflicts.empty? } + end + + def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot + [scalar_snapshot_change(:title, 'タイトル', + base_snapshot, current_snapshot, incoming_snapshot), + scalar_snapshot_change(:original_created_from, '元コンテンツ作成日時(開始)', + base_snapshot, current_snapshot, incoming_snapshot), + scalar_snapshot_change(:original_created_before, '元コンテンツ作成日時(終了)', + base_snapshot, current_snapshot, incoming_snapshot), + set_snapshot_change(:tag_names, 'タグ', + base_snapshot, current_snapshot, incoming_snapshot), + set_snapshot_change(:parent_post_ids, '親投稿', + base_snapshot, current_snapshot, incoming_snapshot)].compact + end + + def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot + base = base_snapshot[field] + current = current_snapshot[field] + mine = incoming_snapshot[field] + + return nil if current == base && mine == base + + { field:, label:, base:, current:, mine:, + changed_by_current: current != base, + changed_by_me: mine != base, + conflict: scalar_snapshot_conflict?(base, current, mine) } + end + + def scalar_snapshot_conflict? base, current, mine + current != base && mine != base && current != mine + end + + def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot + base = base_snapshot[field].to_a + current = current_snapshot[field].to_a + mine = incoming_snapshot[field].to_a + + added_by_current = current - base + removed_by_current = base - current + added_by_me = mine - base + removed_by_me = base - mine + + if (added_by_current.empty? && + removed_by_current.empty? && + added_by_me.empty? && + removed_by_me.empty?) + return nil + end + + { field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:, + added_by_me:, removed_by_me:, + changed_by_current: added_by_current.present? || removed_by_current.present?, + changed_by_me: added_by_me.present? || removed_by_me.present?, + conflict: set_snapshot_conflict?(added_by_current:, + removed_by_current:, + added_by_me:, + removed_by_me:) } + end + + def set_snapshot_conflict? added_by_current:, removed_by_current:, + added_by_me:, removed_by_me: + (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? + end end diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb index e705ec3..fe7c368 100644 --- a/backend/app/services/version_recorder.rb +++ b/backend/app/services/version_recorder.rb @@ -16,19 +16,20 @@ class VersionRecorder @record = record_class.unscoped.lock.find(@record.id) latest = latest_version - if !(latest) && @event_type != 'create' - raise "#{ version_class.name } first event must be create" - end + validate_version_sequence! latest + + attrs = snapshot_attributes - if @event_type == 'create' && latest - raise "#{ version_class.name } create event already exists" + if @event_type == 'update' && latest && same_snapshot?(latest, attrs) + return latest end - attrs = snapshot_attributes + version = version_class.create!( + base_attributes(latest).merge(record_key => @record).merge(attrs)) - return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs) + update_record_version_no! version.version_no - version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs)) + version end end @@ -45,7 +46,31 @@ class VersionRecorder created_by_user: @created_by_user } end - def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v } + def update_record_version_no! version_no + @record.update_columns version_no: version_no + @record.version_no = version_no + end + + def validate_version_sequence! latest + if !(latest) && @event_type != 'create' + raise "#{ version_class.name } first event must be create" + end + + if @event_type == 'create' && latest + raise "#{ version_class.name } create event already exists" + end + + return unless latest + + if @record.version_no != latest.version_no + raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " + + "but latest #{ version_class.name } version_no is #{ latest.version_no }") + end + end + + def same_snapshot? version, attrs + attrs.all? { |k, v| version.public_send(k) == v } + end def validate_event_type! return if EVENT_TYPES.include?(@event_type) diff --git a/backend/db/migrate/20260507124000_add_version_no_to_posts.rb b/backend/db/migrate/20260507124000_add_version_no_to_posts.rb new file mode 100644 index 0000000..7161a23 --- /dev/null +++ b/backend/db/migrate/20260507124000_add_version_no_to_posts.rb @@ -0,0 +1,27 @@ +class AddVersionNoToPosts < ActiveRecord::Migration[8.0] + def up + add_column :posts, :version_no, :integer + + execute <<~SQL + UPDATE + posts + SET + version_no = ( + SELECT + MAX(version_no) + FROM + post_versions + WHERE + post_id = posts.id) + SQL + + change_column_null :posts, :version_no, false + + add_check_constraint :posts, 'version_no > 0', name: 'chk_posts_version_no_positive' + end + + def down + remove_check_constraint :posts, name: 'chk_posts_version_no_positive' + remove_column :posts, :version_no + end +end diff --git a/backend/db/migrate/20260507211600_add_version_no_to_tags.rb b/backend/db/migrate/20260507211600_add_version_no_to_tags.rb new file mode 100644 index 0000000..2a4ef30 --- /dev/null +++ b/backend/db/migrate/20260507211600_add_version_no_to_tags.rb @@ -0,0 +1,37 @@ +class AddVersionNoToTags < ActiveRecord::Migration[8.0] + def up + add_column :tags, :version_no, :integer + + execute <<~SQL + UPDATE + tags + SET + version_no = ( + CASE category + WHEN 'nico' THEN + (SELECT + MAX(version_no) + FROM + nico_tag_versions + WHERE + tag_id = tags.id) + ELSE + (SELECT + MAX(version_no) + FROM + tag_versions + WHERE + tag_id = tags.id) + END) + SQL + + change_column_null :tags, :version_no, false + + add_check_constraint :tags, 'version_no > 0', name: 'chk_tags_version_no_positive' + end + + def down + remove_check_constraint :tags, name: 'chk_tags_version_no_positive' + remove_column :tags, :version_no + end +end diff --git a/backend/db/migrate/20260507213300_add_version_no_to_wiki_pages.rb b/backend/db/migrate/20260507213300_add_version_no_to_wiki_pages.rb new file mode 100644 index 0000000..be34918 --- /dev/null +++ b/backend/db/migrate/20260507213300_add_version_no_to_wiki_pages.rb @@ -0,0 +1,27 @@ +class AddVersionNoToWikiPages < ActiveRecord::Migration[8.0] + def up + add_column :wiki_pages, :version_no, :integer + + execute <<~SQL + UPDATE + wiki_pages + SET + version_no = ( + SELECT + MAX(version_no) + FROM + wiki_versions + WHERE + wiki_page_id = wiki_pages.id) + SQL + + change_column_null :wiki_pages, :version_no, false + + add_check_constraint :wiki_pages, 'version_no > 0', name: 'chk_wiki_pages_version_no_positive' + end + + def down + remove_check_constraint :wiki_pages, name: 'chk_wiki_pages_version_no_positive' + remove_column :wiki_pages, :version_no + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 042d227..94edb82 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_05_01_153900) do +ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) 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 @@ -186,8 +186,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do t.datetime "original_created_from" t.datetime "original_created_before" t.datetime "updated_at", null: false + t.integer "version_no", null: false t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true + t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -262,8 +264,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do t.datetime "updated_at", null: false t.integer "post_count", default: 0, null: false t.datetime "discarded_at" + t.integer "version_no", null: false t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true + t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -369,10 +373,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do t.datetime "updated_at", null: false t.datetime "discarded_at" t.integer "next_asset_no", default: 1, null: false + t.integer "version_no", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" + t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" end create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|