This commit is contained in:
@@ -173,15 +173,41 @@ class PostsController < ApplicationController
|
|||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
base_version_no = parse_base_version_no
|
||||||
|
force = truthy_param?(params[:force])
|
||||||
|
|
||||||
title = params[:title].presence
|
title = params[:title].presence
|
||||||
tag_names = params[:tags].to_s.split
|
tag_names = params[:tags].to_s.split
|
||||||
original_created_from = params[:original_created_from]
|
original_created_from = params[:original_created_from]
|
||||||
original_created_before = params[:original_created_before]
|
original_created_before = params[:original_created_before]
|
||||||
parent_post_ids = parse_parent_post_ids
|
parent_post_ids = parse_parent_post_ids
|
||||||
|
|
||||||
post = Post.find(params[:id].to_i)
|
post = nil
|
||||||
|
conflict_json = nil
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
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)
|
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
|
||||||
|
|
||||||
post.update!(title:, original_created_from:, original_created_before:)
|
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)
|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
return render json: conflict_json, status: :conflict if conflict_json
|
||||||
|
|
||||||
post.reload
|
post.reload
|
||||||
json = post.as_json
|
json = PostRepr.base(post, current_user)
|
||||||
json['tags'] = build_tag_tree_for(post.tags)
|
json['tags'] = build_tag_tree_for(post.tags)
|
||||||
render json:, status: :ok
|
render json:, status: :ok
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
@@ -404,4 +432,178 @@ class PostsController < ApplicationController
|
|||||||
PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
|
PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -16,19 +16,20 @@ class VersionRecorder
|
|||||||
@record = record_class.unscoped.lock.find(@record.id)
|
@record = record_class.unscoped.lock.find(@record.id)
|
||||||
latest = latest_version
|
latest = latest_version
|
||||||
|
|
||||||
if !(latest) && @event_type != 'create'
|
validate_version_sequence! latest
|
||||||
raise "#{ version_class.name } first event must be create"
|
|
||||||
end
|
|
||||||
|
|
||||||
if @event_type == 'create' && latest
|
|
||||||
raise "#{ version_class.name } create event already exists"
|
|
||||||
end
|
|
||||||
|
|
||||||
attrs = snapshot_attributes
|
attrs = snapshot_attributes
|
||||||
|
|
||||||
return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
|
if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
|
||||||
|
return latest
|
||||||
|
end
|
||||||
|
|
||||||
version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
|
version = version_class.create!(
|
||||||
|
base_attributes(latest).merge(record_key => @record).merge(attrs))
|
||||||
|
|
||||||
|
update_record_version_no! version.version_no
|
||||||
|
|
||||||
|
version
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -45,7 +46,31 @@ class VersionRecorder
|
|||||||
created_by_user: @created_by_user }
|
created_by_user: @created_by_user }
|
||||||
end
|
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!
|
def validate_event_type!
|
||||||
return if EVENT_TYPES.include?(@event_type)
|
return if EVENT_TYPES.include?(@event_type)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+7
-1
@@ -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_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|
|
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
|
||||||
@@ -186,8 +186,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do
|
|||||||
t.datetime "original_created_from"
|
t.datetime "original_created_from"
|
||||||
t.datetime "original_created_before"
|
t.datetime "original_created_before"
|
||||||
t.datetime "updated_at", null: false
|
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 ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
||||||
t.index ["url"], name: "index_posts_on_url", unique: true
|
t.index ["url"], name: "index_posts_on_url", unique: true
|
||||||
|
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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.datetime "updated_at", null: false
|
||||||
t.integer "post_count", default: 0, null: false
|
t.integer "post_count", default: 0, null: false
|
||||||
t.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
|
t.integer "version_no", null: false
|
||||||
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
|
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.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
|
end
|
||||||
|
|
||||||
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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 "updated_at", null: false
|
||||||
t.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
t.integer "next_asset_no", default: 1, null: false
|
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 ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
|
||||||
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
|
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 ["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.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
|
end
|
||||||
|
|
||||||
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
|||||||
Reference in New Issue
Block a user