From d737d9f6b8d3ef65d9ba37101083046678bcdcdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 26 May 2025 14:10:27 +0400 Subject: [PATCH 01/15] Refactor ejabberd API integration --- app/jobs/xmpp_exchange_contacts_job.rb | 17 +--- app/jobs/xmpp_send_message_job.rb | 3 +- app/jobs/xmpp_set_avatar_job.rb | 92 +------------------ app/jobs/xmpp_set_default_bookmarks_job.rb | 21 +---- .../ejabberd_manager/exchange_contacts.rb | 25 +++++ app/services/ejabberd_manager/get_avatar.rb | 25 +++++ app/services/ejabberd_manager/send_message.rb | 11 +++ app/services/ejabberd_manager/set_avatar.rb | 80 ++++++++++++++++ .../ejabberd_manager/set_default_bookmarks.rb | 31 +++++++ ..._client.rb => ejabberd_manager_service.rb} | 17 ++-- app/services/rest_api_service.rb | 10 +- app/services/user_manager/update_avatar.rb | 1 + spec/fixtures/files/avatar-base64-png.txt | 1 + spec/jobs/xmpp_exchange_contacts_job_spec.rb | 15 +-- spec/jobs/xmpp_send_message_spec.rb | 25 +++++ .../xmpp_set_default_bookmarks_job_spec.rb | 19 +--- .../exchange_contacts_spec.rb | 22 +++++ .../ejabberd_manager/get_avatar_spec.rb | 37 ++++++++ .../set_default_bookmarks_spec.rb | 22 +++++ 19 files changed, 307 insertions(+), 167 deletions(-) create mode 100644 app/services/ejabberd_manager/exchange_contacts.rb create mode 100644 app/services/ejabberd_manager/get_avatar.rb create mode 100644 app/services/ejabberd_manager/send_message.rb create mode 100644 app/services/ejabberd_manager/set_avatar.rb create mode 100644 app/services/ejabberd_manager/set_default_bookmarks.rb rename app/services/{ejabberd_api_client.rb => ejabberd_manager_service.rb} (76%) create mode 100644 spec/fixtures/files/avatar-base64-png.txt create mode 100644 spec/jobs/xmpp_send_message_spec.rb create mode 100644 spec/services/ejabberd_manager/exchange_contacts_spec.rb create mode 100644 spec/services/ejabberd_manager/get_avatar_spec.rb create mode 100644 spec/services/ejabberd_manager/set_default_bookmarks_spec.rb diff --git a/app/jobs/xmpp_exchange_contacts_job.rb b/app/jobs/xmpp_exchange_contacts_job.rb index 0de829c..269604d 100644 --- a/app/jobs/xmpp_exchange_contacts_job.rb +++ b/app/jobs/xmpp_exchange_contacts_job.rb @@ -2,21 +2,6 @@ class XmppExchangeContactsJob < ApplicationJob queue_as :default def perform(inviter, invitee) - return unless inviter.service_enabled?(:ejabberd) && - invitee.service_enabled?(:ejabberd) && - inviter.preferences[:xmpp_exchange_contacts_with_invitees] - - ejabberd = EjabberdApiClient.new - - ejabberd.add_rosteritem({ - "localuser": invitee.cn, "localhost": invitee.ou, - "user": inviter.cn, "host": inviter.ou, - "nick": inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both" - }) - ejabberd.add_rosteritem({ - "localuser": inviter.cn, "localhost": inviter.ou, - "user": invitee.cn, "host": invitee.ou, - "nick": invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both" - }) + EjabberdManager::ExchangeContacts.call(inviter:, invitee:) end end diff --git a/app/jobs/xmpp_send_message_job.rb b/app/jobs/xmpp_send_message_job.rb index e8eebf5..37a7e57 100644 --- a/app/jobs/xmpp_send_message_job.rb +++ b/app/jobs/xmpp_send_message_job.rb @@ -2,7 +2,6 @@ class XmppSendMessageJob < ApplicationJob queue_as :default def perform(payload) - ejabberd = EjabberdApiClient.new - ejabberd.send_message payload + EjabberdManager::SendMessage.call(payload:) end end diff --git a/app/jobs/xmpp_set_avatar_job.rb b/app/jobs/xmpp_set_avatar_job.rb index 642225c..e1b89d7 100644 --- a/app/jobs/xmpp_set_avatar_job.rb +++ b/app/jobs/xmpp_set_avatar_job.rb @@ -1,97 +1,7 @@ -require 'digest' -require "image_processing/vips" - class XmppSetAvatarJob < ApplicationJob queue_as :default def perform(user:, overwrite: false) - return if Rails.env.development? - @user = user - - unless overwrite - current_avatar = get_current_avatar - Rails.logger.info { "User #{user.cn} already has an avatar set" } - return if current_avatar.present? - end - - Rails.logger.debug { "Setting XMPP avatar for user #{user.cn}" } - - stanzas = build_xep0084_stanzas - - stanzas.each do |stanza| - payload = { from: @user.address, to: @user.address, stanza: stanza } - res = ejabberd.send_stanza payload - raise res.inspect if res.status != 200 - end + EjabberdManager::SetAvatar.call(user:, overwrite:) end - - private - - def ejabberd - @ejabberd ||= EjabberdApiClient.new - end - - def get_current_avatar - res = ejabberd.get_vcard2 @user, "PHOTO", "BINVAL" - - if res.status == 200 - # VCARD PHOTO/BINVAL prop exists - res.body - elsif res.status == 400 - # VCARD or PHOTO/BINVAL prop does not exist - nil - else - # Unexpected error, let job fail - raise res.inspect - end - end - - def process_avatar - @user.avatar.blob.open do |file| - processed = ImageProcessing::Vips - .source(file) - .resize_to_fill(256, 256) - .convert("png") - .call - processed.read - end - end - - # See https://xmpp.org/extensions/xep-0084.html - def build_xep0084_stanzas - img_data = process_avatar - sha1_hash = Digest::SHA1.hexdigest(img_data) - base64_data = Base64.strict_encode64(img_data) - - [ - """ - - - - - #{base64_data} - - - - - """.strip, - """ - - - - - - - - - - - - """.strip, - ] - end end diff --git a/app/jobs/xmpp_set_default_bookmarks_job.rb b/app/jobs/xmpp_set_default_bookmarks_job.rb index 92cd8a5..e9539c9 100644 --- a/app/jobs/xmpp_set_default_bookmarks_job.rb +++ b/app/jobs/xmpp_set_default_bookmarks_job.rb @@ -2,25 +2,6 @@ class XmppSetDefaultBookmarksJob < ApplicationJob queue_as :default def perform(user) - return unless Setting.xmpp_default_rooms.any? - @user = user - ejabberd = EjabberdApiClient.new - ejabberd.private_set user, storage_content - end - - def storage_content - bookmarks = "" - Setting.xmpp_default_rooms.each do |r| - bookmarks << conference_element( - jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn, - autojoin: Setting.xmpp_autojoin_default_rooms - ) - end - - "#{bookmarks}" - end - - def conference_element(jid:, name:, autojoin: false, nick:) - "#{nick}" + EjabberdManager::SetDefaultBookmarks.call(user:) end end diff --git a/app/services/ejabberd_manager/exchange_contacts.rb b/app/services/ejabberd_manager/exchange_contacts.rb new file mode 100644 index 0000000..f71cddc --- /dev/null +++ b/app/services/ejabberd_manager/exchange_contacts.rb @@ -0,0 +1,25 @@ +module EjabberdManager + class ExchangeContacts < EjabberdManagerService + def initialize(inviter:, invitee:) + @inviter = inviter + @invitee = invitee + end + + def call + return unless @inviter.service_enabled?(:ejabberd) && + @invitee.service_enabled?(:ejabberd) && + @inviter.preferences[:xmpp_exchange_contacts_with_invitees] + + add_rosteritem({ + "localuser": @invitee.cn, "localhost": @invitee.ou, + "user": @inviter.cn, "host": @inviter.ou, + "nick": @inviter.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both" + }) + add_rosteritem({ + "localuser": @inviter.cn, "localhost": @inviter.ou, + "user": @invitee.cn, "host": @invitee.ou, + "nick": @invitee.cn, "group": Setting.ejabberd_buddy_roster, "subs": "both" + }) + end + end +end diff --git a/app/services/ejabberd_manager/get_avatar.rb b/app/services/ejabberd_manager/get_avatar.rb new file mode 100644 index 0000000..8569fb7 --- /dev/null +++ b/app/services/ejabberd_manager/get_avatar.rb @@ -0,0 +1,25 @@ +module EjabberdManager + class GetAvatar < EjabberdManagerService + def initialize(user:) + @user = user + end + + def call + res = get_vcard2 @user, "PHOTO", "BINVAL" + + if res.status == 200 + # VCARD PHOTO/BINVAL prop exists + img_base64 = JSON.parse(res.body)["content"] + ct_res = get_vcard2 @user, "PHOTO", "TYPE" + content_type = JSON.parse(ct_res.body)["content"] + { content_type:, img_base64: } + elsif res.status == 400 + # VCARD or PHOTO/BINVAL prop does not exist + nil + else + # Unexpected error, let job fail + raise res.inspect + end + end + end +end diff --git a/app/services/ejabberd_manager/send_message.rb b/app/services/ejabberd_manager/send_message.rb new file mode 100644 index 0000000..86d6a9d --- /dev/null +++ b/app/services/ejabberd_manager/send_message.rb @@ -0,0 +1,11 @@ +module EjabberdManager + class SendMessage < EjabberdManagerService + def initialize(payload:) + @payload = payload + end + + def call + send_message @payload + end + end +end diff --git a/app/services/ejabberd_manager/set_avatar.rb b/app/services/ejabberd_manager/set_avatar.rb new file mode 100644 index 0000000..fcff840 --- /dev/null +++ b/app/services/ejabberd_manager/set_avatar.rb @@ -0,0 +1,80 @@ +require 'digest' +require "image_processing/vips" + +module EjabberdManager + class SetAvatar < EjabberdManagerService + def initialize(user:, overwrite: false) + @user = user + @overwrite = overwrite + end + + def call + unless @overwrite + current_avatar = EjabberdManager::GetAvatar.call(user: @user) + Rails.logger.info { "User #{user.cn} already has an avatar set" } + return if current_avatar.present? + end + + Rails.logger.debug { "Setting XMPP avatar for user #{@user.cn}" } + + stanzas = build_xep0084_stanzas + + stanzas.each do |stanza| + payload = { from: @user.address, to: @user.address, stanza: stanza } + res = send_stanza payload + raise res.inspect if res.status != 200 + end + end + end + + private + + def process_avatar + @user.avatar.blob.open do |file| + processed = ImageProcessing::Vips + .source(file) + .resize_to_fill(256, 256) + .convert("png") + .call + processed.read + end + end + + # See https://xmpp.org/extensions/xep-0084.html + def build_xep0084_stanzas + img_data = process_avatar + sha1_hash = Digest::SHA1.hexdigest(img_data) + base64_data = Base64.strict_encode64(img_data) + + [ + """ + + + + + #{base64_data} + + + + + """.strip, + """ + + + + + + + + + + + + """.strip, + ] + end +end diff --git a/app/services/ejabberd_manager/set_default_bookmarks.rb b/app/services/ejabberd_manager/set_default_bookmarks.rb new file mode 100644 index 0000000..4c94e06 --- /dev/null +++ b/app/services/ejabberd_manager/set_default_bookmarks.rb @@ -0,0 +1,31 @@ +module EjabberdManager + class SetDefaultBookmarks < EjabberdManagerService + def initialize(user:) + @user = user + end + + def call + return unless Setting.xmpp_default_rooms.any? + + private_set @user, storage_content + end + + private + + def storage_content + bookmarks = "" + Setting.xmpp_default_rooms.each do |r| + bookmarks << conference_element( + jid: r[/<(.+)>/, 1], name: r[/^(.+)\s/, 1], nick: @user.cn, + autojoin: Setting.xmpp_autojoin_default_rooms + ) + end + + "#{bookmarks}" + end + + def conference_element(jid:, name:, autojoin: false, nick:) + "#{nick}" + end + end +end diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_manager_service.rb similarity index 76% rename from app/services/ejabberd_api_client.rb rename to app/services/ejabberd_manager_service.rb index df22d25..733e384 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_manager_service.rb @@ -1,11 +1,16 @@ -class EjabberdApiClient - def initialize - @base_url = Setting.ejabberd_api_url +class EjabberdManagerService < RestApiService + private + + def base_url + @base_url ||= Setting.ejabberd_api_url end - def post(endpoint, payload) - Faraday.post "#{@base_url}/#{endpoint}", payload.to_json, - "Content-Type" => "application/json" + def headers + { "Content-Type" => "application/json" } + end + + def parse_responses? + false end # diff --git a/app/services/rest_api_service.rb b/app/services/rest_api_service.rb index e46cfb3..8e23fef 100644 --- a/app/services/rest_api_service.rb +++ b/app/services/rest_api_service.rb @@ -13,15 +13,17 @@ class RestApiService < ApplicationService "#{base_url}/#{path.gsub(/^\//, '')}" end + def parse_responses? + true + end + def get(path, params = {}) res = Faraday.get endpoint_url(path), params, headers - # TODO handle unsuccessful responses with no valid JSON body - JSON.parse(res.body) + parse_responses? ? JSON.parse(res.body) : res end def post(path, payload) res = Faraday.post endpoint_url(path), payload.to_json, headers - # TODO handle unsuccessful responses with no valid JSON body - JSON.parse(res.body) + parse_responses? ? JSON.parse(res.body) : res end end diff --git a/app/services/user_manager/update_avatar.rb b/app/services/user_manager/update_avatar.rb index d03f37b..d3bcc31 100644 --- a/app/services/user_manager/update_avatar.rb +++ b/app/services/user_manager/update_avatar.rb @@ -8,6 +8,7 @@ module UserManager LdapManager::UpdateAvatar.call(user: @user) if Setting.ejabberd_enabled? + return if Rails.env.development? XmppSetAvatarJob.perform_later(user: @user) end end diff --git a/spec/fixtures/files/avatar-base64-png.txt b/spec/fixtures/files/avatar-base64-png.txt new file mode 100644 index 0000000..77cb4bc --- /dev/null +++ b/spec/fixtures/files/avatar-base64-png.txt @@ -0,0 +1 @@ +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 diff --git a/spec/jobs/xmpp_exchange_contacts_job_spec.rb b/spec/jobs/xmpp_exchange_contacts_job_spec.rb index 013a80d..8f25895 100644 --- a/spec/jobs/xmpp_exchange_contacts_job_spec.rb +++ b/spec/jobs/xmpp_exchange_contacts_job_spec.rb @@ -1,5 +1,4 @@ require 'rails_helper' -require 'webmock/rspec' RSpec.describe XmppExchangeContactsJob, type: :job do let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" } @@ -10,19 +9,11 @@ RSpec.describe XmppExchangeContactsJob, type: :job do described_class.perform_later(user, guest) } - before do - stub_request(:post, "http://xmpp.example.com/api/add_rosteritem") - .to_return(status: 200, body: "", headers: {}) - allow_any_instance_of(User).to receive(:services_enabled).and_return(["ejabberd"]) - end + it "calls the service for exchanging contacts" do + expect(EjabberdManager::ExchangeContacts).to receive(:call) + .with(inviter: user, invitee: guest).and_return(true) - it "posts add_rosteritem commands to the ejabberd API" do perform_enqueued_jobs { job } - - expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem") - .with { |req| req.body == '{"localuser":"isaacnewton","localhost":"kosmos.org","user":"willherschel","host":"kosmos.org","nick":"willherschel","group":"Buddies","subs":"both"}' } - expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem") - .with { |req| req.body == '{"localuser":"willherschel","localhost":"kosmos.org","user":"isaacnewton","host":"kosmos.org","nick":"isaacnewton","group":"Buddies","subs":"both"}' } end after do diff --git a/spec/jobs/xmpp_send_message_spec.rb b/spec/jobs/xmpp_send_message_spec.rb new file mode 100644 index 0000000..60166b9 --- /dev/null +++ b/spec/jobs/xmpp_send_message_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe XmppSendMessageJob, type: :job do + let(:payload) {{ + type: "normal", + from: "kosmos.org", to: "willherschel@kosmos.org", + body: "This is a test message" + }} + + subject(:job) { + described_class.perform_later(payload) + } + + it "calls the service for exchanging contacts" do + expect(EjabberdManager::SendMessage).to receive(:call) + .with(payload: payload).and_return(true) + + perform_enqueued_jobs { job } + end + + after do + clear_enqueued_jobs + clear_performed_jobs + end +end diff --git a/spec/jobs/xmpp_set_default_bookmarks_job_spec.rb b/spec/jobs/xmpp_set_default_bookmarks_job_spec.rb index 299cbcc..78c209f 100644 --- a/spec/jobs/xmpp_set_default_bookmarks_job_spec.rb +++ b/spec/jobs/xmpp_set_default_bookmarks_job_spec.rb @@ -1,30 +1,17 @@ require 'rails_helper' -require 'webmock/rspec' RSpec.describe XmppSetDefaultBookmarksJob, type: :job do let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" } - before do - Setting.xmpp_default_rooms = [ - "Welcome ", - "Kosmos Dev " - ] - end - subject(:job) { described_class.perform_later(user) } - before do - stub_request(:post, "http://xmpp.example.com/api/private_set") - .to_return(status: 200, body: "", headers: {}) - end + it "calls the service for setting default bookmarks" do + expect(EjabberdManager::SetDefaultBookmarks).to receive(:call) + .with(user: user).and_return(true) - it "posts a private_set command to the ejabberd API" do perform_enqueued_jobs { job } - - expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/private_set") - .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","element":"\u003cstorage xmlns=\'storage:bookmarks\'\u003e\u003cconference jid=\'welcome@kosmos.chat\' name=\'Welcome\' autojoin=\'false\'\u003e\u003cnick\u003ewillherschel\u003c/nick\u003e\u003c/conference\u003e\u003cconference jid=\'kosmos-dev@kosmos.chat\' name=\'Kosmos Dev\' autojoin=\'false\'\u003e\u003cnick\u003ewillherschel\u003c/nick\u003e\u003c/conference\u003e\u003c/storage\u003e"}' } end after do diff --git a/spec/services/ejabberd_manager/exchange_contacts_spec.rb b/spec/services/ejabberd_manager/exchange_contacts_spec.rb new file mode 100644 index 0000000..b15e436 --- /dev/null +++ b/spec/services/ejabberd_manager/exchange_contacts_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe EjabberdManager::ExchangeContacts, type: :model do + let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" } + let(:guest) { create :user, cn: "isaacnewton", ou: "kosmos.org", + id: 2, email: "hotapple42@eol.com" } + + before do + stub_request(:post, "http://xmpp.example.com/api/add_rosteritem") + .to_return(status: 200, body: "", headers: {}) + allow_any_instance_of(User).to receive(:services_enabled).and_return(["ejabberd"]) + described_class.call(inviter: user, invitee: guest) + end + + it "posts add_rosteritem commands to the ejabberd API" do + expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem") + .with { |req| req.body == '{"localuser":"isaacnewton","localhost":"kosmos.org","user":"willherschel","host":"kosmos.org","nick":"willherschel","group":"Buddies","subs":"both"}' } + expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem") + .with { |req| req.body == '{"localuser":"willherschel","localhost":"kosmos.org","user":"isaacnewton","host":"kosmos.org","nick":"isaacnewton","group":"Buddies","subs":"both"}' } + end +end diff --git a/spec/services/ejabberd_manager/get_avatar_spec.rb b/spec/services/ejabberd_manager/get_avatar_spec.rb new file mode 100644 index 0000000..f7772d5 --- /dev/null +++ b/spec/services/ejabberd_manager/get_avatar_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe EjabberdManager::GetAvatar, type: :model do + let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" } + let(:img_base64) { File.read("#{Rails.root}/spec/fixtures/files/avatar-base64-png.txt").chomp } + + context "when no avatar is set yet" do + before do + stub_request(:post, "http://xmpp.example.com/api/get_vcard2") + .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","name":"PHOTO","subname":"BINVAL"}' } + .to_return(status: 400, body: "", headers: {}) + end + + it "returns nil" do + res = described_class.call(user: user) + expect(res).to be_nil + end + end + + context "when avatar exists" do + before do + stub_request(:post, "http://xmpp.example.com/api/get_vcard2") + .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","name":"PHOTO","subname":"BINVAL"}' } + .and_return(status: 200, body: { content: img_base64 }.to_json, headers: {}) + stub_request(:post, "http://xmpp.example.com/api/get_vcard2") + .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","name":"PHOTO","subname":"TYPE"}' } + .and_return(status: 200, body: { content: "image/png" }.to_json, headers: {}) + end + + it "fetches the avatar and content type" do + res = described_class.call(user: user) + expect(res[:img_base64]).to eq(img_base64) + expect(res[:content_type]).to eq("image/png") + end + end +end diff --git a/spec/services/ejabberd_manager/set_default_bookmarks_spec.rb b/spec/services/ejabberd_manager/set_default_bookmarks_spec.rb new file mode 100644 index 0000000..2986a4c --- /dev/null +++ b/spec/services/ejabberd_manager/set_default_bookmarks_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe EjabberdManager::SetDefaultBookmarks, type: :model do + let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" } + + before do + Setting.xmpp_default_rooms = [ + "Welcome ", + "Kosmos Dev " + ] + stub_request(:post, "http://xmpp.example.com/api/private_set") + .to_return(status: 200, body: "", headers: {}) + + described_class.call(user:) + end + + it "posts a private_set command to the ejabberd API" do + expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/private_set") + .with { |req| req.body == '{"user":"willherschel","host":"kosmos.org","element":"\u003cstorage xmlns=\'storage:bookmarks\'\u003e\u003cconference jid=\'welcome@kosmos.chat\' name=\'Welcome\' autojoin=\'false\'\u003e\u003cnick\u003ewillherschel\u003c/nick\u003e\u003c/conference\u003e\u003cconference jid=\'kosmos-dev@kosmos.chat\' name=\'Kosmos Dev\' autojoin=\'false\'\u003e\u003cnick\u003ewillherschel\u003c/nick\u003e\u003c/conference\u003e\u003c/storage\u003e"}' } + end +end From 393f85e45ce71f7b5fe847c55bbbc15e9eeb2e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 13:32:58 +0400 Subject: [PATCH 02/15] WIP Add member/contributor status to users --- app/controllers/admin/users_controller.rb | 16 ++++-- .../concerns/settings/member_settings.rb | 10 ++++ app/models/user.rb | 52 +++++++++++++++---- app/services/ldap_service.rb | 29 +++++++---- app/views/admin/users/index.html.erb | 18 ++++++- app/views/admin/users/show.html.erb | 7 +++ lib/tasks/ldap.rake | 4 +- schemas/ldap/member_status.ldif | 8 +++ spec/models/user_spec.rb | 2 +- 9 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 app/models/concerns/settings/member_settings.rb create mode 100644 schemas/ldap/member_status.ldif diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index e73d208..105241f 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -4,13 +4,19 @@ class Admin::UsersController < Admin::BaseController # GET /admin/users def index - ldap = LdapService.new - @ou = Setting.primary_domain - @pagy, @users = pagy(User.where(ou: @ou).order(cn: :asc)) + ldap = LdapService.new + ou = Setting.primary_domain + + @admins = ldap.search_users(:admin, true, :cn) + @contributors = ldap.search_users(:memberStatus, :contributor, :cn) + @sustainers = ldap.search_users(:memberStatus, :sustainer, :cn) + @pagy, @users = pagy(User.where(ou: ou).order(cn: :asc)) @stats = { - users_confirmed: User.where(ou: @ou).confirmed.count, - users_pending: User.where(ou: @ou).pending.count + users_confirmed: User.where(ou: ou).confirmed.count, + users_pending: User.where(ou: ou).pending.count, + users_contributing: @contributors.size, + users_paying: @sustainers.size } end diff --git a/app/models/concerns/settings/member_settings.rb b/app/models/concerns/settings/member_settings.rb new file mode 100644 index 0000000..09c648b --- /dev/null +++ b/app/models/concerns/settings/member_settings.rb @@ -0,0 +1,10 @@ +module Settings + module MemberSettings + extend ActiveSupport::Concern + + included do + field :member_default_status, type: :string, + default: ENV["MEMBER_DEFAULT_STATUS"].presence + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index a0c49b0..4479800 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -163,7 +163,21 @@ class User < ApplicationRecord def ldap_entry(reload: false) return @ldap_entry if defined?(@ldap_entry) && !reload - @ldap_entry = ldap.fetch_users(uid: self.cn, ou: self.ou).first + @ldap_entry = ldap.fetch_users(cn: self.cn).first + end + + def add_to_ldap_array(attr_key, ldap_attr, value) + current_entries = ldap_entry[attr_key.to_sym] || [] + new_entries = Array(value).map(&:to_s) + entries = (current_entries + new_entries).uniq.sort + ldap.replace_attribute(dn, ldap_attr.to_sym, entries) + end + + def remove_from_ldap_array(attr_key, ldap_attr, value) + current_entries = ldap_entry[attr_key.to_sym] || [] + entries_to_remove = Array(value).map(&:to_s) + entries = (current_entries - entries_to_remove).uniq.sort + ldap.replace_attribute(dn, ldap_attr.to_sym, entries) end def display_name @@ -220,21 +234,39 @@ class User < ApplicationRecord end def enable_service(service) - current_services = services_enabled - new_services = Array(service).map(&:to_s) - services = (current_services + new_services).uniq.sort - ldap.replace_attribute(dn, :serviceEnabled, services) + add_to_ldap_array :services_enabled, :serviceEnabled, service + ldap_entry(reload: true)[:services_enabled] end def disable_service(service) - current_services = services_enabled - disabled_services = Array(service).map(&:to_s) - services = (current_services - disabled_services).uniq.sort - ldap.replace_attribute(dn, :serviceEnabled, services) + remove_from_ldap_array :services_enabled, :serviceEnabled, service + ldap_entry(reload: true)[:services_enabled] end def disable_all_services - ldap.delete_attribute(dn,:service) + ldap.delete_attribute(dn, :serviceEnabled) + end + + def member_status + ldap_entry[:member_status] || [] + end + + def add_member_status(status) + add_to_ldap_array :member_status, :memberStatus, status + ldap_entry(reload: true)[:member_status] + end + + def remove_member_status(status) + remove_from_ldap_array :member_status, :memberStatus, status + ldap_entry(reload: true)[:member_status] + end + + def is_contributing_member? + member_status.map(&:to_sym).include?(:contributor) + end + + def is_paying_member? + member_status.map(&:to_sym).include?(:sustainer) end private diff --git a/app/services/ldap_service.rb b/app/services/ldap_service.rb index 316a58a..a7e9ff5 100644 --- a/app/services/ldap_service.rb +++ b/app/services/ldap_service.rb @@ -50,19 +50,17 @@ class LdapService < ApplicationService end def fetch_users(args={}) - if args[:ou] - treebase = "ou=#{args[:ou]},cn=users,#{ldap_suffix}" - else - treebase = ldap_config["base"] - end - attributes = %w[ - dn cn uid mail displayName admin serviceEnabled + dn cn uid mail displayName admin serviceEnabled memberStatus mailRoutingAddress mailpassword nostrKey pgpKey ] - filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*") + filter = Net::LDAP::Filter.eq('objectClass', 'person') & + Net::LDAP::Filter.eq("cn", args[:cn] || "*") - entries = client.search(base: treebase, filter: filter, attributes: attributes) + entries = client.search( + base: ldap_config["base"], filter: filter, + attributes: attributes + ) entries.sort_by! { |e| e.cn[0] } entries = entries.collect do |e| { @@ -71,6 +69,7 @@ class LdapService < ApplicationService display_name: e.try(:displayName) ? e.displayName.first : nil, admin: e.try(:admin) ? 'admin' : nil, services_enabled: e.try(:serviceEnabled), + member_status: e.try(:memberStatus), email_maildrop: e.try(:mailRoutingAddress), email_password: e.try(:mailpassword), nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil, @@ -79,10 +78,20 @@ class LdapService < ApplicationService end end + def search_users(search_attr, value, return_attr) + filter = Net::LDAP::Filter.eq('objectClass', 'person') & + Net::LDAP::Filter.eq(search_attr.to_s, value.to_s) & + Net::LDAP::Filter.present('cn') + entries = client.search( + base: ldap_config["base"], filter: filter, + attributes: [return_attr] + ) + entries.map { |entry| entry[return_attr].first }.compact + end + def fetch_organizations attributes = %w{dn ou description} filter = Net::LDAP::Filter.eq("objectClass", "organizationalUnit") - # filter = Net::LDAP::Filter.eq("objectClass", "*") treebase = "cn=users,#{ldap_suffix}" entries = client.search(base: treebase, filter: filter, attributes: attributes) diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 830057a..c2521e8 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -13,6 +13,16 @@ title: 'Pending', value: @stats[:users_pending], ) %> + <%= render QuickstatsItemComponent.new( + type: :number, + title: 'Contributors', + value: @stats[:users_contributing], + ) %> + <%= render QuickstatsItemComponent.new( + type: :number, + title: 'Sustainers', + value: @stats[:users_paying], + ) %> <% end %> @@ -29,8 +39,12 @@ <% @users.each do |user| %> <%= link_to(user.cn, admin_user_path(user.cn), class: 'ks-text-link') %> - <%= user.confirmed_at.nil? ? badge("pending", :yellow) : "" %> - <%= user.is_admin? ? badge("admin", :red) : "" %> + + <%= user.confirmed_at.nil? ? badge("pending", :yellow) : "" %> + <%= @contributors.include?(user.cn) ? badge("contributor", :green) : "" %> + <%= @sustainers.include?(user.cn) ? badge("sustainer", :green) : "" %> + + <%= @admins.include?(user.cn) ? badge("admin", :red) : "" %> <% end %> diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 2c101b8..b5bfd5d 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -32,6 +32,13 @@ Roles <%= @user.is_admin? ? badge("admin", :red) : "—" %> + + Status + + <%= @user.is_contributing_member? ? badge("contributor", :green) : "" %> + <%= @user.is_paying_member? ? badge("sustainer", :green) : "" %> + + Invited by diff --git a/lib/tasks/ldap.rake b/lib/tasks/ldap.rake index 244a7d2..9f078bf 100644 --- a/lib/tasks/ldap.rake +++ b/lib/tasks/ldap.rake @@ -21,7 +21,7 @@ namespace :ldap do desc "Add custom attributes to schema" task add_custom_attributes: :environment do |t, args| - %w[ admin service_enabled nostr_key pgp_key ].each do |name| + %w[ admin service_enabled member_status nostr_key pgp_key ].each do |name| Rake::Task["ldap:modify_ldap_schema"].invoke(name, "add") Rake::Task['ldap:modify_ldap_schema'].reenable end @@ -29,7 +29,7 @@ namespace :ldap do desc "Delete custom attributes from schema" task delete_custom_attributes: :environment do |t, args| - %w[ admin service_enabled nostr_key pgp_key ].each do |name| + %w[ admin service_enabled member_status nostr_key pgp_key ].each do |name| Rake::Task["ldap:modify_ldap_schema"].invoke(name, "delete") Rake::Task['ldap:modify_ldap_schema'].reenable end diff --git a/schemas/ldap/member_status.ldif b/schemas/ldap/member_status.ldif new file mode 100644 index 0000000..4b55f6f --- /dev/null +++ b/schemas/ldap/member_status.ldif @@ -0,0 +1,8 @@ +dn: cn=schema +changetype: modify +add: attributeTypes +attributeTypes: ( 1.3.6.1.4.1.61554.1.1.2.1.3 + NAME 'memberStatus' + DESC 'Current member/contributor status' + EQUALITY caseExactMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a4e0f57..9b96edc 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -154,7 +154,7 @@ RSpec.describe User, type: :model do it "removes all services from the LDAP entry" do expect_any_instance_of(LdapService).to receive(:delete_attribute) - .with(dn, :service).and_return(true) + .with(dn, :serviceEnabled).and_return(true) user.disable_all_services end From 0b4bc4ef5c619c643a47187f5a63d7ac1a6d6699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 14:58:09 +0400 Subject: [PATCH 03/15] Improve color shade of sidebar link icon Was a bit bright --- app/components/sidenav_link_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/sidenav_link_component.rb b/app/components/sidenav_link_component.rb index 9436a67..8a3614b 100644 --- a/app/components/sidenav_link_component.rb +++ b/app/components/sidenav_link_component.rb @@ -29,7 +29,7 @@ class SidenavLinkComponent < ViewComponent::Base def class_names_icon(path) if @active - "text-teal-500 group-hover:text-teal-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" + "text-teal-600 group-hover:text-teal-600 flex-shrink-0 -ml-1 mr-3 h-6 w-6" elsif @disabled "text-gray-300 group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6" else From f313686b138a716f67c190ea1ecd3eade46c7817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 14:59:10 +0400 Subject: [PATCH 04/15] Add settings for member statuses --- .../admin/settings/membership_controller.rb | 23 ++++++++ app/controllers/admin/users_controller.rb | 12 +++-- .../concerns/settings/member_settings.rb | 10 ---- .../concerns/settings/membership_settings.rb | 18 +++++++ app/models/setting.rb | 1 + .../admin/settings/membership/show.html.erb | 53 +++++++++++++++++++ app/views/admin/users/index.html.erb | 12 +++-- app/views/icons/_server.html.erb | 2 +- app/views/icons/_users.html.erb | 2 +- .../shared/_admin_sidenav_settings.html.erb | 6 ++- config/routes.rb | 1 + 11 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 app/controllers/admin/settings/membership_controller.rb delete mode 100644 app/models/concerns/settings/member_settings.rb create mode 100644 app/models/concerns/settings/membership_settings.rb create mode 100644 app/views/admin/settings/membership/show.html.erb diff --git a/app/controllers/admin/settings/membership_controller.rb b/app/controllers/admin/settings/membership_controller.rb new file mode 100644 index 0000000..f01e958 --- /dev/null +++ b/app/controllers/admin/settings/membership_controller.rb @@ -0,0 +1,23 @@ +class Admin::Settings::MembershipController < Admin::SettingsController + def show + end + + def update + update_settings + + redirect_to admin_settings_membership_path, flash: { + success: "Settings saved" + } + end + + private + + def setting_params + params.require(:setting).permit([ + :member_status_contributor, + :member_status_sustainer, + :user_index_show_contributors, + :user_index_show_sustainers + ]) + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 105241f..0baeed7 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -6,18 +6,20 @@ class Admin::UsersController < Admin::BaseController def index ldap = LdapService.new ou = Setting.primary_domain + @show_contributors = Setting.user_index_show_contributors + @show_sustainers = Setting.user_index_show_sustainers + @contributors = ldap.search_users(:memberStatus, :contributor, :cn) if @show_contributors + @sustainers = ldap.search_users(:memberStatus, :sustainer, :cn) if @show_sustainers @admins = ldap.search_users(:admin, true, :cn) - @contributors = ldap.search_users(:memberStatus, :contributor, :cn) - @sustainers = ldap.search_users(:memberStatus, :sustainer, :cn) @pagy, @users = pagy(User.where(ou: ou).order(cn: :asc)) @stats = { users_confirmed: User.where(ou: ou).confirmed.count, - users_pending: User.where(ou: ou).pending.count, - users_contributing: @contributors.size, - users_paying: @sustainers.size + users_pending: User.where(ou: ou).pending.count } + @stats[:users_contributing] = @contributors.size if @show_contributors + @stats[:users_paying] = @sustainers.size if @show_sustainers end # GET /admin/users/:username diff --git a/app/models/concerns/settings/member_settings.rb b/app/models/concerns/settings/member_settings.rb deleted file mode 100644 index 09c648b..0000000 --- a/app/models/concerns/settings/member_settings.rb +++ /dev/null @@ -1,10 +0,0 @@ -module Settings - module MemberSettings - extend ActiveSupport::Concern - - included do - field :member_default_status, type: :string, - default: ENV["MEMBER_DEFAULT_STATUS"].presence - end - end -end diff --git a/app/models/concerns/settings/membership_settings.rb b/app/models/concerns/settings/membership_settings.rb new file mode 100644 index 0000000..bf4712c --- /dev/null +++ b/app/models/concerns/settings/membership_settings.rb @@ -0,0 +1,18 @@ +module Settings + module MembershipSettings + extend ActiveSupport::Concern + + included do + field :member_status_contributor, type: :string, + default: "Contributor" + field :member_status_sustainer, type: :string, + default: "Sustainer" + + # Admin panel + field :user_index_show_contributors, type: :boolean, + default: false + field :user_index_show_sustainers, type: :boolean, + default: false + end + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb index aa8072d..2c31ca8 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -16,6 +16,7 @@ class Setting < RailsSettings::Base include Settings::LightningNetworkSettings include Settings::MastodonSettings include Settings::MediaWikiSettings + include Settings::MembershipSettings include Settings::NostrSettings include Settings::OpenCollectiveSettings include Settings::RemoteStorageSettings diff --git a/app/views/admin/settings/membership/show.html.erb b/app/views/admin/settings/membership/show.html.erb new file mode 100644 index 0000000..5b403d6 --- /dev/null +++ b/app/views/admin/settings/membership/show.html.erb @@ -0,0 +1,53 @@ +<%= render HeaderComponent.new(title: "Settings") %> + +<%= render MainWithSidenavComponent.new(sidenav_partial: 'shared/admin_sidenav_settings') do %> + <%= form_for(Setting.new, url: admin_settings_membership_path, method: :put) do |f| %> +
+

