From dce0fcdac3d78c3e21c52c0dece0436904e8a823 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 15:53:01 -0230 Subject: [PATCH 01/25] Add debugging guidelines to AGENTS.md and copilot-instructions.md for improved test-driven development practices --- .github/copilot-instructions.md | 16 +++++++++++++++- AGENTS.md | 13 ++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e01d210b8..ba81eac26 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,6 +92,19 @@ This repository contains the **Better Together Community Engine** (an isolated R ## Coding Guidelines +### Debugging and Development Practices +- **Never use Rails console or runner for debugging** - These commands don't support our test-driven development approach +- **Debug through comprehensive tests**: Write detailed test scenarios to reproduce, understand, and verify fixes for issues +- **Use test-driven debugging workflow**: + - Create specific tests that reproduce the problematic behavior + - Add debugging assertions in tests to verify intermediate state + - Trace through code by reading files and using grep search + - Validate fixes by ensuring tests pass +- **Leverage RSpec debugging tools**: Use `--format documentation` for detailed output, `fit` for focused testing, `puts` for temporary debug output in tests +- **Analyze logs and error messages**: Examine Rails logs, test output, and stack traces for debugging information +- **Read code systematically**: Use file reading tools to understand code paths and data flow +- **Temporary debug output**: Add debug statements in application code if needed, but remove before committing + ### Docker Environment Usage - **All database-dependent commands must use `bin/dc-run`**: This includes tests, generators, and any command that connects to PostgreSQL, Redis, or Elasticsearch - **Dummy app commands use `bin/dc-run-dummy`**: For Rails commands that need the dummy app context (console, migrations specific to dummy app) @@ -102,10 +115,11 @@ This repository contains the **Better Together Community Engine** (an isolated R - RuboCop: `bin/dc-run bundle exec rubocop` - **IMPORTANT**: Never use `rspec -v` - this displays version info, not verbose output. Use `--format documentation` for detailed output. - **Examples of commands requiring `bin/dc-run-dummy`**: - - Rails console: `bin/dc-run-dummy rails console` + - Rails console: `bin/dc-run-dummy rails console` (for administrative tasks only, NOT for debugging) - Dummy app migrations: `bin/dc-run-dummy rails db:migrate` - Dummy app database operations: `bin/dc-run-dummy rails db:seed` - **Commands that don't require bin/dc-run**: File operations, documentation generation (unless database access needed), static analysis tools that don't connect to services +- **CRITICAL**: Rails console and runner are NOT debugging tools in this project - use comprehensive test suites instead ### Security Requirements - **Run Brakeman before generating code**: `bin/dc-run bundle exec brakeman --quiet --no-pager` diff --git a/AGENTS.md b/AGENTS.md index 347785440..f754090af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,17 @@ Instructions for GitHub Copilot and other automated contributors working in this - test: `community_engine_test` - Use `DATABASE_URL` to connect (overrides fallback host in `config/database.yml`). +## Debugging Guidelines +- **Never use Rails console or runner for debugging** - These commands don't align with our test-driven development approach +- **Use comprehensive test suites instead**: Write detailed tests to understand and verify system behavior +- **Debug through tests**: Create specific test scenarios to reproduce and validate fixes for issues +- **Use log analysis**: Examine Rails logs, test output, and error messages for debugging information +- **Add temporary debugging assertions in tests**: Use `expect()` statements to verify intermediate state in tests +- **Use RSpec debugging tools**: Use `--format documentation` for detailed test output, `fit` for focused testing +- **Trace through code by reading files**: Use file reading and grep search to understand code paths +- **Add debug output in application code temporarily** if needed, but remove before committing +- **Validate fixes through test success**: Confirm that issues are resolved by having tests pass + ## Commands - **Tests:** `bin/dc-run bin/ci` (Equivalent: `bin/dc-run bash -c "cd spec/dummy && bundle exec rspec"`) @@ -29,7 +40,7 @@ Instructions for GitHub Copilot and other automated contributors working in this - Multiple specific lines: `bin/dc-run bundle exec rspec spec/file1_spec.rb:123 spec/file2_spec.rb:456` - **Important**: RSpec does NOT support hyphenated line numbers (e.g., `spec/file_spec.rb:123-456` is INVALID) - **Do NOT use `-v` flag**: The `-v` flag displays RSpec version information, NOT verbose output. Use `--format documentation` for detailed test descriptions. -- **Rails Console:** `bin/dc-run-dummy rails console` (runs console in the dummy app context) +- **Rails Console:** `bin/dc-run-dummy rails console` (for administrative tasks only - NOT for debugging. Use comprehensive tests for debugging instead) - **Rails Commands in Dummy App:** `bin/dc-run-dummy rails [command]` for any Rails commands that need the dummy app environment - **Lint:** `bin/dc-run bundle exec rubocop` - **Security:** `bin/dc-run bundle exec brakeman --quiet --no-pager` and `bin/dc-run bundle exec bundler-audit --update` From 22563738b3b5c6667c23c68714e7311931c239c3 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 15:56:52 -0230 Subject: [PATCH 02/25] Refactor ElasticsearchIndexJob and GeocodingJob to improve error handling and job queueing --- app/jobs/better_together/elasticsearch_index_job.rb | 8 +++++++- app/jobs/better_together/geography/geocoding_job.rb | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/jobs/better_together/elasticsearch_index_job.rb b/app/jobs/better_together/elasticsearch_index_job.rb index f5cf1fa40..d16e634d7 100644 --- a/app/jobs/better_together/elasticsearch_index_job.rb +++ b/app/jobs/better_together/elasticsearch_index_job.rb @@ -5,7 +5,10 @@ module BetterTogether # This job is responsible for indexing and deleting documents in Elasticsearch # when records are created, updated, or destroyed. class ElasticsearchIndexJob < ApplicationJob - queue_as :default + queue_as :es_indexing + + # Don't retry on deserialization errors - the record no longer exists + discard_on ActiveJob::DeserializationError def perform(record, action) return unless record.respond_to? :__elasticsearch__ @@ -18,6 +21,9 @@ def perform(record, action) else raise ArgumentError, "Unknown action: #{action}" end + rescue ActiveRecord::RecordNotFound + # Record was deleted before the job could run - this is expected for delete operations + Rails.logger.info "ElasticsearchIndexJob: Record no longer exists, skipping #{action} operation" end end end diff --git a/app/jobs/better_together/geography/geocoding_job.rb b/app/jobs/better_together/geography/geocoding_job.rb index d4674860f..7c6c0ec82 100644 --- a/app/jobs/better_together/geography/geocoding_job.rb +++ b/app/jobs/better_together/geography/geocoding_job.rb @@ -6,9 +6,15 @@ class GeocodingJob < ApplicationJob # rubocop:todo Style/Documentation queue_as :geocoding retry_on StandardError, wait: :polynomially_longer, attempts: 5 + # Don't retry on deserialization errors - the record no longer exists + discard_on ActiveJob::DeserializationError + def perform(geocodable) coords = geocodable.geocode geocodable.save if coords + rescue ActiveRecord::RecordNotFound + # Record was deleted before the job could run + Rails.logger.info 'GeocodingJob: Record no longer exists, skipping geocoding operation' end end end From 1f5be648b05ba948a89275ca8f4990950cdda552 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 15:58:41 -0230 Subject: [PATCH 03/25] Enhance geocoder configuration for development by adding test stubs for New York and San Francisco --- config/initializers/geocoder.rb | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb index 7e164ef33..d679211c0 100644 --- a/config/initializers/geocoder.rb +++ b/config/initializers/geocoder.rb @@ -2,6 +2,8 @@ # config/initializers/geocoder.rb Geocoder.configure( + # Use test lookup in development/test to avoid external API calls + lookup: Rails.env.production? ? :nominatim : :test, always_raise: :all, # geocoding service request timeout, in seconds (default 3): timeout: 5, @@ -12,3 +14,48 @@ # caching (see Caching section below for details): cache: Geocoder::CacheStore::Generic.new(Rails.cache, {}) ) + +# Configure test geocoding results for development/test environments +unless Rails.env.production? + Geocoder::Lookup::Test.add_stub( + 'New York, NY', [ + { + 'latitude' => 40.7143528, + 'longitude' => -74.0059731, + 'address' => 'New York, NY, USA', + 'state' => 'New York', + 'state_code' => 'NY', + 'country' => 'United States', + 'country_code' => 'US' + } + ] + ) + + Geocoder::Lookup::Test.add_stub( + 'San Francisco, CA', [ + { + 'latitude' => 37.7749295, + 'longitude' => -122.4194155, + 'address' => 'San Francisco, CA, USA', + 'state' => 'California', + 'state_code' => 'CA', + 'country' => 'United States', + 'country_code' => 'US' + } + ] + ) + + # Default stub for any address not specifically configured + Geocoder::Lookup::Test.set_default_stub( + [ + { + 'latitude' => 0.0, + 'longitude' => 0.0, + 'address' => 'Test Address', + 'state' => 'Test State', + 'country' => 'Test Country', + 'country_code' => 'TC' + } + ] + ) +end From 5bfd3eef08710a7b5e944c429c9f9d016aa1bd66 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 16:03:21 -0230 Subject: [PATCH 04/25] Add queue configuration for notifications in various notifiers --- .../event_invitation_notifier.rb | 29 +++++++++++-------- .../event_reminder_notifier.rb | 6 ++-- .../better_together/event_update_notifier.rb | 6 ++-- .../joatu/agreement_notifier.rb | 5 ++-- .../joatu/agreement_status_notifier.rb | 5 ++-- .../better_together/joatu/match_notifier.rb | 6 ++-- .../better_together/new_message_notifier.rb | 5 ++-- .../page_authorship_notifier.rb | 5 ++-- 8 files changed, 41 insertions(+), 26 deletions(-) diff --git a/app/notifiers/better_together/event_invitation_notifier.rb b/app/notifiers/better_together/event_invitation_notifier.rb index 6f97f6f39..d40fe9233 100644 --- a/app/notifiers/better_together/event_invitation_notifier.rb +++ b/app/notifiers/better_together/event_invitation_notifier.rb @@ -2,32 +2,37 @@ module BetterTogether class EventInvitationNotifier < ApplicationNotifier # rubocop:todo Style/Documentation - deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message - deliver_by :email, mailer: 'BetterTogether::EventInvitationsMailer', method: :invite, params: :email_params + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message, + queue: :notifications + deliver_by :email, mailer: 'BetterTogether::EventInvitationsMailer', method: :invite, params: :email_params, + queue: :mailers - param :invitation + required_param :invitation - notification_methods do - def invitation = params[:invitation] - def event = invitation.invitable + def event + params[:invitation].invitable end def title - I18n.t('better_together.notifications.event_invitation.title', - event_name: event&.name, default: 'You have been invited to an event') + I18n.with_locale(params[:invitation].locale) do + I18n.t('better_together.notifications.event_invitation.title', + event_name: event&.name, default: 'You have been invited to an event') + end end def body - I18n.t('better_together.notifications.event_invitation.body', - event_name: event&.name, default: 'Invitation to %s') + I18n.with_locale(params[:invitation].locale) do + I18n.t('better_together.notifications.event_invitation.body', + event_name: event&.name, default: 'Invitation to %s') + end end def build_message(_notification) - { title:, body:, url: invitation.url_for_review } + { title:, body:, url: params[:invitation].url_for_review } end def email_params(_notification) - { invitation: } + params[:invitation] end end end diff --git a/app/notifiers/better_together/event_reminder_notifier.rb b/app/notifiers/better_together/event_reminder_notifier.rb index e504e3d6b..1d84ba218 100644 --- a/app/notifiers/better_together/event_reminder_notifier.rb +++ b/app/notifiers/better_together/event_reminder_notifier.rb @@ -3,10 +3,12 @@ module BetterTogether # Notifies attendees when an event is approaching class EventReminderNotifier < ApplicationNotifier - deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message do |config| + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message, + queue: :notifications do |config| config.if = -> { should_notify? } end - deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_reminder, params: :email_params do |config| + deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_reminder, params: :email_params, + queue: :mailers do |config| config.wait = 15.minutes config.if = -> { send_email_notification? } end diff --git a/app/notifiers/better_together/event_update_notifier.rb b/app/notifiers/better_together/event_update_notifier.rb index ebf2d479a..b37dda083 100644 --- a/app/notifiers/better_together/event_update_notifier.rb +++ b/app/notifiers/better_together/event_update_notifier.rb @@ -3,10 +3,12 @@ module BetterTogether # Notifies attendees when an event is updated class EventUpdateNotifier < ApplicationNotifier - deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message do |config| + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message, + queue: :notifications do |config| config.if = -> { should_notify? } end - deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_update, params: :email_params do |config| + deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_update, params: :email_params, + queue: :mailers do |config| config.if = -> { recipient_has_email? && should_notify? } end diff --git a/app/notifiers/better_together/joatu/agreement_notifier.rb b/app/notifiers/better_together/joatu/agreement_notifier.rb index 74f8dc0bb..b3f0e5ed4 100644 --- a/app/notifiers/better_together/joatu/agreement_notifier.rb +++ b/app/notifiers/better_together/joatu/agreement_notifier.rb @@ -4,11 +4,12 @@ module BetterTogether module Joatu # Sends notifications when a new agreement is created class AgreementNotifier < ApplicationNotifier - deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message, + queue: :notifications deliver_by :email, mailer: 'BetterTogether::JoatuMailer', method: :agreement_created, - params: :email_params do |config| + params: :email_params, queue: :mailers do |config| config.if = -> { recipient.email.present? && recipient.notification_preferences['notify_by_email'] } end diff --git a/app/notifiers/better_together/joatu/agreement_status_notifier.rb b/app/notifiers/better_together/joatu/agreement_status_notifier.rb index 09e783cfc..1fb273e05 100644 --- a/app/notifiers/better_together/joatu/agreement_status_notifier.rb +++ b/app/notifiers/better_together/joatu/agreement_status_notifier.rb @@ -4,10 +4,11 @@ module BetterTogether module Joatu # Notifies offer and request creators when an agreement status changes class AgreementStatusNotifier < ApplicationNotifier - deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message, + queue: :notifications deliver_by :email, mailer: 'BetterTogether::JoatuMailer', method: :agreement_status_changed, - params: :email_params do |config| + params: :email_params, queue: :mailers do |config| config.if = -> { send_email_notification? } end diff --git a/app/notifiers/better_together/joatu/match_notifier.rb b/app/notifiers/better_together/joatu/match_notifier.rb index d8aabd603..f9788f5cf 100644 --- a/app/notifiers/better_together/joatu/match_notifier.rb +++ b/app/notifiers/better_together/joatu/match_notifier.rb @@ -4,10 +4,12 @@ module BetterTogether module Joatu # Notifies creators when a new offer or request matches class MatchNotifier < ApplicationNotifier - deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message do |config| + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message, + queue: :notifications do |config| config.if = -> { should_notify? } end - deliver_by :email, mailer: 'BetterTogether::JoatuMailer', method: :new_match, params: :email_params do |config| + deliver_by :email, mailer: 'BetterTogether::JoatuMailer', method: :new_match, params: :email_params, + queue: :mailers do |config| config.if = -> { recipient_has_email? && should_notify? } end diff --git a/app/notifiers/better_together/new_message_notifier.rb b/app/notifiers/better_together/new_message_notifier.rb index 047a963ae..9a614f538 100644 --- a/app/notifiers/better_together/new_message_notifier.rb +++ b/app/notifiers/better_together/new_message_notifier.rb @@ -3,10 +3,11 @@ module BetterTogether # Uses Noticed gem to create and dispatch notifications for new messages class NewMessageNotifier < ApplicationNotifier - deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message, + queue: :notifications # deliver_by :action_cable, channel: 'BetterTogether::MessagesChannel', message: :build_message deliver_by :email, mailer: 'BetterTogether::ConversationMailer', method: :new_message_notification, - params: :email_params do |config| + params: :email_params, queue: :mailers do |config| config.wait = 15.minutes config.if = -> { send_email_notification? } end diff --git a/app/notifiers/better_together/page_authorship_notifier.rb b/app/notifiers/better_together/page_authorship_notifier.rb index f8567c813..605e0f138 100644 --- a/app/notifiers/better_together/page_authorship_notifier.rb +++ b/app/notifiers/better_together/page_authorship_notifier.rb @@ -3,12 +3,13 @@ module BetterTogether # Notifies a person when added to or removed from a Page as an author class PageAuthorshipNotifier < ApplicationNotifier - deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message, + queue: :notifications deliver_by :email, mailer: 'BetterTogether::AuthorshipMailer', method: :authorship_changed_notification, - params: :email_params do |config| + params: :email_params, queue: :mailers do |config| config.wait = 15.minutes config.if = -> { send_email_notification? } end From 76c35b18dc355c789e54cc6c11769bffda0e48e7 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 16:03:47 -0230 Subject: [PATCH 05/25] Improve alt and title attributes in image helper methods to use entity name or string representation --- app/helpers/better_together/image_helper.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/helpers/better_together/image_helper.rb b/app/helpers/better_together/image_helper.rb index 0c8a73cb9..92d8e2067 100644 --- a/app/helpers/better_together/image_helper.rb +++ b/app/helpers/better_together/image_helper.rb @@ -12,8 +12,8 @@ def cover_image_tag(entity, options = {}) # rubocop:todo Metrics/MethodLength, M image_width = options[:width] || 2400 image_height = options[:height] || 600 image_format = options[:format] || 'jpg' - image_alt = options[:alt] || entity - image_title = options[:title] || entity + image_alt = options[:alt] || entity&.to_s || entity&.name || 'Cover image' + image_title = options[:title] || entity&.to_s || entity&.name || 'Cover image' image_tag_attributes = { class: image_classes, style: image_style, @@ -52,8 +52,8 @@ def card_image_tag(entity, options = {}) # rubocop:todo Metrics/MethodLength, Me image_width = options[:width] || 1200 image_height = options[:height] || 800 image_format = options[:format] || 'jpg' - image_alt = options[:alt] || entity - image_title = options[:title] || entity + image_alt = options[:alt] || entity&.to_s || entity&.name || 'Card image' + image_title = options[:title] || entity&.to_s || entity&.name || 'Card image' image_tag_attributes = { class: image_classes, style: image_style, From 4377eb99735296ca9c93c5fd43bc102555dd0a3f Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 16:06:02 -0230 Subject: [PATCH 06/25] Increase token column limit for invitations from 24 to 64 characters --- ...172911_increase_token_limit_for_invitations.rb | 15 +++++++++++++++ spec/dummy/db/schema.rb | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20250906172911_increase_token_limit_for_invitations.rb diff --git a/db/migrate/20250906172911_increase_token_limit_for_invitations.rb b/db/migrate/20250906172911_increase_token_limit_for_invitations.rb new file mode 100644 index 000000000..2c78e3377 --- /dev/null +++ b/db/migrate/20250906172911_increase_token_limit_for_invitations.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class IncreaseTokenLimitForInvitations < ActiveRecord::Migration[7.1] + def up + # Increase token column limit from 24 to 64 characters to support longer, more secure tokens + change_column :better_together_invitations, :token, :string, limit: 64, null: false + change_column :better_together_platform_invitations, :token, :string, limit: 64, null: false + end + + def down + # Revert back to 24 character limit (note: this could cause data loss if tokens are longer) + change_column :better_together_invitations, :token, :string, limit: 24, null: false + change_column :better_together_platform_invitations, :token, :string, limit: 24, null: false + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 245af03a2..b10cbc984 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_09_05_163813) do +ActiveRecord::Schema[7.2].define(version: 2025_09_06_172911) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -730,7 +730,7 @@ t.datetime "last_sent" t.datetime "accepted_at" t.string "locale", limit: 5, default: "en", null: false - t.string "token", limit: 24, null: false + t.string "token", limit: 64, null: false t.string "invitable_type", null: false t.uuid "invitable_id", null: false t.string "inviter_type", null: false @@ -1145,7 +1145,7 @@ t.uuid "platform_role_id" t.string "status", limit: 20, null: false t.string "locale", limit: 5, default: "en", null: false - t.string "token", limit: 24, null: false + t.string "token", limit: 64, null: false t.datetime "valid_from", null: false t.datetime "valid_until" t.datetime "last_sent" From 9ade31fb93dacad5cb42037097ec78aabdffedcd Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 16:07:28 -0230 Subject: [PATCH 07/25] Update platform membership creation to use PersonPlatformMembership model --- spec/support/automatic_test_configuration.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/support/automatic_test_configuration.rb b/spec/support/automatic_test_configuration.rb index 50a2564ca..51aa31a97 100644 --- a/spec/support/automatic_test_configuration.rb +++ b/spec/support/automatic_test_configuration.rb @@ -240,9 +240,10 @@ def find_or_create_test_user(email, password, role_type = :user) platform = BetterTogether::Platform.first role = BetterTogether::Role.find_by(identifier: 'platform_manager') if platform && role - BetterTogether::PlatformMembership.create!( + # Use PersonPlatformMembership model which links people to platforms + BetterTogether::PersonPlatformMembership.create!( member: user.person, - platform: platform, + joinable: platform, role: role ) end From 36b2e9efef6acbc9b8242ada7129741a509cdede Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 16:10:42 -0230 Subject: [PATCH 08/25] Redirect unauthenticated users to login page instead of fallback location --- app/controllers/better_together/application_controller.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/controllers/better_together/application_controller.rb b/app/controllers/better_together/application_controller.rb index c3e63861e..1f4ddac3c 100644 --- a/app/controllers/better_together/application_controller.rb +++ b/app/controllers/better_together/application_controller.rb @@ -158,7 +158,13 @@ def user_not_authorized(exception) # rubocop:todo Metrics/AbcSize, Metrics/Metho ] else flash[:error] = message # Use flash for regular redirects - redirect_back(fallback_location: home_page_path) + + # For unauthenticated users, redirect to login + if current_user.nil? + redirect_to new_user_session_path(locale: I18n.locale) + else + redirect_back(fallback_location: home_page_path) + end end end # rubocop:enable Metrics/MethodLength From 4ebd64b21232c6614e775da34dbf5071c59e5285 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 16:56:17 -0230 Subject: [PATCH 09/25] Fix AJAX search parameter name and improve localization strings for blocked users --- .../controllers/better_together/slim_select_controller.js | 2 +- .../better_together/person_blocks/_index_content.html.erb | 4 ++-- config/locales/en.yml | 1 + config/locales/es.yml | 1 + config/locales/fr.yml | 1 + 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/javascript/controllers/better_together/slim_select_controller.js b/app/javascript/controllers/better_together/slim_select_controller.js index a8bd7706d..0f71df33b 100644 --- a/app/javascript/controllers/better_together/slim_select_controller.js +++ b/app/javascript/controllers/better_together/slim_select_controller.js @@ -31,7 +31,7 @@ export default class extends Controller { return new Promise((resolve, reject) => { const url = new URL(options.ajax.url, window.location.origin); - url.searchParams.append('q', search); + url.searchParams.append('search', search); fetch(url.toString(), { method: 'GET', diff --git a/app/views/better_together/person_blocks/_index_content.html.erb b/app/views/better_together/person_blocks/_index_content.html.erb index 300adb268..a2a2a849d 100644 --- a/app/views/better_together/person_blocks/_index_content.html.erb +++ b/app/views/better_together/person_blocks/_index_content.html.erb @@ -4,8 +4,8 @@
- <%= profile_image_tag(person_block.blocked, - size: 50, + <%= profile_image_tag(person_block.blocked, + size: 50, class: 'rounded-circle') %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c4f22531..e85345c58 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1286,6 +1286,7 @@ en: block_person: Block Person blocked_at: Blocked blocked_count: + zero: No one blocked one: 1 person blocked other: "%{count} people blocked" blocked_on: Blocked on %{date} diff --git a/config/locales/es.yml b/config/locales/es.yml index 7be2c483c..7bd2dc515 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1292,6 +1292,7 @@ es: block_person: Bloquear Persona blocked_at: Bloqueado blocked_count: + zero: Aún no has bloqueado a nadie one: 1 persona bloqueada other: "%{count} personas bloqueadas" blocked_on: Bloqueado el %{date} diff --git a/config/locales/fr.yml b/config/locales/fr.yml index bdc76d421..1d84485f7 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1298,6 +1298,7 @@ fr: block_person: Bloquer une personne blocked_at: Bloqué blocked_count: + zero: Aucun personne bloquée one: 1 personne bloquée other: "%{count} personnes bloquées" blocked_on: Bloqué le %{date} From 6eba19145a5c9fc1ecddd3f69b0e90e7746a6a41 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 17:08:02 -0230 Subject: [PATCH 10/25] Add person search functionality for new event invitations - Implemented a new Stimulus controller for person search, allowing users to search and select people from a dropdown. - Updated the invitations controller to handle person invitations by ID and added a new action to fetch available people. - Created a new invitations.scss file for styling invitation-related components. - Enhanced the event invitations mailer to include the inviter's name in the invitation email. - Refactored event and event invitation models to support new invitation types and ensure uniqueness for event invitations. - Updated views to include tabs for inviting members and inviting by email, along with a new table for displaying invitations. - Improved localization for invitation-related texts in English, Spanish, and French. - Added routes for fetching available people for invitations. --- .../controllers/person_search_controller.js | 154 ++++++++++++++++++ .../better_together/application.scss | 1 + .../better_together/invitations.scss | 5 + .../events/invitations_controller.rb | 72 +++++++- .../event_invitations_mailer.rb | 12 +- app/models/better_together/event.rb | 5 +- .../better_together/event_invitation.rb | 66 +++++++- app/policies/better_together/event_policy.rb | 13 ++ .../event_invitations_mailer/invite.html.erb | 61 ++++++- .../events/_invitation_row.html.erb | 82 ++++++++++ .../events/_invitations_panel.html.erb | 115 +++++++------ .../events/_invitations_table.html.erb | 21 +++ .../events/_pending_invitation_rows.html.erb | 15 -- .../better_together/events/show.html.erb | 40 +++-- config/locales/en.yml | 2 +- config/locales/es.yml | 3 +- config/locales/fr.yml | 3 +- config/routes.rb | 3 + 18 files changed, 574 insertions(+), 99 deletions(-) create mode 100644 app/assets/javascripts/better_together/controllers/person_search_controller.js create mode 100644 app/assets/stylesheets/better_together/invitations.scss create mode 100644 app/views/better_together/events/_invitation_row.html.erb create mode 100644 app/views/better_together/events/_invitations_table.html.erb delete mode 100644 app/views/better_together/events/_pending_invitation_rows.html.erb diff --git a/app/assets/javascripts/better_together/controllers/person_search_controller.js b/app/assets/javascripts/better_together/controllers/person_search_controller.js new file mode 100644 index 000000000..b65da8810 --- /dev/null +++ b/app/assets/javascripts/better_together/controllers/person_search_controller.js @@ -0,0 +1,154 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["select", "input"] + static values = { + searchUrl: String, + searchDelay: { type: Number, default: 300 } + } + + connect() { + this.setupPersonSearch() + } + + setupPersonSearch() { + const select = this.selectTarget + + // Convert select to a searchable input + this.createSearchInput(select) + + // Hide the original select + select.style.display = 'none' + } + + createSearchInput(select) { + const searchContainer = select.parentElement + + // Create search input + const searchInput = document.createElement('input') + searchInput.type = 'text' + searchInput.className = 'form-control person-search-input' + searchInput.placeholder = select.options[0]?.text || 'Search for people...' + searchInput.setAttribute('data-person-search-target', 'input') + + // Create results dropdown + const resultsDropdown = document.createElement('div') + resultsDropdown.className = 'person-search-results' + resultsDropdown.style.cssText = ` + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ced4da; + border-top: none; + border-radius: 0 0 0.375rem 0.375rem; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + display: none; + ` + + // Insert elements + searchContainer.style.position = 'relative' + searchContainer.insertBefore(searchInput, select) + searchContainer.appendChild(resultsDropdown) + + // Setup event listeners + let searchTimeout + searchInput.addEventListener('input', (e) => { + clearTimeout(searchTimeout) + searchTimeout = setTimeout(() => { + this.performSearch(e.target.value, resultsDropdown, select) + }, this.searchDelayValue) + }) + + searchInput.addEventListener('focus', () => { + if (searchInput.value) { + this.performSearch(searchInput.value, resultsDropdown, select) + } + }) + + // Hide dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!searchContainer.contains(e.target)) { + resultsDropdown.style.display = 'none' + } + }) + } + + async performSearch(query, resultsDropdown, select) { + if (query.length < 2) { + resultsDropdown.style.display = 'none' + return + } + + try { + const response = await fetch(`${this.searchUrlValue}?search=${encodeURIComponent(query)}`, { + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }) + + if (!response.ok) throw new Error('Search failed') + + const people = await response.json() + this.displayResults(people, resultsDropdown, select) + } catch (error) { + console.error('Person search error:', error) + resultsDropdown.innerHTML = '
Search failed
' + resultsDropdown.style.display = 'block' + } + } + + displayResults(people, resultsDropdown, select) { + if (people.length === 0) { + resultsDropdown.innerHTML = '
No people found
' + resultsDropdown.style.display = 'block' + return + } + + const resultsHtml = people.map(person => ` +
+
+ +
+
+
${person.name}
+ @${person.slug} +
+
+ `).join('') + + resultsDropdown.innerHTML = resultsHtml + resultsDropdown.style.display = 'block' + + // Add click handlers to results + resultsDropdown.querySelectorAll('.person-result').forEach(result => { + result.addEventListener('click', () => { + this.selectPerson(result, select) + resultsDropdown.style.display = 'none' + }) + }) + } + + selectPerson(resultElement, select) { + const personId = resultElement.dataset.personId + const personName = resultElement.dataset.personName + + // Update the hidden select + select.innerHTML = `` + select.value = personId + + // Update the search input + const searchInput = this.inputTarget + searchInput.value = personName + + // Trigger change event for form handling + select.dispatchEvent(new Event('change', { bubbles: true })) + } +} diff --git a/app/assets/stylesheets/better_together/application.scss b/app/assets/stylesheets/better_together/application.scss index 2b5614eae..641bafa30 100644 --- a/app/assets/stylesheets/better_together/application.scss +++ b/app/assets/stylesheets/better_together/application.scss @@ -31,6 +31,7 @@ @use 'conversations'; @use 'forms'; @use 'image-galleries'; +@use 'invitations'; @use 'maps'; @use 'metrics'; @use 'navigation'; diff --git a/app/assets/stylesheets/better_together/invitations.scss b/app/assets/stylesheets/better_together/invitations.scss new file mode 100644 index 000000000..f0e341b8f --- /dev/null +++ b/app/assets/stylesheets/better_together/invitations.scss @@ -0,0 +1,5 @@ + +.profile-image.invitee { + width: 32px; + height: 32px; +} \ No newline at end of file diff --git a/app/controllers/better_together/events/invitations_controller.rb b/app/controllers/better_together/events/invitations_controller.rb index 77018e6f9..904facb1a 100644 --- a/app/controllers/better_together/events/invitations_controller.rb +++ b/app/controllers/better_together/events/invitations_controller.rb @@ -5,7 +5,8 @@ module Events class InvitationsController < ApplicationController # rubocop:todo Style/Documentation before_action :set_event before_action :set_invitation, only: %i[destroy resend] - after_action :verify_authorized + after_action :verify_authorized, except: %i[available_people] + after_action :verify_policy_scoped, only: %i[available_people] def create # rubocop:todo Metrics/MethodLength @invitation = BetterTogether::EventInvitation.new(invitation_params) @@ -14,6 +15,14 @@ def create # rubocop:todo Metrics/MethodLength @invitation.status = 'pending' @invitation.valid_from ||= Time.zone.now + # Handle person invitation by ID + if params.dig(:invitation, :invitee_id).present? + @invitation.invitee = BetterTogether::Person.find(params[:invitation][:invitee_id]) + # Use the person's email address and locale + @invitation.invitee_email = @invitation.invitee.email + @invitation.locale = @invitation.invitee.locale || I18n.default_locale + end + authorize @invitation if @invitation.save @@ -26,8 +35,21 @@ def create # rubocop:todo Metrics/MethodLength def destroy authorize @invitation + invitation_dom_id = helpers.dom_id(@invitation) @invitation.destroy - respond_success(@invitation, :ok) + + respond_to do |format| + format.html { redirect_to @event, notice: t('flash.generic.destroyed', resource: t('resources.invitation')) } + format.turbo_stream do + flash.now[:notice] = t('flash.generic.destroyed', resource: t('resources.invitation')) + render turbo_stream: [ + turbo_stream.remove(invitation_dom_id), + turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages', + locals: { flash: }) + ] + end + format.json { render json: { id: @invitation.id }, status: :ok } + end end def resend @@ -36,6 +58,40 @@ def resend respond_success(@invitation, :ok) end + def available_people + # Get IDs of people who are already invited to this event + # We use EventInvitation directly to avoid the default scope with includes(:invitee) + invited_person_ids = BetterTogether::EventInvitation + .where(invitable: @event, invitee_type: 'BetterTogether::Person') + .pluck(:invitee_id) + + # Search for people excluding those already invited and those without email + # People have email through either user.email or contact_detail.email_addresses (association) + people = policy_scope(BetterTogether::Person) + .left_joins(:user, contact_detail: :email_addresses) + .where.not(id: invited_person_ids) + .where( + 'better_together_users.email IS NOT NULL OR ' \ + 'better_together_email_addresses.email IS NOT NULL' + ) + + # Apply search filter if provided + if params[:search].present? + search_term = "%#{params[:search]}%" + people = people.joins(:string_translations) + .where('mobility_string_translations.value ILIKE ? AND mobility_string_translations.key = ?', + search_term, 'name') + .distinct + end + + # Format for SlimSelect + formatted_people = people.limit(20).map do |person| + { value: person.id, text: person.name } + end + + render json: formatted_people + end + private def set_event @@ -49,7 +105,7 @@ def set_invitation end def invitation_params - params.require(:invitation).permit(:invitee_email, :valid_from, :valid_until, :locale, :role_id) + params.require(:invitation).permit(:invitee_id, :invitee_email, :valid_from, :valid_until, :locale, :role_id) end def notify_invitee(invitation) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength @@ -59,10 +115,12 @@ def notify_invitee(invitation) # rubocop:todo Metrics/AbcSize, Metrics/MethodLen return end - if invitation.invitee.present? + if invitation.for_existing_user? && invitation.invitee.present? + # Send notification to existing user through the notification system BetterTogether::EventInvitationNotifier.with(invitation:).deliver_later(invitation.invitee) invitation.update_column(:last_sent, Time.zone.now) - elsif invitation.respond_to?(:invitee_email) && invitation[:invitee_email].present? + elsif invitation.for_email? + # Send email directly to external email address (bypassing notification system) BetterTogether::EventInvitationsMailer.invite(invitation).deliver_later invitation.update_column(:last_sent, Time.zone.now) end @@ -72,11 +130,13 @@ def respond_success(invitation, status) # rubocop:todo Metrics/MethodLength respond_to do |format| format.html { redirect_to @event, notice: t('flash.generic.queued', resource: t('resources.invitation')) } format.turbo_stream do + flash.now[:notice] = t('flash.generic.queued', resource: t('resources.invitation')) render turbo_stream: [ turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages', locals: { flash: }), turbo_stream.replace('event_invitations_table_body', - partial: 'better_together/events/pending_invitation_rows', locals: { event: @event }) + partial: 'better_together/events/invitation_row', + collection: @event.invitations.order(:status, :created_at)) ], status: end format.json { render json: { id: invitation.id }, status: } diff --git a/app/mailers/better_together/event_invitations_mailer.rb b/app/mailers/better_together/event_invitations_mailer.rb index 9316c8b2a..1a7b9651d 100644 --- a/app/mailers/better_together/event_invitations_mailer.rb +++ b/app/mailers/better_together/event_invitations_mailer.rb @@ -7,12 +7,16 @@ def invite(invitation) @event = invitation.invitable @invitation_url = invitation.url_for_review - to_email = invitation[:invitee_email].to_s + to_email = invitation.invitee_email.to_s return if to_email.blank? - mail(to: to_email, - subject: I18n.t('better_together.event_invitations_mailer.invite.subject', - default: 'You are invited to an event')) + # Use the invitation's locale for proper internationalization + I18n.with_locale(invitation.locale) do + mail(to: to_email, + subject: I18n.t('better_together.event_invitations_mailer.invite.subject', + event_name: @event&.name, + default: 'You are invited to %s')) + end end end end diff --git a/app/models/better_together/event.rb b/app/models/better_together/event.rb index b4c080793..f11069eff 100644 --- a/app/models/better_together/event.rb +++ b/app/models/better_together/event.rb @@ -17,7 +17,10 @@ class Event < ApplicationRecord attachable_cover_image - has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy + has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', + foreign_key: :event_id, inverse_of: :event, dependent: :destroy + has_many :invitations, -> { includes(:invitee, :inviter) }, class_name: 'BetterTogether::EventInvitation', + foreign_key: :invitable_id, inverse_of: :invitable, dependent: :destroy has_many :attendees, through: :event_attendances, source: :person has_many :calendar_entries, class_name: 'BetterTogether::CalendarEntry', dependent: :destroy diff --git a/app/models/better_together/event_invitation.rb b/app/models/better_together/event_invitation.rb index 6923c871c..cf5d658f7 100644 --- a/app/models/better_together/event_invitation.rb +++ b/app/models/better_together/event_invitation.rb @@ -13,10 +13,15 @@ class EventInvitation < Invitation validates :locale, presence: true, inclusion: { in: I18n.available_locales.map(&:to_s) } validate :invitee_presence + validate :invitee_uniqueness_for_event # Ensure token is generated before validation before_validation :ensure_token_present + # Scopes for different invitation types + scope :for_existing_users, -> { where.not(invitee: nil) } + scope :for_email_addresses, -> { where(invitee: nil).where.not(invitee_email: [nil, '']) } + # Convenience helpers (invitable is the event) def event invitable @@ -26,6 +31,9 @@ def after_accept!(invitee_person: nil) person = invitee_person || resolve_invitee_person return unless person && event + # Ensure the person has community membership for the event's community + ensure_community_membership!(person) + attendance = BetterTogether::EventAttendance.find_or_initialize_by(event:, person:) attendance.status = 'going' attendance.save! @@ -43,7 +51,29 @@ def decline! end def url_for_review - BetterTogether::Engine.routes.url_helpers.invitation_url(token, locale: I18n.locale) + BetterTogether::Engine.routes.url_helpers.event_url( + invitable.slug, + locale: locale, + invitation_token: token + ) + end + + # Helper method to determine invitation type + def invitation_type + return :person if invitee.present? + return :email if invitee_email.present? + + :unknown + end + + # Check if this is an invitation for an existing user + def for_existing_user? + invitation_type == :person + end + + # Check if this is an email invitation + def for_email? + invitation_type == :email end private @@ -63,7 +93,39 @@ def resolve_invitee_person def invitee_presence return unless invitee.blank? && self[:invitee_email].to_s.strip.blank? - errors.add(:invitee_email, :blank) + errors.add(:base, 'Either invitee or invitee_email must be present') + end + + def invitee_uniqueness_for_event + return unless event + + # Check for duplicate person invitation + if invitee.present? + existing = event.invitations.where(invitee:, status: %w[pending accepted]) + .where.not(id:) + errors.add(:invitee, 'has already been invited to this event') if existing.exists? + end + + # Check for duplicate email invitation + return unless invitee_email.present? + + existing = event.invitations.where(invitee_email:, status: %w[pending accepted]) + .where.not(id:) + errors.add(:invitee_email, 'has already been invited to this event') if existing.exists? + end + + def ensure_community_membership!(person) + return unless event&.creator&.community + + community = event.creator.community + return if community.person_community_memberships.exists?(member: person) + + # Create community membership for the invitee + default_role = BetterTogether::Role.find_by(identifier: 'community_member') + community.person_community_memberships.create!( + member: person, + role: default_role + ) end end end diff --git a/app/policies/better_together/event_policy.rb b/app/policies/better_together/event_policy.rb index b24f7cd9a..7ac3f49c4 100644 --- a/app/policies/better_together/event_policy.rb +++ b/app/policies/better_together/event_policy.rb @@ -27,6 +27,19 @@ def destroy? creator_or_manager || event_host_member? end + # RSVP policy methods + def rsvp_interested? + show? && user.present? + end + + def rsvp_going? + show? && user.present? + end + + def rsvp_cancel? + show? && user.present? + end + def event_host_member? return false unless user.present? diff --git a/app/views/better_together/event_invitations_mailer/invite.html.erb b/app/views/better_together/event_invitations_mailer/invite.html.erb index 50bfc078e..619210022 100644 --- a/app/views/better_together/event_invitations_mailer/invite.html.erb +++ b/app/views/better_together/event_invitations_mailer/invite.html.erb @@ -1,6 +1,57 @@ -

<%= t('.greeting', default: 'Hello,') %>

-

<%= t('.invited_html', default: 'You have been invited to the event %{event}.', event: @event&.name) %>

-

- <%= t('.review_invitation', default: 'Review Invitation') %> -

+
+

<%= t('.greeting', default: 'Hello!') %>

+ +

+ <%= t('.invited_html', default: 'You have been invited to the event %{event_name}.', event_name: @event&.name).html_safe %> +

+ + <% if @invitation.inviter %> +

+ <%= t('.invited_by_html', default: 'You were invited by %{inviter_name}.', inviter_name: @invitation.inviter.name).html_safe %> +

+ <% end %> + +
+

<%= t('.event_details', default: 'Event Details') %>

+ + <% if @event.starts_at %> +

+ <%= t('.when', default: 'When') %>: + <%= l(@event.starts_at, format: :long) %> +

+ <% end %> + + <% if @event.location.present? %> +

+ <%= t('.where', default: 'Where') %>: + <%= @event.location %> +

+ <% end %> + + <% if @event.description.present? %> +
+ <%= simple_format(@event.description) %> +
+ <% end %> +
+ + + +
+

+ <%= t('.need_account_html', default: "You'll need to create an account to accept this invitation and join the event.").html_safe %> +

+
+ +
+ +

+ <%= t('better_together.mailer.footer.no_reply', default: 'This is an automated message. Please do not reply to this email.') %> +

+
diff --git a/app/views/better_together/events/_invitation_row.html.erb b/app/views/better_together/events/_invitation_row.html.erb new file mode 100644 index 000000000..c27d88609 --- /dev/null +++ b/app/views/better_together/events/_invitation_row.html.erb @@ -0,0 +1,82 @@ + + + <% if invitation_row.for_existing_user? %> +
+ <%= profile_image_tag(invitation_row.invitee, size: 32, class: 'rounded-circle me-2 invitee') %> +
+
<%= invitation_row.invitee.name %>
+ @<%= invitation_row.invitee.identifier %> +
+
+ <% else %> +
+
+ +
+
+
<%= invitation_row.invitee_email %>
+ <%= t('better_together.invitations.external_user', default: 'External user') %> +
+
+ <% end %> + + + <% if invitation_row.for_existing_user? %> + + + <%= t('better_together.invitations.type.person', default: 'Member') %> + + <% else %> + + + <%= t('better_together.invitations.type.email', default: 'Email') %> + + <% end %> + + + <% case invitation_row.status %> + <% when 'pending' %> + + + <%= t('globals.pending', default: 'Pending') %> + + <% when 'accepted' %> + + + <%= t('globals.accepted', default: 'Accepted') %> + + <% when 'declined' %> + + + <%= t('globals.declined', default: 'Declined') %> + + <% end %> + + + <% if invitation_row.last_sent %> + <%= time_ago_in_words(invitation_row.last_sent) %> <%= t('globals.ago', default: 'ago') %> + <% else %> + <%= t('globals.not_sent', default: 'Not sent') %> + <% end %> + + + <% if policy(invitation_row).resend? %> + <%= button_to t('globals.resend', default: 'Resend'), + better_together.resend_event_invitation_path(@event, invitation_row), + method: :put, + class: 'btn btn-outline-secondary btn-sm me-2', + data: { turbo: true } %> + <% end %> + <% if policy(invitation_row).destroy? %> + <%= button_to t('globals.remove', default: 'Remove'), + better_together.event_invitation_path(@event, invitation_row), + method: :delete, + class: 'btn btn-outline-danger btn-sm', + data: { + turbo: true, + confirm: t('better_together.invitations.confirm_remove', + default: 'Are you sure you want to remove this invitation?') + } %> + <% end %> + + diff --git a/app/views/better_together/events/_invitations_panel.html.erb b/app/views/better_together/events/_invitations_panel.html.erb index 15956f32b..bff843bc1 100644 --- a/app/views/better_together/events/_invitations_panel.html.erb +++ b/app/views/better_together/events/_invitations_panel.html.erb @@ -7,55 +7,78 @@
- <%= form_with url: better_together.event_invitations_path(event_id: @event.slug), method: :post, data: { turbo: true } do |f| %> -
-
- <%= f.label :invitee_email, t('better_together.invitations.invitee_email', default: 'Email'), class: 'form-label' %> - <%= f.text_field :invitee_email, name: 'invitation[invitee_email]', class: 'form-control', required: true %> -
-
- <%= f.label :locale, t('globals.locale', default: 'Locale'), class: 'form-label' %> - <%= f.select :locale, I18n.available_locales.map{ |l| [l, l] }, {}, name: 'invitation[locale]', class: 'form-select' %> -
-
- <%= f.submit t('better_together.invitations.send_invite', default: 'Send Invitation'), class: 'btn btn-primary w-100' %> -
+ + + + + +
+ +
+ <%= form_with url: better_together.event_invitations_path(event_id: @event.slug), method: :post, data: { turbo: true }, id: 'invite_person_form' do |f| %> +
+
+ <%= f.label :invitee_id, t('better_together.invitations.select_person', default: 'Select Person'), class: 'form-label' %> + <%= f.select :invitee_id, [], + { prompt: t('better_together.invitations.search_people', default: 'Search for people...') }, + { + class: 'form-select', + name: 'invitation[invitee_id]', + data: { + controller: 'better-together--slim-select', + 'better-together--slim-select-options-value': { + ajax: { + url: better_together.available_people_event_invitations_path(@event.slug, format: :json) + }, + settings: { + searchPlaceholder: t('better_together.invitations.search_people', default: 'Search for people...'), + searchHighlight: true, + closeOnSelect: true + } + }.to_json + } + } %> +
+
+ <%= f.submit t('better_together.invitations.send_invite', default: 'Send Invitation'), class: 'btn btn-primary w-100' %> +
+
+ <% end %>
- <% end %> - - <% pending = BetterTogether::EventInvitation.where(invitable: @event, status: 'pending') %> - <% if pending.any? %> -
-
<%= t('better_together.invitations.pending', default: 'Pending Invitations') %>
-
- - - - - - - - - - <% pending.each do |pi| %> - - - - - - <% end %> - -
<%= t('globals.email', default: 'Email') %><%= t('globals.sent', default: 'Sent') %>
<%= pi[:invitee_email] %><%= l(pi.last_sent, format: :short) if pi.last_sent %> - <% if policy(pi).resend? %> - <%= button_to t('globals.resend', default: 'Resend'), better_together.resend_event_invitation_path(@event, pi), method: :put, class: 'btn btn-outline-secondary btn-sm me-2' %> - <% end %> - <% if policy(pi).destroy? %> - <%= button_to t('globals.remove', default: 'Remove'), better_together.event_invitation_path(@event, pi), method: :delete, class: 'btn btn-outline-danger btn-sm' %> - <% end %> -
+ + +
+ <%= form_with url: better_together.event_invitations_path(event_id: @event.slug), method: :post, data: { turbo: true }, id: 'invite_email_form' do |f| %> +
+
+ <%= f.label :invitee_email, t('better_together.invitations.invitee_email', default: 'Email'), class: 'form-label' %> + <%= f.email_field :invitee_email, name: 'invitation[invitee_email]', class: 'form-control', required: true %> +
+
+ <%= f.label :locale, t('globals.locale', default: 'Locale'), class: 'form-label' %> + <%= language_select_field(form: f, field_name: :locale, selected_locale: I18n.locale, html_options: { name: 'invitation[locale]', class: 'form-select' }) %> +
+
+ <%= f.submit t('better_together.invitations.send_invite', default: 'Send Invitation'), class: 'btn btn-primary w-100' %> +
+
+ <% end %>
- <% end %> +
<% end %> +<%= render 'better_together/events/invitations_table' %> + diff --git a/app/views/better_together/events/_invitations_table.html.erb b/app/views/better_together/events/_invitations_table.html.erb new file mode 100644 index 000000000..378003a26 --- /dev/null +++ b/app/views/better_together/events/_invitations_table.html.erb @@ -0,0 +1,21 @@ +<% invitations = BetterTogether::EventInvitation.where(invitable: @event).order(:status, :created_at) %> +<% if invitations.any? %> +
+
<%= t('better_together.invitations.title', default: 'Event Invitations') %>
+
+ + + + + + + + + + + + <%= render partial: 'better_together/events/invitation_row', collection: invitations %> + +
<%= t('better_together.invitations.invitee', default: 'Invitee') %><%= t('better_together.invitations.invitee_type', default: 'Invitee Type') %><%= t('globals.status', default: 'Status') %><%= t('globals.sent', default: 'Sent') %>
+
+<% end %> diff --git a/app/views/better_together/events/_pending_invitation_rows.html.erb b/app/views/better_together/events/_pending_invitation_rows.html.erb deleted file mode 100644 index ec8c253b9..000000000 --- a/app/views/better_together/events/_pending_invitation_rows.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -<% pending = BetterTogether::EventInvitation.where(invitable: event, status: 'pending') %> -<% pending.each do |pi| %> - - <%= pi[:invitee_email] %> - <%= l(pi.last_sent, format: :short) if pi.last_sent %> - - <% if policy(pi).resend? %> - <%= button_to t('globals.resend', default: 'Resend'), better_together.resend_event_invitation_path(event, pi), method: :put, class: 'btn btn-outline-secondary btn-sm me-2' %> - <% end %> - <% if policy(pi).destroy? %> - <%= button_to t('globals.remove', default: 'Remove'), better_together.event_invitation_path(event, pi), method: :delete, class: 'btn btn-outline-danger btn-sm' %> - <% end %> - - -<% end %> diff --git a/app/views/better_together/events/show.html.erb b/app/views/better_together/events/show.html.erb index cbc0cdda7..03f0be8c5 100644 --- a/app/views/better_together/events/show.html.erb +++ b/app/views/better_together/events/show.html.erb @@ -93,10 +93,14 @@ <%= link_to t('globals.tabs.about'), '#about', class: 'nav-link active', id: 'about-tab', data: { bs_toggle: 'tab', bs_target: '#about', turbo: false, 'better_together--tabs-target': 'tab' }, role: 'tab', aria: { controls: 'about', selected: 'true' }, tabindex: '0' %> <%# Show the Attendees tab only to organizers (reuse invitation policy check) %> <% invitation = BetterTogether::EventInvitation.new(invitable: @event, inviter: current_person) %> - <% if policy(invitation).create? %> + <% if policy(@event).update? %> <% attendees_count = @event.event_attendances.count %> <%= link_to "#{t('globals.tabs.attendees', default: 'Attendees')} (#{attendees_count})", '#attendees', class: 'nav-link', id: 'attendees-tab', data: { bs_toggle: 'tab', bs_target: '#attendees', turbo: false, 'better_together--tabs-target': 'tab' }, role: 'tab', aria: { controls: 'attendees', selected: 'false' }, tabindex: '0' %> <% end %> + <% if policy(invitation).create? %> + <% invitations_count = @event.invitations.count %> + <%= link_to "#{t('globals.tabs.invitations', default: 'Invitations')} (#{invitations_count})", '#invitations', class: 'nav-link', id: 'invitations-tab', data: { bs_toggle: 'tab', bs_target: '#invitations', turbo: false, 'better_together--tabs-target': 'tab' }, role: 'tab', aria: { controls: 'invitations', selected: 'false' }, tabindex: '0' %> + <% end %> <% end %>
@@ -146,29 +150,31 @@ + + <% invitation ||= BetterTogether::EventInvitation.new(invitable: @event, inviter: current_person) %> + <% if policy(invitation).create? %> + + + <% end %>
- + <%= share_buttons(shareable: @event) if @event.privacy_public? %> diff --git a/config/locales/en.yml b/config/locales/en.yml index e85345c58..07668b7e3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -806,7 +806,7 @@ en: event_invitations_mailer: invite: greeting: Hello, - invited_html: You have been invited to the event %{event}. + invited_html: You have been invited to the event %{event_name}. review_invitation: Review Invitation subject: You are invited to an event event_mailer: diff --git a/config/locales/es.yml b/config/locales/es.yml index 7bd2dc515..62e2bf7a8 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -809,7 +809,7 @@ es: event_invitations_mailer: invite: greeting: Hola, - invited_html: Has sido invitado al evento %{event}. + invited_html: Has sido invitado al evento %{event_name}. review_invitation: Revisar invitación subject: Has sido invitado a un evento event_mailer: @@ -1008,6 +1008,7 @@ es: event_panel: title: Invitar personas invitee_email: Correo electrónico + invitee_type: Tipo de invitado pending: Invitaciones pendientes review: Revisar invitación send_invite: Enviar invitación diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 1d84485f7..7be913d22 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -815,7 +815,7 @@ fr: event_invitations_mailer: invite: greeting: Bonjour, - invited_html: Vous avez été invité à l'événement %{event}. + invited_html: Vous avez été invité à l'événement %{event_name}. review_invitation: Voir l'invitation subject: Vous êtes invité à un événement event_mailer: @@ -1013,6 +1013,7 @@ fr: event_panel: title: Inviter des personnes invitee_email: E-mail + invitee_type: Type d'invité pending: Invitations en attente review: Invitation send_invite: Envoyer l'invitation diff --git a/config/routes.rb b/config/routes.rb index 5b292a6e7..360286ee0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,6 +57,9 @@ resources :events, except: %i[index show] do resources :invitations, only: %i[create destroy], module: :events do + collection do + get :available_people + end member do put :resend end From 4078e65a0e67ca8cefe96f827c0e313e8f57d92b Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 7 Sep 2025 19:48:30 -0230 Subject: [PATCH 11/25] Implement invitation token authorization and enhance event invitation handling - Introduced InvitationTokenAuthorization concern for context-aware authorization. - Updated EventsController to handle invitation tokens and mark notifications as read. - Enhanced RegistrationsController to pre-fill user email from event invitations. - Modified EventInvitationsMailer to read invitations from parameters. - Added event invitation handling in policies for access control. - Created views for invitation review and notifications. - Updated Person model to associate with event invitations. --- .../events/invitations_controller.rb | 3 +- .../better_together/events_controller.rb | 35 +++++++++ .../better_together/invitations_controller.rb | 27 ++++++- .../users/registrations_controller.rb | 59 +++++++++++++-- .../invitation_token_authorization.rb | 75 +++++++++++++++++++ .../event_invitations_mailer.rb | 13 ++-- app/models/better_together/person.rb | 2 + .../event_invitation_notifier.rb | 20 +++-- .../better_together/application_policy.rb | 10 ++- .../event_invitation_policy.rb | 33 +++++++- app/policies/better_together/event_policy.rb | 55 +++++++++++++- app/policies/better_together/person_policy.rb | 16 ++-- .../notifications/_notification.html.erb | 10 +++ .../events/_invitation_review.html.erb | 37 +++++++++ .../better_together/events/show.html.erb | 6 +- .../people/_calendar_section.html.erb | 30 ++++---- 16 files changed, 377 insertions(+), 54 deletions(-) create mode 100644 app/controllers/concerns/better_together/invitation_token_authorization.rb create mode 100644 app/views/better_together/event_invitation_notifier/notifications/_notification.html.erb create mode 100644 app/views/better_together/events/_invitation_review.html.erb diff --git a/app/controllers/better_together/events/invitations_controller.rb b/app/controllers/better_together/events/invitations_controller.rb index 904facb1a..9e1138334 100644 --- a/app/controllers/better_together/events/invitations_controller.rb +++ b/app/controllers/better_together/events/invitations_controller.rb @@ -117,7 +117,8 @@ def notify_invitee(invitation) # rubocop:todo Metrics/AbcSize, Metrics/MethodLen if invitation.for_existing_user? && invitation.invitee.present? # Send notification to existing user through the notification system - BetterTogether::EventInvitationNotifier.with(invitation:).deliver_later(invitation.invitee) + BetterTogether::EventInvitationNotifier.with(record: invitation.invitable, + invitation:).deliver_later(invitation.invitee) invitation.update_column(:last_sent, Time.zone.now) elsif invitation.for_email? # Send email directly to external email address (bypassing notification system) diff --git a/app/controllers/better_together/events_controller.rb b/app/controllers/better_together/events_controller.rb index 440c19e6d..428ca3676 100644 --- a/app/controllers/better_together/events_controller.rb +++ b/app/controllers/better_together/events_controller.rb @@ -3,6 +3,9 @@ module BetterTogether # CRUD for BetterTogether::Event class EventsController < FriendlyResourceController # rubocop:todo Metrics/ClassLength + include InvitationTokenAuthorization + include NotificationReadable + before_action if: -> { Rails.env.development? } do # Make sure that all subclasses are loaded in dev to generate type selector Rails.application.eager_load! @@ -25,6 +28,11 @@ def show return end + # Check for valid invitation if accessing via invitation token + @current_invitation = find_invitation_by_token + + mark_match_notifications_read_for(resource_instance) + super end @@ -91,6 +99,33 @@ def resource_class ::BetterTogether::Event end + def resource_collection + # Set invitation token for policy scope + invitation_token = params[:invitation_token] || session[:event_invitation_token] + set_current_invitation_token(invitation_token) + + super + end + + # Override the parent's authorize_resource method to include invitation token context + def authorize_resource + # Set invitation token for authorization + invitation_token = params[:invitation_token] || session[:event_invitation_token] + set_current_invitation_token(invitation_token) + + authorize resource_instance + end + + # Helper method to find invitation by token + def find_invitation_by_token + return nil unless current_invitation_token.present? + + BetterTogether::EventInvitation.find_by( + token: current_invitation_token, + invitable: @event + ) + end + private # rubocop:todo Metrics/MethodLength diff --git a/app/controllers/better_together/invitations_controller.rb b/app/controllers/better_together/invitations_controller.rb index 74524e3b3..2c47381ae 100644 --- a/app/controllers/better_together/invitations_controller.rb +++ b/app/controllers/better_together/invitations_controller.rb @@ -32,7 +32,7 @@ def accept # rubocop:todo Metrics/AbcSize, Metrics/MethodLength end def decline # rubocop:todo Metrics/MethodLength - ensure_authenticated! + # ensure_authenticated! return if performed? if @invitation.respond_to?(:decline!) @@ -61,9 +61,30 @@ def find_invitation_by_token end def ensure_authenticated! - return if helpers.current_person.present? + return if current_user + + # Store invitation token in session for after authentication + if @invitation.is_a?(BetterTogether::EventInvitation) + session[:event_invitation_token] = @invitation.token + session[:event_invitation_expires_at] = 24.hours.from_now + end + + if BetterTogether::User.find_by(email: @invitation.invitee_email).present? + redirect_path = new_user_session_path(locale: I18n.locale) + redirect_notice = t('better_together.invitations.login_to_respond', + default: 'Please log in to respond to your invitation.') + else + redirect_path = new_user_registration_path(locale: I18n.locale) + redirect_notice = t('better_together.invitations.register_to_respond', + default: 'Please register to respond to your invitation.') + end + + redirect_to redirect_path, notice: redirect_notice + end - redirect_to new_user_session_path(locale: I18n.locale), alert: t('flash.generic.unauthorized') + def set_event_invitation_from_session + # This ensures @event_invitation is available in ApplicationController + @event_invitation = @invitation if @invitation.is_a?(BetterTogether::EventInvitation) end end end diff --git a/app/controllers/better_together/users/registrations_controller.rb b/app/controllers/better_together/users/registrations_controller.rb index e93f4d637..eb053c770 100644 --- a/app/controllers/better_together/users/registrations_controller.rb +++ b/app/controllers/better_together/users/registrations_controller.rb @@ -8,6 +8,7 @@ class RegistrationsController < ::Devise::RegistrationsController # rubocop:todo skip_before_action :check_platform_privacy before_action :set_required_agreements, only: %i[new create] + before_action :set_event_invitation_from_session, only: %i[new create] before_action :configure_account_update_params, only: [:update] # PUT /resource @@ -62,7 +63,14 @@ def update # rubocop:todo Metrics/AbcSize, Metrics/MethodLength def new super do |user| + # Pre-fill email from platform invitation user.email = @platform_invitation.invitee_email if @platform_invitation && user.email.empty? + + if @event_invitation + # Pre-fill email from event invitation + user.email = @event_invitation.invitee_email if @event_invitation && user.email.empty? + user.person = @event_invitation.invitee if @event_invitation.invitee.present? + end end end @@ -78,22 +86,25 @@ def create # rubocop:todo Metrics/MethodLength, Metrics/AbcSize super do |user| return unless user.persisted? - user.build_person(person_params) + if @event_invitation && @event_invitation.invitee.present? + user.person = @event_invitation.invitee + user.person.update(person_params) + else + user.build_person(person_params) + end if user.save! user.reload - community_role = if @platform_invitation - @platform_invitation.community_role - else - ::BetterTogether::Role.find_by(identifier: 'community_member') - end + # Handle community membership based on invitation type + community_role = determine_community_role helpers.host_community.person_community_memberships.create!( member: user.person, role: community_role ) + # Handle platform invitation if @platform_invitation if @platform_invitation.platform_role helpers.host_platform.person_platform_memberships.create!( @@ -105,6 +116,16 @@ def create # rubocop:todo Metrics/MethodLength, Metrics/AbcSize @platform_invitation.accept!(invitee: user.person) end + # Handle event invitation + if @event_invitation + @event_invitation.update!(invitee: user.person) + @event_invitation.accept!(invitee_person: user.person) + + # Clear session data + session.delete(:event_invitation_token) + session.delete(:event_invitation_expires_at) + end + create_agreement_participants(user.person) end end @@ -129,6 +150,9 @@ def set_required_agreements end def after_sign_up_path_for(resource) + # Redirect to event if signed up via event invitation + return better_together.event_path(@event_invitation.event) if @event_invitation&.event + if is_navigational_format? && helpers.host_platform&.privacy_private? return better_together.new_user_session_path end @@ -136,6 +160,29 @@ def after_sign_up_path_for(resource) super end + def set_event_invitation_from_session + return unless session[:event_invitation_token].present? + + # Check if session token is still valid + return if session[:event_invitation_expires_at].present? && + Time.current > session[:event_invitation_expires_at] + + @event_invitation = ::BetterTogether::EventInvitation.pending.not_expired + .find_by(token: session[:event_invitation_token]) + + nil if @event_invitation + end + + def determine_community_role + return @platform_invitation.community_role if @platform_invitation + + # For event invitations, use the event creator's community + return @event_invitation.role if @event_invitation && @event_invitation.role.present? + + # Default role + ::BetterTogether::Role.find_by(identifier: 'community_member') + end + def after_inactive_sign_up_path_for(resource) if is_navigational_format? && helpers.host_platform&.privacy_private? return better_together.new_user_session_path diff --git a/app/controllers/concerns/better_together/invitation_token_authorization.rb b/app/controllers/concerns/better_together/invitation_token_authorization.rb new file mode 100644 index 000000000..98dc7d0b1 --- /dev/null +++ b/app/controllers/concerns/better_together/invitation_token_authorization.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module BetterTogether + # Concern to override Pundit's authorize method to support invitation token authorization + # This allows policies to receive invitation tokens for context-aware authorization + module InvitationTokenAuthorization + extend ActiveSupport::Concern + + included do + attr_reader :current_invitation_token + end + + private + + # Override Pundit's authorize method to pass invitation token to policies + # @param record [Object] The record to authorize + # @param query [Symbol] The policy method to call (defaults to action query) - can be positional or keyword arg + # @param policy_class [Class] Optional policy class override + # @return [Object] The authorized record + def authorize(record, query = nil, policy_class: nil) + # Handle both old syntax: authorize(record, :query?) and new syntax: authorize(record, query: :query?) + query ||= "#{action_name}?" + policy_class ||= policy_class_for(record) + + # Create policy instance with invitation token + policy = policy_class.new(current_user, record, invitation_token: current_invitation_token) + + # Check authorization + raise Pundit::NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query) + + # Mark that authorization was performed (required for verify_authorized) + @_pundit_policy_authorized = true + + record + end + + # Override Pundit's policy_scope method to pass invitation token to policy scopes + # @param scope [Class] The scope class (typically a model class) + # @param policy_scope_class [Class] Optional policy scope class override + # @return [Object] The scoped collection + def policy_scope(scope, policy_scope_class: nil, invitation_token: nil) + policy_scope_class ||= policy_scope_class_for(scope) + + # Use provided invitation token or fall back to current + token = invitation_token || current_invitation_token + + # Create policy scope instance with invitation token + policy_scope_class.new(current_user, scope, invitation_token: token).resolve + end + + # Set the current invitation token for use in authorization + # @param token [String] The invitation token + def set_current_invitation_token(token) + @current_invitation_token = token + end + + # Helper method to determine policy class for a record + # @param record [Object] The record to find policy for + # @return [Class] The policy class + def policy_class_for(record) + if record.is_a?(Class) + "#{record.name}Policy".constantize + else + "#{record.class.name}Policy".constantize + end + end + + # Helper method to determine policy scope class for a scope + # @param scope [Class] The scope class + # @return [Class] The policy scope class + def policy_scope_class_for(scope) + "#{scope.name}Policy::Scope".constantize + end + end +end diff --git a/app/mailers/better_together/event_invitations_mailer.rb b/app/mailers/better_together/event_invitations_mailer.rb index 1a7b9651d..692a21a74 100644 --- a/app/mailers/better_together/event_invitations_mailer.rb +++ b/app/mailers/better_together/event_invitations_mailer.rb @@ -2,16 +2,19 @@ module BetterTogether class EventInvitationsMailer < ApplicationMailer # rubocop:todo Style/Documentation - def invite(invitation) + # Parameterized mailer: Noticed calls mailer.with(params).invite + # so read the invitation from params rather than using a positional arg. + def invite + invitation = params[:invitation] @invitation = invitation - @event = invitation.invitable - @invitation_url = invitation.url_for_review + @event = invitation&.invitable + @invitation_url = invitation&.url_for_review - to_email = invitation.invitee_email.to_s + to_email = invitation&.invitee_email.to_s return if to_email.blank? # Use the invitation's locale for proper internationalization - I18n.with_locale(invitation.locale) do + I18n.with_locale(invitation&.locale) do mail(to: to_email, subject: I18n.t('better_together.event_invitations_mailer.invite.subject', event_name: @event&.name, diff --git a/app/models/better_together/person.rb b/app/models/better_together/person.rb index ac6296de1..9ffa762bd 100644 --- a/app/models/better_together/person.rb +++ b/app/models/better_together/person.rb @@ -46,7 +46,9 @@ def self.primary_community_delegation_attrs has_many :agreements, through: :agreement_participants has_many :calendars, foreign_key: :creator_id, class_name: 'BetterTogether::Calendar', dependent: :destroy + has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy + has_many :event_invitations, class_name: 'BetterTogether::EventInvitation', as: :invitee, dependent: :destroy has_one :user_identification, lambda { diff --git a/app/notifiers/better_together/event_invitation_notifier.rb b/app/notifiers/better_together/event_invitation_notifier.rb index d40fe9233..5a03c86cb 100644 --- a/app/notifiers/better_together/event_invitation_notifier.rb +++ b/app/notifiers/better_together/event_invitation_notifier.rb @@ -9,30 +9,38 @@ class EventInvitationNotifier < ApplicationNotifier # rubocop:todo Style/Documen required_param :invitation - def event - params[:invitation].invitable + notification_methods do + delegate :title, :body, :invitation, :invitable, to: :event end + def invitation = params[:invitation] + def invitable = params[:invitable] || invitation&.invitable + def title I18n.with_locale(params[:invitation].locale) do I18n.t('better_together.notifications.event_invitation.title', - event_name: event&.name, default: 'You have been invited to an event') + event_name: invitable&.name, default: 'You have been invited to an event') end end def body I18n.with_locale(params[:invitation].locale) do I18n.t('better_together.notifications.event_invitation.body', - event_name: event&.name, default: 'Invitation to %s') + event_name: invitable&.name, default: 'Invitation to %s') end end def build_message(_notification) - { title:, body:, url: params[:invitation].url_for_review } + # Pass the invitable (event) as the notification url object so views can + # link to the event record (consistent with other notifiers that pass + # domain objects like agreement/request). + { title:, body:, url: invitation.url_for_review } end def email_params(_notification) - params[:invitation] + # Include the invitation and the invitable (event) so mailers and views + # have the full context without needing to resolve the invitation. + { invitation: params[:invitation], invitable: } end end end diff --git a/app/policies/better_together/application_policy.rb b/app/policies/better_together/application_policy.rb index c52120b72..5f3643a59 100644 --- a/app/policies/better_together/application_policy.rb +++ b/app/policies/better_together/application_policy.rb @@ -2,12 +2,13 @@ module BetterTogether class ApplicationPolicy # rubocop:todo Style/Documentation - attr_reader :user, :record, :agent + attr_reader :user, :record, :agent, :invitation_token - def initialize(user, record) + def initialize(user, record, invitation_token: nil) @user = user @agent = user&.person @record = record + @invitation_token = invitation_token end def index? @@ -39,12 +40,13 @@ def destroy? end class Scope # rubocop:todo Style/Documentation - attr_reader :user, :scope, :agent + attr_reader :user, :scope, :agent, :invitation_token - def initialize(user, scope) + def initialize(user, scope, invitation_token: nil) @user = user @agent = user&.person @scope = scope + @invitation_token = invitation_token end def resolve # rubocop:todo Metrics/AbcSize, Metrics/MethodLength diff --git a/app/policies/better_together/event_invitation_policy.rb b/app/policies/better_together/event_invitation_policy.rb index beae04a8f..e57b13c07 100644 --- a/app/policies/better_together/event_invitation_policy.rb +++ b/app/policies/better_together/event_invitation_policy.rb @@ -2,9 +2,13 @@ module BetterTogether class EventInvitationPolicy < ApplicationPolicy # rubocop:todo Style/Documentation - # Only platform managers may create event invitations for now def create? - user.present? && permitted_to?('manage_platform') + return false unless user.present? + + # Event creator and hosts can invite people + return true if allowed_on_event? + + permitted_to?('manage_platform') || event_host_member? end def destroy? @@ -17,7 +21,22 @@ def resend? class Scope < Scope # rubocop:todo Style/Documentation def resolve - scope + return scope.none unless user.present? + + # Platform managers see all invitations + return scope.all if permitted_to?('manage_platform') + + # Users see invitations for events they can manage + scope.joins(:invitable) + .where(better_together_invitations: { invitable_type: 'BetterTogether::Event' }) + .where( + 'better_together_events.creator_id = ? OR ' \ + 'EXISTS (SELECT 1 FROM better_together_event_hosts ' \ + 'WHERE better_together_event_hosts.event_id = better_together_events.id ' \ + 'AND better_together_event_hosts.host_type = ? ' \ + 'AND better_together_event_hosts.host_id = ?)', + user.person&.id, 'BetterTogether::Person', user.person&.id + ) end end @@ -31,8 +50,14 @@ def allowed_on_event? return true if permitted_to?('manage_platform') ep = BetterTogether::EventPolicy.new(user, event) - # Organizer-only: event hosts or event creator (exclude platform-manager-only path) + # Event hosts or event creator ep.event_host_member? || (user.present? && event.creator == agent) end + + def event_host_member? + return false unless user&.person && record.invitable.is_a?(BetterTogether::Event) + + record.invitable.event_hosts.exists?(host_type: 'BetterTogether::Person', host_id: user.person.id) + end end end diff --git a/app/policies/better_together/event_policy.rb b/app/policies/better_together/event_policy.rb index 7ac3f49c4..459ce6d7a 100644 --- a/app/policies/better_together/event_policy.rb +++ b/app/policies/better_together/event_policy.rb @@ -8,7 +8,11 @@ def index? end def show? - (record.privacy_public? && record.starts_at.present?) || creator_or_manager || event_host_member? + (record.privacy_public? && record.starts_at.present?) || + creator_or_manager || + event_host_member? || + has_invitation? || + has_valid_invitation_token? end def ics? @@ -88,12 +92,37 @@ def permitted_query # rubocop:todo Metrics/AbcSize, Metrics/MethodLength ) end + if agent.event_attendances.any? + event_ids = agent.event_attendances.pluck(:event_id) + query = query.or( + events_table[:id].in(event_ids) + ) + end + + if agent.event_invitations.any? + event_ids = agent.event_invitations.pluck(:invitable_id) + query = query.or( + events_table[:id].in(event_ids) + ) + end + query else - # Events must have a start time to be shown to people who aren't conencted to the event + # Events must have a start time to be shown to people who aren't connected to the event query = query.and(events_table[:starts_at].not_eq(nil)) end + # Add logic for invitation token access + if invitation_token.present? + invitation_table = ::BetterTogether::EventInvitation.arel_table + event_ids_with_valid_invitations = invitation_table + .where(invitation_table[:token].eq(invitation_token)) + .where(invitation_table[:status].eq('pending')) + .project(:invitable_id) + + query = query.or(events_table[:id].in(event_ids_with_valid_invitations)) + end + query end # rubocop:enable Metrics/MethodLength @@ -102,5 +131,27 @@ def permitted_query # rubocop:todo Metrics/AbcSize, Metrics/MethodLength def creator_or_manager user.present? && (record.creator == agent || permitted_to?('manage_platform')) end + + def has_invitation? + return false unless agent.present? + + # Check if the current person has an invitation to this event + BetterTogether::EventInvitation.exists?( + invitable: record, + invitee: agent + ) + end + + # Check if there's a valid invitation token for this event + def has_valid_invitation_token? + return false unless invitation_token.present? + + invitation = BetterTogether::EventInvitation.find_by( + token: invitation_token, + invitable: record + ) + + invitation.present? && invitation.status_pending? + end end end diff --git a/app/policies/better_together/person_policy.rb b/app/policies/better_together/person_policy.rb index e8150cd2b..1c5d04a07 100644 --- a/app/policies/better_together/person_policy.rb +++ b/app/policies/better_together/person_policy.rb @@ -68,6 +68,13 @@ def resolve # rubocop:todo Metrics/AbcSize, Metrics/MethodLength # Add people with direct interactions (blocked users, conversation participants, etc.) query = query.or(people_table[:id].in(interaction_person_ids)) if interaction_person_ids.any? + # Get IDs of people the current user has blocked or been blocked by + blocked_ids = agent.person_blocks.pluck(:blocked_id) + blocker_ids = BetterTogether::PersonBlock.where(blocked_id: agent.id).pluck(:blocker_id) + excluded_ids = blocked_ids + blocker_ids + + query = query.and(people_table[:id].not_in(excluded_ids)) if excluded_ids.any? + base_scope.where(query).distinct end @@ -93,18 +100,13 @@ def shared_community_member_ids # rubocop:todo Metrics/MethodLength end end - def interaction_person_ids # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + def interaction_person_ids # rubocop:todo Metrics/MethodLength return @interaction_person_ids if defined?(@interaction_person_ids) @interaction_person_ids = if agent.present? ids = [] - # People the current user has blocked or been blocked by - blocked_ids = agent.person_blocks.pluck(:blocked_id) - blocker_ids = BetterTogether::PersonBlock.where(blocked_id: agent.id).pluck(:blocker_id) # rubocop:disable Layout/LineLength - ids.concat(blocked_ids + blocker_ids) - - # People in conversations with the current user + # People in conversations with the current user, excluding blocked people if defined?(BetterTogether::Conversation) && defined?(BetterTogether::ConversationParticipant) conversation_ids = BetterTogether::ConversationParticipant .where(person_id: agent.id) diff --git a/app/views/better_together/event_invitation_notifier/notifications/_notification.html.erb b/app/views/better_together/event_invitation_notifier/notifications/_notification.html.erb new file mode 100644 index 000000000..c41ce72a2 --- /dev/null +++ b/app/views/better_together/event_invitation_notifier/notifications/_notification.html.erb @@ -0,0 +1,10 @@ + + +<%= render layout: 'better_together/notifications/notification', + locals: { notification: notification, + notification_title: notification.title, + notification_url: notification.invitable } do %> +

+ <%= notification.body %> +

+<% end %> \ No newline at end of file diff --git a/app/views/better_together/events/_invitation_review.html.erb b/app/views/better_together/events/_invitation_review.html.erb new file mode 100644 index 000000000..ec009678e --- /dev/null +++ b/app/views/better_together/events/_invitation_review.html.erb @@ -0,0 +1,37 @@ +<%# Invitation review partial - shows status and accept/decline actions %> +<%# Expected local: invitation (BetterTogether::EventInvitation) %> +<% invitation ||= local_assigns[:invitation] || @current_invitation %> + +
+
<%= t('better_together.invitations.review', default: 'Invitation') %>
+ + <%# Status badge - map statuses to Bootstrap badge classes %> + <% status = invitation&.status || 'pending' %> + <% actionable = (status == 'pending') %> + <% badge_class = case status + when 'accepted' then 'badge bg-success' + when 'declined' then 'badge bg-secondary' + when 'pending' then 'badge bg-warning text-dark' + else 'badge bg-light text-dark' + end %> + +
+ <%= t("better_together.invitations.status.#{status}", default: status.humanize) %> +
+ + <% if actionable %> +
+ <%# Accept button: actionable only when invitation is pending %> + <%= button_to better_together.accept_invitation_path(invitation.token), { method: :post, class: 'btn btn-success' } do %> + + <%= t('better_together.invitations.accept', default: 'Accept') %> + <% end %> + + <%# Decline button: actionable only when invitation is pending %> + <%= button_to better_together.decline_invitation_path(invitation.token), { method: :post, class: 'btn btn-outline-secondary' } do %> + + <%= t('better_together.invitations.decline', default: 'Decline') %> + <% end %> +
+ <% end %> +
diff --git a/app/views/better_together/events/show.html.erb b/app/views/better_together/events/show.html.erb index 03f0be8c5..55338b5d8 100644 --- a/app/views/better_together/events/show.html.erb +++ b/app/views/better_together/events/show.html.erb @@ -52,7 +52,7 @@ <% if current_person && @event.scheduled? %> <% attendance = @current_attendance %> -
+
<%= button_to rsvp_interested_event_path(@event), method: :post, class: "btn btn-outline-danger #{'active' if attendance&.status == 'interested'}", style: "#{attendance&.status == 'interested' ? '--bs-btn-active-bg: #e91e63; --bs-btn-active-border-color: #e91e63;' : '--bs-btn-color: #e91e63; --bs-btn-border-color: #e91e63; --bs-btn-hover-bg: #e91e63; --bs-btn-hover-border-color: #e91e63;'}" do %> <%= t('better_together.events.rsvp_interested', default: 'Interested') %> @@ -84,6 +84,10 @@
<% end %> + <% if @current_invitation %> + <%= render 'better_together/events/invitation_review', invitation: @current_invitation %> + <% end %> +
diff --git a/app/views/better_together/people/_calendar_section.html.erb b/app/views/better_together/people/_calendar_section.html.erb index 2969aec63..60883f43e 100644 --- a/app/views/better_together/people/_calendar_section.html.erb +++ b/app/views/better_together/people/_calendar_section.html.erb @@ -3,22 +3,22 @@ <%= t('better_together.people.calendar.title', default: 'Personal Calendar') %> - + <% all_events = person.all_calendar_events %> <% if all_events.any? %>
- <%= month_calendar(events: all_events) do |date, events| %> + <%= month_calendar(events: all_events, params: { anchor: 'calendar' }) do |date, events| %> <% events.each do |event| %> <% event_url = better_together.event_path(event) %> <% icon_data = event_relationship_icon(person, event) %> -
<%= link_to event, class: 'text-decoration-none event-link' do %> - <%= t('better_together.people.calendar.upcoming_events', default: 'Upcoming Events') %> - + <% upcoming_events = all_events.select { |e| e.starts_at && e.starts_at >= Time.current }.sort_by(&:starts_at).first(5) %> <% if upcoming_events.any? %>
@@ -51,8 +51,8 @@
<%= link_to event, class: 'text-decoration-none event-link' do %>
- <%= t('better_together.people.calendar.recent_past_events', default: 'Recent Past Events') %> - + <% past_events = all_events.select { |e| e.starts_at && e.starts_at < Time.current }.sort_by(&:starts_at).reverse.first(3) %> <% if past_events.any? %>
@@ -98,8 +98,8 @@
<%= link_to event, class: 'text-decoration-none text-muted event-link' do %>
-