Skip to content

Commit 24fea4c

Browse files
committed
feat: add admin people list and person merge
Pagination and search on the admin users list. New /admin/people list with the same, plus registered and sender filters, sorted by default alias name. PersonMergeService merges one person into another and logs it to person_merges. Refuses when the source has an account, so the registered side survives. Merge UI is new -> preview -> create.
1 parent 98bf39b commit 24fea4c

26 files changed

Lines changed: 1340 additions & 44 deletions

‎app/assets/stylesheets/application.postcss.css‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
@import "components/stats.css";
2626
@import "components/reports.css";
2727
@import "components/help.css";
28+
@import "components/pagination.css";
2829
@import "components/ci.css";
2930
@import "components/ci-stats.css";
3031
@import "components/admin.css";

‎app/assets/stylesheets/components/admin.css‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,3 +645,33 @@ textarea.form-input {
645645
grid-template-columns: 1fr;
646646
}
647647
}
648+
649+
.settings-page .admin-search {
650+
display: flex;
651+
flex-wrap: wrap;
652+
align-items: center;
653+
gap: var(--spacing-3);
654+
margin-top: var(--spacing-4);
655+
}
656+
657+
.settings-page .admin-search input[type="search"] {
658+
flex: 1 1 240px;
659+
padding: var(--spacing-2) var(--spacing-3);
660+
border: 1px solid var(--color-border);
661+
border-radius: var(--border-radius-sm);
662+
background: var(--color-bg-card);
663+
color: var(--color-text-primary);
664+
}
665+
666+
.settings-page .admin-search-check {
667+
display: flex;
668+
align-items: center;
669+
gap: var(--spacing-2);
670+
font-size: var(--font-size-sm);
671+
color: var(--color-text-muted);
672+
}
673+
674+
.settings-page .admin-search-clear {
675+
font-size: var(--font-size-sm);
676+
color: var(--color-text-link);
677+
}

‎app/assets/stylesheets/components/ci.css‎

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -354,25 +354,6 @@
354354
color: var(--color-bg-card);
355355
}
356356