Membership

+ + <% if @errors && @errors.any? %> + <%= render partial: "admin/settings/errors", locals: { errors: @errors } %> + <% end %> + +
    + <%= render FormElements::FieldsetResettableSettingComponent.new( + key: :member_status_contributor, + title: "Status name for contributing users", + description: "A contributing member of your organization/group" + ) %> + <%= render FormElements::FieldsetResettableSettingComponent.new( + key: :member_status_sustainer, + title: "Status name for paying users", + description: "A paying/donating member or customer" + ) %> +
+
+ +
+

Admin panel

+ +
    + <%= render FormElements::FieldsetToggleComponent.new( + form: f, + attribute: :user_index_show_contributors, + enabled: Setting.user_index_show_contributors?, + title: "Show #{Setting.member_status_contributor.downcase} status in user list", + description: "Can slow down page rendering with large user base" + ) %> + <%= render FormElements::FieldsetToggleComponent.new( + form: f, + attribute: :user_index_show_sustainers, + enabled: Setting.user_index_show_sustainers?, + title: "Show #{Setting.member_status_sustainer.downcase} status in user list", + description: "Can slow down page rendering with large user base" + ) %> +
+
+ +
+

+ <%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %> +

+
+ <% end %> +<% end %> diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index c2521e8..66bf6f6 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -13,16 +13,20 @@ title: 'Pending', value: @stats[:users_pending], ) %> + <% if @show_contributors %> <%= render QuickstatsItemComponent.new( type: :number, - title: 'Contributors', + title: Setting.member_status_contributor.pluralize, value: @stats[:users_contributing], ) %> + <% end %> + <% if @show_sustainers %> <%= render QuickstatsItemComponent.new( type: :number, - title: 'Sustainers', + title: Setting.member_status_sustainer.pluralize, value: @stats[:users_paying], ) %> + <% end %> <% end %> @@ -41,8 +45,8 @@ <%= link_to(user.cn, admin_user_path(user.cn), class: 'ks-text-link') %> <%= user.confirmed_at.nil? ? badge("pending", :yellow) : "" %> - <%= @contributors.include?(user.cn) ? badge("contributor", :green) : "" %> - <%= @sustainers.include?(user.cn) ? badge("sustainer", :green) : "" %> + <% if @show_contributors %><%= @contributors.include?(user.cn) ? badge("contributor", :green) : "" %><% end %> + <% if @show_sustainers %><%= @sustainers.include?(user.cn) ? badge("sustainer", :green) : "" %><% end %> <%= @admins.include?(user.cn) ? badge("admin", :red) : "" %> diff --git a/app/views/icons/_server.html.erb b/app/views/icons/_server.html.erb index 54ce094..f97725e 100644 --- a/app/views/icons/_server.html.erb +++ b/app/views/icons/_server.html.erb @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/views/icons/_users.html.erb b/app/views/icons/_users.html.erb index aacf6b0..81970a1 100644 --- a/app/views/icons/_users.html.erb +++ b/app/views/icons/_users.html.erb @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/views/shared/_admin_sidenav_settings.html.erb b/app/views/shared/_admin_sidenav_settings.html.erb index 48c5ced..14efb00 100644 --- a/app/views/shared/_admin_sidenav_settings.html.erb +++ b/app/views/shared/_admin_sidenav_settings.html.erb @@ -3,7 +3,11 @@ active: current_page?(admin_settings_registrations_path) ) %> <%= render SidenavLinkComponent.new( - name: "Services", path: admin_settings_services_path, icon: "grid", + name: "Membership", path: admin_settings_membership_path, icon: "users", + active: current_page?(admin_settings_membership_path) +) %> +<%= render SidenavLinkComponent.new( + name: "Services", path: admin_settings_services_path, icon: "server", active: controller_name == "services" ) %> <% if controller_name == "services" %> diff --git a/config/routes.rb b/config/routes.rb index 759abcc..115fadd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -109,6 +109,7 @@ Rails.application.routes.draw do namespace :settings do resource 'registrations', only: ['show', 'update'] + resource 'membership', only: ['show', 'update'], controller: 'membership' resources 'services', param: 'service', only: ['index', 'show', 'update'] end end From 463bf34cdf31544b59ad480dc8ede8069828b138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 15:12:19 +0400 Subject: [PATCH 05/15] Add link for icon library to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 530186b..4b69035 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ command: ### Front-end +* [Icons](https://feathericons.com) * [Tailwind CSS](https://tailwindcss.com/) * [Sass](https://sass-lang.com/documentation) * [Stimulus](https://stimulus.hotwired.dev/handbook/) From e48132cf5f91dfd8cb27b1fbac25905c35aa7243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 16:39:03 +0400 Subject: [PATCH 06/15] Set member status to sustainer upon payment Introduces a state machine for the payment status as well. refs #213 --- Gemfile | 1 + Gemfile.lock | 3 ++ .../contributions/donations_controller.rb | 9 ++-- app/jobs/btcpay_check_donation_job.rb | 4 +- app/models/donation.rb | 42 ++++++++++++++++--- .../contributions/donations/index.html.erb | 4 +- ...113805_update_payment_status_to_pending.rb | 6 +++ db/schema.rb | 2 +- spec/jobs/btcpay_check_donation_job_spec.rb | 19 ++++++--- spec/requests/contributions/donations_spec.rb | 7 +++- 10 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 db/migrate/20250527113805_update_payment_status_to_pending.rb diff --git a/Gemfile b/Gemfile index d691dee..20d1473 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ gem 'devise_ldap_authenticatable' gem 'net-ldap' # Utilities +gem 'aasm' gem "image_processing", "~> 1.12.2" gem "rqrcode", "~> 2.0" gem 'rails-settings-cached', '~> 2.8.3' diff --git a/Gemfile.lock b/Gemfile.lock index 396c65e..c96c0c3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,8 @@ GEM remote: https://rubygems.org/ specs: + aasm (5.5.0) + concurrent-ruby (~> 1.0) actioncable (8.0.2) actionpack (= 8.0.2) activesupport (= 8.0.2) @@ -526,6 +528,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + aasm aws-sdk-s3 bcrypt (~> 3.1) capybara diff --git a/app/controllers/contributions/donations_controller.rb b/app/controllers/contributions/donations_controller.rb index b9f46c4..99e614e 100644 --- a/app/controllers/contributions/donations_controller.rb +++ b/app/controllers/contributions/donations_controller.rb @@ -11,7 +11,7 @@ class Contributions::DonationsController < ApplicationController def index @current_section = :contributions @donations_completed = current_user.donations.completed.order('paid_at desc') - @donations_pending = current_user.donations.processing.order('created_at desc') + @donations_processing = current_user.donations.processing.order('created_at desc') if Setting.lndhub_enabled? begin @@ -81,14 +81,11 @@ class Contributions::DonationsController < ApplicationController case invoice["status"] when "Settled" - @donation.paid_at = DateTime.now - @donation.payment_status = "settled" - @donation.save! + @donation.complete! flash_message = { success: "Thank you!" } when "Processing" unless @donation.processing? - @donation.payment_status = "processing" - @donation.save! + @donation.start_processing! flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." } BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation) end diff --git a/app/jobs/btcpay_check_donation_job.rb b/app/jobs/btcpay_check_donation_job.rb index 3cfdbcb..839dda8 100644 --- a/app/jobs/btcpay_check_donation_job.rb +++ b/app/jobs/btcpay_check_donation_job.rb @@ -10,9 +10,7 @@ class BtcpayCheckDonationJob < ApplicationJob case invoice["status"] when "Settled" - donation.paid_at = DateTime.now - donation.payment_status = "settled" - donation.save! + donation.complete! NotificationMailer.with(user: donation.user) .bitcoin_donation_confirmed diff --git a/app/models/donation.rb b/app/models/donation.rb index 9799ae6..b319259 100644 --- a/app/models/donation.rb +++ b/app/models/donation.rb @@ -1,22 +1,41 @@ class Donation < ApplicationRecord - # Relations + include AASM + belongs_to :user - # Validations validates_presence_of :user validates_presence_of :donation_method, inclusion: { in: %w[ custom btcpay lndhub ] } validates_presence_of :payment_status, allow_nil: true, - inclusion: { in: %w[ processing settled ] } + inclusion: { in: %w[ pending processing settled ] } validates_presence_of :paid_at, allow_nil: true validates_presence_of :amount_sats, allow_nil: true validates_presence_of :fiat_amount, allow_nil: true validates_presence_of :fiat_currency, allow_nil: true, inclusion: { in: %w[ EUR USD ] } - #Scopes + scope :pending, -> { where(payment_status: "pending") } scope :processing, -> { where(payment_status: "processing") } - scope :completed, -> { where(payment_status: "settled") } + scope :completed, -> { where(payment_status: "settled") } + + aasm column: :payment_status do + state :pending, initial: true + state :processing + state :settled + + event :start_processing do + transitions from: :pending, to: :processing + end + + event :complete do + transitions from: :processing, to: :settled, after: [:set_paid_at, :set_sustainer_status] + transitions from: :pending, to: :settled, after: [:set_paid_at, :set_sustainer_status] + end + end + + def pending? + payment_status == "pending" + end def processing? payment_status == "processing" @@ -25,4 +44,17 @@ class Donation < ApplicationRecord def completed? payment_status == "settled" end + + private + + def set_paid_at + update paid_at: DateTime.now if paid_at.nil? + end + + def set_sustainer_status + user.add_member_status :sustainer + rescue => e + Sentry.capture_exception(e) if Setting.sentry_enabled? + Rails.logger.error("Failed to set memberStatus: #{e.message}") + end end diff --git a/app/views/contributions/donations/index.html.erb b/app/views/contributions/donations/index.html.erb index 3e03617..5c9d6db 100644 --- a/app/views/contributions/donations/index.html.erb +++ b/app/views/contributions/donations/index.html.erb @@ -22,11 +22,11 @@ - <% if @donations_pending.any? %> + <% if @donations_processing.any? %>

Pending

<%= render partial: "contributions/donations/list", - locals: { donations: @donations_pending } %> + locals: { donations: @donations_processing } %>
<% end %> diff --git a/db/migrate/20250527113805_update_payment_status_to_pending.rb b/db/migrate/20250527113805_update_payment_status_to_pending.rb new file mode 100644 index 0000000..f334c21 --- /dev/null +++ b/db/migrate/20250527113805_update_payment_status_to_pending.rb @@ -0,0 +1,6 @@ +class UpdatePaymentStatusToPending < ActiveRecord::Migration[8.0] + def change + Donation.where(payment_status: nil).update_all(payment_status: "pending") + Donation.where.not(payment_status: %w[pending processing settled]).update_all(payment_status: "pending") + end +end diff --git a/db/schema.rb b/db/schema.rb index 1140431..b7746ac 100644 --- a/db/schema.rb +++ b/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: 2025_05_17_105755) do +ActiveRecord::Schema[8.0].define(version: 2025_05_27_113805) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false diff --git a/spec/jobs/btcpay_check_donation_job_spec.rb b/spec/jobs/btcpay_check_donation_job_spec.rb index 71cd1df..6e12adb 100644 --- a/spec/jobs/btcpay_check_donation_job_spec.rb +++ b/spec/jobs/btcpay_check_donation_job_spec.rb @@ -8,16 +8,18 @@ RSpec.describe BtcpayCheckDonationJob, type: :job do user.donations.create!( donation_method: "btcpay", btcpay_invoice_id: "K4e31MhbLKmr3D7qoNYRd3", - paid_at: nil, payment_status: "processing", - fiat_amount: 120, fiat_currency: "USD" + paid_at: nil, + payment_status: "processing", + fiat_amount: 120, + fiat_currency: "USD" ) end before do allow_any_instance_of(User).to receive(:ldap_entry).and_return({ - uid: user.cn, ou: user.ou, mail: user.email, admin: nil, - display_name: nil + uid: user.cn, ou: user.ou, mail: user.email, admin: nil, display_name: nil }) + allow_any_instance_of(User).to receive(:add_member_status) end after(:each) do @@ -65,15 +67,20 @@ RSpec.describe BtcpayCheckDonationJob, type: :job do it "notifies the user via email" do perform_enqueued_jobs(only: described_class) { job } expect(enqueued_jobs.size).to eq(1) - job = enqueued_jobs.select{|j| j['job_class'] == "ActionMailer::MailDeliveryJob"}.first + job = enqueued_jobs.select { |j| j['job_class'] == "ActionMailer::MailDeliveryJob" }.first expect(job['arguments'][0]).to eq('NotificationMailer') expect(job['arguments'][1]).to eq('bitcoin_donation_confirmed') - expect(job['arguments'][3]['params']['user']['_aj_globalid']).to eq('gid://akkounts/User/1') + expect(job['arguments'][3]['params']['user']['_aj_globalid']).to eq(user.to_global_id.to_s) end it "does not enqueue itself again" do expect_any_instance_of(described_class).not_to receive(:re_enqueue_job) perform_enqueued_jobs(only: described_class) { job } end + + it "updates the user's member status" do + expect_any_instance_of(User).to receive(:add_member_status).with(:sustainer) + perform_enqueued_jobs(only: described_class) { job } + end end end diff --git a/spec/requests/contributions/donations_spec.rb b/spec/requests/contributions/donations_spec.rb index 01397eb..e6d1392 100644 --- a/spec/requests/contributions/donations_spec.rb +++ b/spec/requests/contributions/donations_spec.rb @@ -177,7 +177,7 @@ RSpec.describe "Donations", type: :request do .to_return(status: 200, headers: {}, body: invoice) stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/MCkDbf2cUgBuuisUCgnRnb/payment-methods") .to_return(status: 200, headers: {}, body: payments) - + allow(user).to receive(:add_member_status).with(:sustainer).and_return(["sustainer"]) get confirm_btcpay_contributions_donation_path(subject) end @@ -185,11 +185,16 @@ RSpec.describe "Donations", type: :request do subject.reload expect(subject.paid_at).not_to be_nil expect(subject.amount_sats).to eq(2061) + expect(subject.payment_status).to eq("settled") end it "redirects to the donations index" do expect(response).to redirect_to(contributions_donations_url) end + + it "updates the user's member status" do + expect(user).to have_received(:add_member_status).with(:sustainer) + end end describe "amount in sats" do From b657a25d4d62fde05336ffa9d7f94d91d18e8c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 17:16:26 +0400 Subject: [PATCH 07/15] Wording --- app/views/contributions/donations/index.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/contributions/donations/index.html.erb b/app/views/contributions/donations/index.html.erb index 5c9d6db..2e9d33c 100644 --- a/app/views/contributions/donations/index.html.erb +++ b/app/views/contributions/donations/index.html.erb @@ -32,7 +32,7 @@ <% if @donations_completed.any? %>
-