357-
/* kaminari pagination */
358-
.ci-container nav.pagination {
359-
display: flex;
360-
flex-wrap: wrap;
361-
gap: var(--spacing-3);
362-
margin-top: var(--spacing-4);
363-
font-size: var(--font-size-sm);
364-
color: var(--color-text-muted);
365-
}
366-
367-
.ci-container nav.pagination a {
368-
color: var(--color-text-link);
369-
}
370-
371-
.ci-container nav.pagination .page.current {
372-
font-weight: var(--font-weight-bold);
373-
color: var(--color-text-primary);
374-
}
375-
376357
/* branch health */
377358
.ci-health {
378359
display: grid;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/* kaminari pagination */
2+
nav.pagination {
3+
display: flex;
4+
flex-wrap: wrap;
5+
gap: var(--spacing-3);
6+
margin-top: var(--spacing-4);
7+
font-size: var(--font-size-sm);
8+
color: var(--color-text-muted);
9+
}
10+
11+
nav.pagination a {
12+
color: var(--color-text-link);
13+
}
14+
15+
nav.pagination .page.current {
16+
font-weight: var(--font-weight-bold);
17+
color: var(--color-text-primary);
18+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
class Admin::PeopleController < Admin::BaseController
4+
PER_PAGE = 50
5+
REGISTERED_FILTERS = %w[all registered unregistered].freeze
6+
7+
def active_admin_section
8+
:people
9+
end
10+
11+
def index
12+
@query = params[:q].to_s.strip
13+
@registered = REGISTERED_FILTERS.include?(params[:registered]) ? params[:registered] : "all"
14+
@senders_only = params[:senders].present?
15+
16+
scope = filtered_scope
17+
@people_total_count = scope.count
18+
@people = scope.left_joins(:default_alias)
19+
.includes(:aliases, :default_alias, :user, :contributor_memberships)
20+
.order(Arel.sql("aliases.name ASC NULLS LAST"))
21+
.page(params[:page]).per(PER_PAGE)
22+
end
23+
24+
private
25+
26+
def filtered_scope
27+
scope = Person.all
28+
scope = scope.matching(@query) if @query.present?
29+
scope = scope.registered if @registered == "registered"
30+
scope = scope.unregistered if @registered == "unregistered"
31+
scope = scope.with_senders if @senders_only
32+
scope
33+
end
34+
end
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# frozen_string_literal: true
2+
3+
class Admin::PersonMergesController < Admin::BaseController
4+
CANDIDATE_LIMIT = 20
5+
6+
before_action :set_source_person, only: [ :new, :preview, :create ]
7+
before_action :set_target_person, only: [ :preview, :create ]
8+
9+
def active_admin_section
10+
:person_merges
11+
end
12+
13+
def index
14+
@person_merges = PersonMerge.includes(:performed_by).order(created_at: :desc).limit(100)
15+
end
16+
17+
def new
18+
@target_query = params[:target_q].to_s.strip
19+
@candidates = if @target_query.present?
20+
Person.matching(@target_query)
21+
.where.not(id: @source_person.id)
22+
.includes(:aliases, :default_alias, :user)
23+
.limit(CANDIDATE_LIMIT)
24+
else
25+
Person.none
26+
end
27+
end
28+
29+
def preview
30+
return redirect_to_new_with_error("Target person is required") unless @target_person
31+
32+
error = service.validation_error
33+
return redirect_to_new_with_error(error) if error
34+
35+
@preview = service.preview
36+
end
37+
38+
def create
39+
return redirect_to_new_with_error("Target person is required") unless @target_person
40+
41+
result = service.call
42+
43+
if result.success?
44+
redirect_to admin_people_path,
45+
notice: "Merged #{result.person_merge.source_name} into #{result.person_merge.target_name}."
46+
else
47+
redirect_to_new_with_error(result.error)
48+
end
49+
end
50+
51+
private
52+
53+
def service
54+
@service ||= PersonMergeService.new(
55+
source_person: @source_person,
56+
target_person: @target_person,
57+
merged_by: current_user
58+
)
59+
end
60+
61+
def set_source_person
62+
@source_person = Person.find(params[:person_id])
63+
rescue ActiveRecord::RecordNotFound
64+
redirect_to admin_people_path, alert: "Source person not found"
65+
end
66+
67+
def set_target_person
68+
target_id = params[:target_person_id].presence
69+
@target_person = Person.find_by(id: target_id) if target_id
70+
end
71+
72+
def redirect_to_new_with_error(message)
73+
redirect_to new_admin_person_merge_path(@source_person), alert: message
74+
end
75+
end

‎app/controllers/admin/users_controller.rb‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
# frozen_string_literal: true
22

33
class Admin::UsersController < Admin::BaseController
4+
PER_PAGE = 50
5+
46
before_action :set_user, only: [ :toggle_admin, :new_email, :confirm_email, :add_email ]
57

68
def active_admin_section
79
:users
810
end
911

1012
def index
11-
@users_total_count = User.active.count
12-
@users = User.active
13-
.includes(person: [ :default_alias, :aliases ])
14-
.order(created_at: :desc)
15-
.limit(params.fetch(:limit, 50).to_i)
16-
.offset(params.fetch(:offset, 0).to_i)
13+
@query = params[:q].to_s.strip
14+
scope = User.active.includes(person: [ :default_alias, :aliases ])
15+
scope = scope.matching(@query) if @query.present?
16+
17+
@users_total_count = scope.count
18+
@users = scope.order(created_at: :desc).page(params[:page]).per(PER_PAGE)
1719
end
1820

1921
def toggle_admin

‎app/models/person.rb‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,27 @@ class Person < ApplicationRecord
99

1010
belongs_to :default_alias, class_name: "Alias", optional: true
1111

12+
scope :matching, ->(query) {
13+
like = "%#{ActiveRecord::Base.sanitize_sql_like(query.to_s.strip)}%"
14+
where(
15+
"EXISTS (SELECT 1 FROM aliases WHERE aliases.person_id = people.id " \
16+
"AND (aliases.email ILIKE :q OR aliases.name ILIKE :q))",
17+
q: like
18+
)
19+
}
20+
21+
scope :registered, -> {
22+
where("EXISTS (SELECT 1 FROM users WHERE users.person_id = people.id AND users.deleted_at IS NULL)")
23+
}
24+
25+
scope :unregistered, -> {
26+
where("NOT EXISTS (SELECT 1 FROM users WHERE users.person_id = people.id AND users.deleted_at IS NULL)")
27+
}
28+
29+
scope :with_senders, -> {
30+
where("EXISTS (SELECT 1 FROM aliases WHERE aliases.person_id = people.id AND aliases.sender_count > 0)")
31+
}
32+
1233
def display_name
1334
default_alias&.name || aliases.order(:created_at).first&.name || "Unknown"
1435
end

‎app/models/person_merge.rb‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
class PersonMerge < ApplicationRecord
4+
belongs_to :performed_by, class_name: "User"
5+
6+
# Not an association: the column carries no foreign key, so the target may
7+
# already be gone if it was itself merged away later.
8+
def target_person
9+
Person.find_by(id: target_person_id)
10+
end
11+
end

‎app/models/user.rb‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ class User < ApplicationRecord
2222

2323
scope :active, -> { where(deleted_at: nil) }
2424

25+
scope :matching, ->(query) {
26+
like = "%#{ActiveRecord::Base.sanitize_sql_like(query.to_s.strip)}%"
27+
where(
28+
"users.username ILIKE :q OR EXISTS (" \
29+
"SELECT 1 FROM aliases WHERE aliases.person_id = users.person_id " \
30+
"AND (aliases.email ILIKE :q OR aliases.name ILIKE :q))",
31+
q: like
32+
)
33+
}
34+
2535
def primary_alias
2636
person&.default_alias
2737
end

0 commit comments

Comments
 (0)