Past contributions

+

Contributions

<%= render partial: "contributions/donations/list", locals: { donations: @donations_completed } %>
From 7a8ca0707a0bafc25ab6527dbca93662dad2465d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 17:17:56 +0400 Subject: [PATCH 08/15] Add missing dash for no member status --- app/views/admin/users/show.html.erb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index b5bfd5d..0490cea 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -35,8 +35,12 @@ Status - <%= @user.is_contributing_member? ? badge("contributor", :green) : "" %> - <%= @user.is_paying_member? ? badge("sustainer", :green) : "" %> + <% if @user.is_contributing_member? || @user.is_paying_member? %> + <%= @user.is_contributing_member? ? badge("contributor", :green) : "" %> + <%= @user.is_paying_member? ? badge("sustainer", :green) : "" %> + <% else %> + — + <% end %> From fff7527694ca81d9c689ee742f8ab51e178a1837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 17:35:48 +0400 Subject: [PATCH 09/15] Don't show njump link when no pubkey set --- app/views/admin/users/show.html.erb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 0490cea..101a360 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -278,7 +278,9 @@ ) %> + <% if @user.nostr_pubkey.present? %> <%= link_to "Open profile", "https://njump.me/#{@user.nostr_pubkey_bech32}", class: "btn-sm btn-gray" %> + <% end %> <% end %> From 71352d13d2fca30d5adef556b3c7544ac9d82c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 18:08:22 +0400 Subject: [PATCH 10/15] Add pending donations to admin donations index And add more info to the details page --- app/controllers/admin/donations_controller.rb | 1 + app/models/donation.rb | 1 + app/views/admin/donations/_list.html.erb | 32 +++++++++++++ app/views/admin/donations/index.html.erb | 46 ++++++------------- app/views/admin/donations/show.html.erb | 10 +++- 5 files changed, 57 insertions(+), 33 deletions(-) create mode 100644 app/views/admin/donations/_list.html.erb diff --git a/app/controllers/admin/donations_controller.rb b/app/controllers/admin/donations_controller.rb index 51ea4e3..bdfff93 100644 --- a/app/controllers/admin/donations_controller.rb +++ b/app/controllers/admin/donations_controller.rb @@ -5,6 +5,7 @@ class Admin::DonationsController < Admin::BaseController # GET /donations def index @pagy, @donations = pagy(Donation.completed.order('paid_at desc')) + @pending_donations = Donation.incomplete.order('paid_at desc') @stats = { overall_sats: @donations.sum("amount_sats"), diff --git a/app/models/donation.rb b/app/models/donation.rb index b319259..f3e585c 100644 --- a/app/models/donation.rb +++ b/app/models/donation.rb @@ -17,6 +17,7 @@ class Donation < ApplicationRecord scope :pending, -> { where(payment_status: "pending") } scope :processing, -> { where(payment_status: "processing") } scope :completed, -> { where(payment_status: "settled") } + scope :incomplete, -> { where.not(payment_status: "settled") } aasm column: :payment_status do state :pending, initial: true diff --git a/app/views/admin/donations/_list.html.erb b/app/views/admin/donations/_list.html.erb new file mode 100644 index 0000000..8a75699 --- /dev/null +++ b/app/views/admin/donations/_list.html.erb @@ -0,0 +1,32 @@ + + + + + + + + + + + + + <% donations.each do |donation| %> + + + + + + + + + <% end %> + +
UserSatsFiat AmountPublic nameDate
<%= link_to donation.user.cn, admin_user_path(donation.user.cn), class: 'ks-text-link' %><% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %><% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %><%= donation.public_name %><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : donation.created_at.strftime("%Y-%m-%d (%H:%M UTC)") %> + <%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %> + <%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %> + <%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red', + data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %> +
+<% if defined?(pagy) %> +<%== pagy_nav pagy %> +<% end %> diff --git a/app/views/admin/donations/index.html.erb b/app/views/admin/donations/index.html.erb index 90a755e..2fa6f91 100644 --- a/app/views/admin/donations/index.html.erb +++ b/app/views/admin/donations/index.html.erb @@ -18,42 +18,24 @@ <% end %> + <% if @pending_donations.any? %> +
+

Pending

+ <%= render partial: "admin/donations/list", locals: { + donations: @pending_donations + } %> +
+ <% end %> +
<% if @donations.any? %> -

Recent Donations

- - - - - - - - - - - - - <% @donations.each do |donation| %> - - - - - - - - - <% end %> - -
UserSatsFiat AmountPublic nameDate
<%= link_to donation.user.cn, admin_user_path(donation.user.cn), class: 'ks-text-link' %><% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %><% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %><%= donation.public_name %><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %> - <%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %> - <%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %> - <%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red', - data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %> -
- <%== pagy_nav @pagy %> +

Recent

+ <%= render partial: "admin/donations/list", locals: { + donations: @donations, pagy: @pagy + } %> <% else %>

- No donations yet. + No donations received yet.

<% end %>
diff --git a/app/views/admin/donations/show.html.erb b/app/views/admin/donations/show.html.erb index 00b32cc..3e3ad65 100644 --- a/app/views/admin/donations/show.html.erb +++ b/app/views/admin/donations/show.html.erb @@ -25,7 +25,15 @@ <%= @donation.public_name %> - Date + Payment status + <%= @donation.payment_status %> + + + Created at + <%= @donation.created_at&.strftime("%Y-%m-%d (%H:%M UTC)") %> + + + Paid at <%= @donation.paid_at&.strftime("%Y-%m-%d (%H:%M UTC)") %> From 04094efbdb0402cb81789ac00225bccc9ce24339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 18:43:45 +0400 Subject: [PATCH 11/15] Add username filter with UI to admin donations page --- app/controllers/admin/donations_controller.rb | 16 ++++++++++++--- app/views/admin/donations/_list.html.erb | 6 ++++-- app/views/admin/donations/index.html.erb | 20 ++++++++++++++++--- app/views/icons/_filter.html.erb | 2 +- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/controllers/admin/donations_controller.rb b/app/controllers/admin/donations_controller.rb index bdfff93..ca394e0 100644 --- a/app/controllers/admin/donations_controller.rb +++ b/app/controllers/admin/donations_controller.rb @@ -4,11 +4,21 @@ class Admin::DonationsController < Admin::BaseController # GET /donations def index - @pagy, @donations = pagy(Donation.completed.order('paid_at desc')) - @pending_donations = Donation.incomplete.order('paid_at desc') + @username = params[:username].presence + + pending_scope = Donation.incomplete.joins(:user).order('paid_at desc') + completed_scope = Donation.completed.joins(:user).order('paid_at desc') + + if @username + pending_scope = pending_scope.where(users: { cn: @username }) + completed_scope = completed_scope.where(users: { cn: @username }) + end + + @pending_donations = pending_scope + @pagy, @donations = pagy(completed_scope) @stats = { - overall_sats: @donations.sum("amount_sats"), + overall_sats: Donation.completed.sum("amount_sats"), donor_count: Donation.completed.count(:user_id) } end diff --git a/app/views/admin/donations/_list.html.erb b/app/views/admin/donations/_list.html.erb index 8a75699..aa405ec 100644 --- a/app/views/admin/donations/_list.html.erb +++ b/app/views/admin/donations/_list.html.erb @@ -1,4 +1,4 @@ - +
@@ -28,5 +28,7 @@
User
<% if defined?(pagy) %> -<%== pagy_nav pagy %> +
+ <%== pagy_nav pagy %> +
<% end %> diff --git a/app/views/admin/donations/index.html.erb b/app/views/admin/donations/index.html.erb index 2fa6f91..8003568 100644 --- a/app/views/admin/donations/index.html.erb +++ b/app/views/admin/donations/index.html.erb @@ -18,7 +18,21 @@ <% end %> - <% if @pending_donations.any? %> +
+ <%= form_with url: admin_donations_path, method: :get, local: true, class: "flex gap-1" do %> + <%= text_field_tag :username, @username, placeholder: 'Filter by username' %> + <%= button_tag type: 'submit', name: nil, title: "Filter", class: 'btn-md btn-icon btn-outline' do %> + <%= render partial: "icons/filter", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %> + <% end %> + <% if @username %> + <%= link_to admin_donations_path, title: "Remove filter", class: 'btn-md btn-icon btn-outline' do %> + <%= render partial: "icons/x", locals: { custom_class: "text-red-600 h-4 w-4 inline" } %> + <% end %> + <% end %> + <% end %> +
+ + <% if @pending_donations.present? %>

Pending

<%= render partial: "admin/donations/list", locals: { @@ -28,8 +42,8 @@ <% end %>
- <% if @donations.any? %> -

Recent

+ <% if @donations.present? %> +

Received

<%= render partial: "admin/donations/list", locals: { donations: @donations, pagy: @pagy } %> diff --git a/app/views/icons/_filter.html.erb b/app/views/icons/_filter.html.erb index 38a47e0..5adc889 100644 --- a/app/views/icons/_filter.html.erb +++ b/app/views/icons/_filter.html.erb @@ -1 +1 @@ - \ No newline at end of file + From c94a0e34d1a446aa0491b894c9e7dd6c6ef9933f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 27 May 2025 19:04:35 +0400 Subject: [PATCH 12/15] Add donations to user details, link to filtered list --- app/controllers/admin/donations_controller.rb | 4 ++-- app/views/admin/donations/index.html.erb | 2 +- app/views/admin/invitations/index.html.erb | 2 +- app/views/admin/users/show.html.erb | 23 +++++++++++++++---- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/controllers/admin/donations_controller.rb b/app/controllers/admin/donations_controller.rb index ca394e0..466fd44 100644 --- a/app/controllers/admin/donations_controller.rb +++ b/app/controllers/admin/donations_controller.rb @@ -18,8 +18,8 @@ class Admin::DonationsController < Admin::BaseController @pagy, @donations = pagy(completed_scope) @stats = { - overall_sats: Donation.completed.sum("amount_sats"), - donor_count: Donation.completed.count(:user_id) + overall_sats: completed_scope.sum("amount_sats"), + donor_count: completed_scope.count(:user_id) } end diff --git a/app/views/admin/donations/index.html.erb b/app/views/admin/donations/index.html.erb index 8003568..ca1acc1 100644 --- a/app/views/admin/donations/index.html.erb +++ b/app/views/admin/donations/index.html.erb @@ -5,7 +5,7 @@ <%= render QuickstatsContainerComponent.new do %> <%= render QuickstatsItemComponent.new( type: :number, - title: 'Overall', + title: 'Received', value: @stats[:overall_sats], unit: 'sats' ) %> diff --git a/app/views/admin/invitations/index.html.erb b/app/views/admin/invitations/index.html.erb index 7c2cd43..f18555f 100644 --- a/app/views/admin/invitations/index.html.erb +++ b/app/views/admin/invitations/index.html.erb @@ -23,7 +23,7 @@
<% if @invitations_used.any? %>
-

Recently Accepted

+

Accepted

diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 101a360..6147850 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -35,12 +35,25 @@ + + + + From e8f912360bfbb45b73cc69d6e502f165c977c44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 28 May 2025 12:11:26 +0400 Subject: [PATCH 13/15] Fix wrong stats number --- app/controllers/admin/donations_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/admin/donations_controller.rb b/app/controllers/admin/donations_controller.rb index 466fd44..c12a868 100644 --- a/app/controllers/admin/donations_controller.rb +++ b/app/controllers/admin/donations_controller.rb @@ -19,7 +19,7 @@ class Admin::DonationsController < Admin::BaseController @stats = { overall_sats: completed_scope.sum("amount_sats"), - donor_count: completed_scope.count(:user_id) + donor_count: completed_scope.distinct.count(:user_id) } end From c99d8545c1791dbe31d6511fab05851047697bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 28 May 2025 12:34:52 +0400 Subject: [PATCH 14/15] Add username filter to admin invitations index --- .../admin/invitations_controller.rb | 26 +++++++++++++++---- .../admin/_username_search_form.html.erb | 11 ++++++++ app/views/admin/donations/index.html.erb | 13 ++-------- app/views/admin/invitations/index.html.erb | 6 +++++ 4 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 app/views/admin/_username_search_form.html.erb diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb index 97a33b3..c5c0290 100644 --- a/app/controllers/admin/invitations_controller.rb +++ b/app/controllers/admin/invitations_controller.rb @@ -1,12 +1,28 @@ class Admin::InvitationsController < Admin::BaseController + before_action :set_current_section + def index - @current_section = :invitations - @pagy, @invitations_used = pagy(Invitation.used.order('used_at desc')) + @username = params[:username].presence + accepted_scope = Invitation.used.order('used_at desc') + unused_scope = Invitation.unused + + if @username + accepted_scope = accepted_scope.joins(:user).where(users: { cn: @username }) + unused_scope = unused_scope.joins(:user).where(users: { cn: @username }) + end + + @pagy, @invitations_used = pagy(accepted_scope) @stats = { - available: Invitation.unused.count, - accepted: @invitations_used.length, - users_with_referrals: Invitation.used.distinct.count(:user_id) + available: unused_scope.count, + accepted: accepted_scope.count, + users_with_referrals: accepted_scope.distinct.count(:user_id) } end + + private + + def set_current_section + @current_section = :invitations + end end diff --git a/app/views/admin/_username_search_form.html.erb b/app/views/admin/_username_search_form.html.erb new file mode 100644 index 0000000..b318c86 --- /dev/null +++ b/app/views/admin/_username_search_form.html.erb @@ -0,0 +1,11 @@ +<%= form_with url: path, method: :get, local: true, class: "flex gap-1" do %> + <%= text_field_tag :username, @username, placeholder: 'Filter by username' %> + <%= button_tag type: 'submit', name: nil, title: "Filter", class: 'btn-md btn-icon btn-outline' do %> + <%= render partial: "icons/filter", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %> + <% end %> + <% if @username %> + <%= link_to path, title: "Remove filter", class: 'btn-md btn-icon btn-outline' do %> + <%= render partial: "icons/x", locals: { custom_class: "text-red-600 h-4 w-4 inline" } %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/admin/donations/index.html.erb b/app/views/admin/donations/index.html.erb index ca1acc1..2e80ba2 100644 --- a/app/views/admin/donations/index.html.erb +++ b/app/views/admin/donations/index.html.erb @@ -19,17 +19,8 @@
- <%= form_with url: admin_donations_path, method: :get, local: true, class: "flex gap-1" do %> - <%= text_field_tag :username, @username, placeholder: 'Filter by username' %> - <%= button_tag type: 'submit', name: nil, title: "Filter", class: 'btn-md btn-icon btn-outline' do %> - <%= render partial: "icons/filter", locals: { custom_class: "text-blue-600 h-4 w-4 inline" } %> - <% end %> - <% if @username %> - <%= link_to admin_donations_path, title: "Remove filter", class: 'btn-md btn-icon btn-outline' do %> - <%= render partial: "icons/x", locals: { custom_class: "text-red-600 h-4 w-4 inline" } %> - <% end %> - <% end %> - <% end %> + <%= render partial: "admin/username_search_form", + locals: { path: admin_donations_path } %>
<% if @pending_donations.present? %> diff --git a/app/views/admin/invitations/index.html.erb b/app/views/admin/invitations/index.html.erb index f18555f..acc5606 100644 --- a/app/views/admin/invitations/index.html.erb +++ b/app/views/admin/invitations/index.html.erb @@ -21,6 +21,12 @@ ) %> <% end %> + +
+ <%= render partial: "admin/username_search_form", + locals: { path: admin_invitations_path } %> +
+ <% if @invitations_used.any? %>

Accepted

From c6a187b25a09445fe74290fe532fd89257395dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 28 May 2025 12:50:10 +0400 Subject: [PATCH 15/15] Limit invitees on admin user page, link to invitations for more --- app/controllers/admin/users_controller.rb | 4 ++++ app/views/admin/users/show.html.erb | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 0baeed7..e6504e4 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -24,6 +24,10 @@ class Admin::UsersController < Admin::BaseController # GET /admin/users/:username def show + @invitees = @user.invitees + @recent_invitees = @user.invitees.order(created_at: :desc).limit(5) + @more_invitees = (@invitees - @recent_invitees).count + if Setting.lndhub_admin_enabled? @lndhub_user = @user.lndhub_user end diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 6147850..367b5a7 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -99,10 +99,13 @@
Status - <% if @user.is_contributing_member? || @user.is_paying_member? %> - <%= @user.is_contributing_member? ? badge("contributor", :green) : "" %> - <%= @user.is_paying_member? ? badge("sustainer", :green) : "" %> - <% else %> - — + <% if @user.is_contributing_member? || @user.is_paying_member? %> + <%= @user.is_contributing_member? ? badge("contributor", :green) : "" %> + <%= @user.is_paying_member? ? badge("sustainer", :green) : "" %> + <% else %> + — + <% end %> +
Donations + <% if @user.donations.any? %> + <%= link_to admin_donations_path(username: @user.cn), class: "ks-text-link" do %> + <%= @user.donations.completed.count %> for + <%= number_with_delimiter @user.donations.completed.sum("amount_sats") %> sats <% end %> + <% else %> + — + <% end %>
Invited users - <% if @user.invitees.length > 0 %> + <% if @invitees.any? %>
    - <% @user.invitees.order(cn: :asc).each do |invitee| %> -
  • <%= link_to invitee.cn, admin_user_path(invitee.cn), class: 'ks-text-link' %>
  • + <% @recent_invitees.each do |invitee| %> +
  • <%= link_to invitee.cn, admin_user_path(invitee.cn), class: "ks-text-link" %>
  • + <% end %> + <% if @more_invitees > 0 %> +
  • and <%= link_to "#{@more_invitees} more", admin_invitations_path(username: @user.cn), class: "ks-text-link" %>
  • <% end %>
<% else %>—<% end %>