diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b8aac7c82..e01d210b8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -116,6 +116,7 @@ This repository contains the **Better Together Community Engine** (an isolated R - **Use allow-lists for dynamic class resolution**: Follow the `joatu_source_class` pattern with concern-based allow-lists - **Validate user inputs**: Always sanitize and validate parameters, especially for file uploads and dynamic queries - **Strong parameters**: Use Rails strong parameters in all controllers + - **Model-level permitted attributes**: Prefer defining a class method `self.permitted_attributes` on models that returns the permitted attribute array (including nested attributes). Controllers and shared resource code should call `Model.permitted_attributes` rather than hard-coding permit lists. Compose nested permitted attributes by referencing other models' `permitted_attributes` (for example: `Conversation.permitted_attributes` may include `{ messages_attributes: Message.permitted_attributes }`). - **Authorization everywhere**: Implement Pundit policy checks on all actions - **SQL injection prevention**: Use parameterized queries, avoid string interpolation in SQL - **XSS prevention**: Use Rails auto-escaping, sanitize HTML inputs with allowlists @@ -205,6 +206,35 @@ This repository contains the **Better Together Community Engine** (an isolated R - **Rails-Controller-Testing**: Add `gem 'rails-controller-testing'` to Gemfile for `assigns` method in controller tests. - Toggle requires_invitation and provide invitation_code when needed for registration tests. +### Automatic test configuration & auth helper patterns + +This repository provides an automatic test-configuration layer (see `spec/support/automatic_test_configuration.rb`) that sets up the host `Platform` and, where appropriate, performs authentication for request, controller, and feature specs so most specs do NOT need to call `configure_host_platform` manually. + +- Automatic setup applies to specs with `type: :request`, `type: :controller`, and `type: :feature` by default. +- Use these example metadata tags to control authentication explicitly: + - `:as_platform_manager` or `:platform_manager` — login as the platform manager (elevated privileges) + - `:as_user`, `:authenticated`, or `:user` — login as a regular user + - `:no_auth` or `:unauthenticated` — ensure no authentication is performed for the example + - `:skip_host_setup` — skip host platform creation/configuration for this example + +How it works: +- The test helper inspects example metadata and description text (describe/context). If the description contains keywords such as "platform manager", "admin", "authenticated", or "signed in", it will automatically set appropriate tags and perform the corresponding authentication. +- The helper creates a host `Platform` if one does not exist and marks the default setup wizard as completed. +- For request specs it uses HTTP login helpers (`login(email, password)`); for controller specs it uses Devise test helpers (`sign_in`); for feature specs it uses Capybara UI login flows. + +Recommended usage: +- Prefer using metadata tags (`:as_platform_manager`, `:as_user`, `:skip_host_setup`) in the `describe` or `context` header when a test needs a specific authentication state. Example: + +```ruby +RSpec.describe 'Creating a conversation', type: :request, :as_user do + # host platform and user login are automatically configured +end +``` + +- Avoid calling `configure_host_platform` manually in most specs; reserve manual calls for special cases (use `:skip_host_setup` to opt out of automatic config). + +Note: The helper set lives under `spec/support/automatic_test_configuration.rb` and provides helpers like `configure_host_platform`, `find_or_create_test_user`, and `capybara_login_as_platform_manager` to use directly if needed by unusual tests. + ### Testing Architecture Standards - **Project Standard**: Use request specs (`type: :request`) for all controller testing to maintain consistency - **Request Specs Advantages**: Handle Rails engine routing automatically through full HTTP stack diff --git a/AGENTS.md b/AGENTS.md index f4ed7c1a6..f11e3327b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ Instructions for GitHub Copilot and other automated contributors working in this - Use allow-lists for dynamic class resolution (see `joatu_source_class` pattern) - Sanitize and validate all user inputs - Use strong parameters in controllers + - Define model-level permitted attributes: prefer a class method `self.permitted_attributes` on models that returns the permitted attribute list (including nested attribute structures). Controllers should call `Model.permitted_attributes` to build permit lists instead of hard-coding them. When composing nested attributes, reference other models' `permitted_attributes` (for example: `Conversation.permitted_attributes` may include `{ messages_attributes: Message.permitted_attributes }`). - Implement proper authorization checks (Pundit policies) - **For reflection-based features**: Create concerns with `included_in_models` class methods for safe dynamic class resolution - **Post-generation security check**: Run `bin/dc-run bundle exec brakeman --quiet --no-pager -c UnsafeReflection,SQL,CrossSiteScripting` after major code changes @@ -226,6 +227,35 @@ For every implementation plan, create acceptance criteria covering relevant stak - **Required for**: Controller specs, request specs, feature specs, and any integration tests that involve routing or authentication. - **Locale Parameters**: Engine controller tests require locale parameters (e.g., `params: { locale: I18n.default_locale }`) due to routing constraints. +### Automatic test configuration & auth helper patterns + +This repository provides an automatic test-configuration layer (see `spec/support/automatic_test_configuration.rb`) that sets up the host `Platform` and, where appropriate, performs authentication for request, controller, and feature specs so most specs do NOT need to call `configure_host_platform` manually. + +- Automatic setup applies to specs with `type: :request`, `type: :controller`, and `type: :feature` by default. +- Use these example metadata tags to control authentication explicitly: + - `:as_platform_manager` or `:platform_manager` — login as the platform manager (elevated privileges) + - `:as_user`, `:authenticated`, or `:user` — login as a regular user + - `:no_auth` or `:unauthenticated` — ensure no authentication is performed for the example + - `:skip_host_setup` — skip host platform creation/configuration for this example + +How it works: +- The test helper inspects example metadata and description text (describe/context). If the description contains keywords such as "platform manager", "admin", "authenticated", or "signed in", it will automatically set appropriate tags and perform the corresponding authentication. +- The helper creates a host `Platform` if one does not exist and marks the default setup wizard as completed. +- For request specs it uses HTTP login helpers (`login(email, password)`); for controller specs it uses Devise test helpers (`sign_in`); for feature specs it uses Capybara UI login flows. + +Recommended usage: +- Prefer using metadata tags (`:as_platform_manager`, `:as_user`, `:skip_host_setup`) in the `describe` or `context` header when a test needs a specific authentication state. Example: + +```ruby +RSpec.describe 'Creating a conversation', type: :request, :as_user do + # host platform and user login are automatically configured +end +``` + +- Avoid calling `configure_host_platform` manually in most specs; reserve manual calls for special cases (use `:skip_host_setup` to opt out of automatic config). + +Note: The helper set lives under `spec/support/automatic_test_configuration.rb` and provides helpers like `configure_host_platform`, `find_or_create_test_user`, and `capybara_login_as_platform_manager` to use directly if needed by unusual tests. + ## Test Coverage Standards - **Models**: Test validations, associations, scopes, instance methods, class methods, and callbacks. - **Controllers**: Test all actions, authorization policies, parameter handling, and response formats. diff --git a/app/assets/stylesheets/better_together/_resource_toolbar.scss b/app/assets/stylesheets/better_together/_resource_toolbar.scss new file mode 100644 index 000000000..d1ace7896 --- /dev/null +++ b/app/assets/stylesheets/better_together/_resource_toolbar.scss @@ -0,0 +1,14 @@ +// Styles specific to the shared resource toolbar + +.resource-toolbar { + display: flex; + align-items: center; +} + +.resource-toolbar-extra { + display: flex; + align-items: center; + margin-left: auto; // Fallback in case .ms-auto is unavailable + gap: 0.5rem; // Consistent spacing for appended actions +} + diff --git a/app/assets/stylesheets/better_together/application.scss b/app/assets/stylesheets/better_together/application.scss index 03bf6fecf..09631f3f3 100644 --- a/app/assets/stylesheets/better_together/application.scss +++ b/app/assets/stylesheets/better_together/application.scss @@ -37,6 +37,7 @@ @use 'share'; @use 'sidebar_nav'; @use 'trix-extensions/richtext'; +@use 'resource_toolbar'; // Styles that use the variables .text-opposite-theme { @@ -104,4 +105,3 @@ .spin-horizontal { animation: flip 1s linear infinite; } - diff --git a/app/assets/stylesheets/better_together/conversations.scss b/app/assets/stylesheets/better_together/conversations.scss index 55e2f4249..1d9cd14a5 100644 --- a/app/assets/stylesheets/better_together/conversations.scss +++ b/app/assets/stylesheets/better_together/conversations.scss @@ -93,3 +93,74 @@ overflow-y: auto; } +/* Responsive tweaks for conversation header & participants */ +/* Reduce header padding and font size on small screens */ +@media (max-width: 991.98px) { // Bootstrap lg breakpoint + /* Reduce overall header vertical padding on small screens */ + .card-header { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + + /* Make the title smaller when present */ + .card-header h4 { + font-size: 1rem; /* smaller header */ + margin: 0; + } + + /* Allow header children to wrap so we can place participants on their own row */ + .card-header { + flex-wrap: wrap; + } + + /* Reduce padding on the back link (left arrow) to save vertical space */ + .card-header > a.p-4 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + + /* Reduce horizontal margin around the options dropdown icon */ + .card-header .mx-4 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + + /* Place the participants container on its own full-width row below the header + when an h4 title exists, but keep participant items inline (horizontal). */ + .card-header h4 + .conversation-participants { + flex: 0 0 100% !important; + order: 3; + width: 100%; + display: flex !important; + flex-direction: row !important; /* keep items side-by-side */ + align-items: center !important; + justify-content: space-evenly !important; /* distribute participants evenly */ + gap: 0.5rem; + margin-top: 0.5rem; + overflow-x: auto; + } + + /* Small spacing adjustments for individual participant blocks when moved below */ + .card-header h4 + .conversation-participants .mention_person { + margin-right: 0.5rem !important; + padding-top: 0.15rem; + padding-bottom: 0.15rem; + } + + /* Shrink avatar size for participants in the header row to reduce height */ + .card-header h4 + .conversation-participants .mention_person .profile-image { + width: 32px !important; + height: 32px !important; + object-fit: cover; + } + + /* Ensure the options (ellipsis) container hugs the right edge of the header */ + .card-header > .align-self-center.mx-4 { + margin-left: auto !important; + order: 2; /* keep it on the top row before participants (participants are order:3) */ + align-self: center !important; + } +} + diff --git a/app/controllers/better_together/conversations_controller.rb b/app/controllers/better_together/conversations_controller.rb index bbcc474e4..6b7ce2ee8 100644 --- a/app/controllers/better_together/conversations_controller.rb +++ b/app/controllers/better_together/conversations_controller.rb @@ -16,11 +16,23 @@ class ConversationsController < ApplicationController # rubocop:todo Metrics/Cla helper_method :available_participants def index - authorize @conversations + # Conversations list is prepared by set_conversations (before_action) + # Provide a blank conversation for the new-conversation form in the sidebar + @conversation = Conversation.new + authorize @conversation end def new - @conversation = Conversation.new + if params[:conversation].present? + conv_params = params.require(:conversation).permit(:title, participant_ids: []) + @conversation = Conversation.new(conv_params) + else + @conversation = Conversation.new + end + + # Ensure nested message is available for the form (so users can create the first message inline) + @conversation.messages.build if @conversation.messages.empty? + authorize @conversation end @@ -32,6 +44,13 @@ def create # rubocop:todo Metrics/MethodLength, Metrics/AbcSize @conversation = Conversation.new(filtered_params.merge(creator: helpers.current_person)) + # If nested messages were provided, ensure the sender is set to the creator/current person + if @conversation.messages.any? + @conversation.messages.each do |m| + m.sender = helpers.current_person + end + end + authorize @conversation if submitted_any && filtered_empty @@ -52,7 +71,7 @@ def create # rubocop:todo Metrics/MethodLength, Metrics/AbcSize end end elsif @conversation.save - @conversation.participants << helpers.current_person + @conversation.add_participant_safe(helpers.current_person) respond_to do |format| format.turbo_stream @@ -203,20 +222,47 @@ def available_participants end def conversation_params - params.require(:conversation).permit(:title, participant_ids: []) + # Use model-defined permitted attributes so nested attributes composition stays DRY + params.require(:conversation).permit(*Conversation.permitted_attributes) end # Ensure participant_ids only include people the agent is allowed to message. # If none remain, keep it empty; creator is always added after create. - def conversation_params_filtered # rubocop:todo Metrics/AbcSize + def conversation_params_filtered # rubocop:todo Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity permitted = ConversationPolicy.new(helpers.current_user, Conversation.new).permitted_participants permitted_ids = permitted.pluck(:id) # Always allow the current person (creator/participant) to appear in the list permitted_ids << helpers.current_person.id if helpers.current_person + cp = conversation_params.dup + + # Filter participant_ids to only those the agent may message if cp[:participant_ids].present? cp[:participant_ids] = Array(cp[:participant_ids]).map(&:presence).compact & permitted_ids end + + # Protect nested messages on update: only allow creating messages via the create path. + # On update, permit edits only to existing messages that belong to the current person, + # and only allow their content (prevent sender_id spoofing or reassigning other people's messages). + if action_name == 'update' && cp[:messages_attributes].present? + safe_messages = Array(cp[:messages_attributes]).map do |m| + # handle ActionController::Parameters or Hash + attrs = m.respond_to?(:to_h) ? m.to_h : m + msg_id = attrs['id'] || attrs[:id] + next nil unless msg_id + + msg = BetterTogether::Message.find_by(id: msg_id) + next nil unless msg && helpers.current_person && msg.sender_id == helpers.current_person.id + + # Only allow content edits through this path + { 'id' => msg_id, 'content' => attrs['content'] || attrs[:content] } + end.compact + + # Replace messages_attributes with the vetted set (or nil if none) + cp[:messages_attributes] = safe_messages.presence + end + + # On create, leave messages_attributes as-is so nested creation works; controller will set sender. cp end diff --git a/app/controllers/better_together/messages_controller.rb b/app/controllers/better_together/messages_controller.rb index b72e3cbfe..7ac8c163a 100644 --- a/app/controllers/better_together/messages_controller.rb +++ b/app/controllers/better_together/messages_controller.rb @@ -28,7 +28,7 @@ def set_conversation end def message_params - params.require(:message).permit(:content) + params.require(:message).permit(*BetterTogether::Message.permitted_attributes) end def notify_participants(message) diff --git a/app/controllers/better_together/navigation_areas_controller.rb b/app/controllers/better_together/navigation_areas_controller.rb index 14727f87b..b7996ffde 100644 --- a/app/controllers/better_together/navigation_areas_controller.rb +++ b/app/controllers/better_together/navigation_areas_controller.rb @@ -101,7 +101,7 @@ def set_navigation_area end def navigation_area_params - params.require(:navigation_area).permit(:name, :slug, :visible, :style, :protected) + params.require(:navigation_area).permit(*resource_class.permitted_attributes) end def resource_class diff --git a/app/controllers/better_together/navigation_items_controller.rb b/app/controllers/better_together/navigation_items_controller.rb index d56e4298b..3fb41593e 100644 --- a/app/controllers/better_together/navigation_items_controller.rb +++ b/app/controllers/better_together/navigation_items_controller.rb @@ -137,11 +137,7 @@ def navigation_item end def navigation_item_params - params.require(:navigation_item).permit( - :navigation_area_id, :url, :icon, :position, :visible, - :item_type, :linkable_id, :parent_id, :route_name, - *resource_class.localized_attribute_list - ) + params.require(:navigation_item).permit(*resource_class.permitted_attributes) end def resource_class diff --git a/app/helpers/better_together/form_helper.rb b/app/helpers/better_together/form_helper.rb index d0508e7f8..6f733316b 100644 --- a/app/helpers/better_together/form_helper.rb +++ b/app/helpers/better_together/form_helper.rb @@ -116,25 +116,40 @@ def privacy_field(form:, klass:) end # rubocop:todo Metrics/MethodLength - def required_label(form_or_object, field, **options) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + # Accepts an optional label_text override which, when provided, will be used + # instead of the model's human_attribute_name for the field. This is useful + # when the visible label needs to be different from the translated attribute + # name (for example: participant_ids -> "Add participants"). + # rubocop:todo Metrics/PerceivedComplexity + def required_label(form_or_object, field, label_text: nil, **options) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength # Determine if it's a form object or just an object if form_or_object.respond_to?(:object) object = form_or_object.object - label_text = object.class.human_attribute_name(field) + # Use provided label_text override if present, otherwise fall back to translation + label_text ||= object.class.human_attribute_name(field) class_name = options.delete(:class_name) # Use the provided class_name for validation check if present, otherwise use the object's class else object = form_or_object - label_text = object.class.human_attribute_name(field) + label_text ||= object.class.human_attribute_name(field) # Use the provided class_name for validation check if present, otherwise use the object's class end + klass = class_name ? class_name.constantize : object.class is_required = class_field_required(klass, field) - # Append asterisk for required fields - label_text += " *" if is_required + # Append asterisk for required fields and attach tooltip to the asterisk + if is_required + tooltip_text = I18n.t('helpers.required_info', default: 'This field is required') + # Make the asterisk keyboard-focusable and allow the tooltip to be + # triggered by click as well as hover/focus so it works on mobile. + asterisk = content_tag(:span, '*', class: 'required-indicator', tabindex: 0, role: 'button', + data: { bs_toggle: 'tooltip', bs_trigger: 'hover focus click' }, + title: tooltip_text) + label_text += " #{asterisk}" + end if form_or_object.respond_to?(:label) form_or_object.label(field, label_text.html_safe, options) @@ -142,6 +157,7 @@ def required_label(form_or_object, field, **options) # rubocop:todo Metrics/AbcS label_tag(field, label_text.html_safe, options) end end + # rubocop:enable Metrics/PerceivedComplexity # rubocop:enable Metrics/MethodLength # rubocop:todo Metrics/MethodLength diff --git a/app/javascript/controllers/better_together/form_validation_controller.js b/app/javascript/controllers/better_together/form_validation_controller.js index 9fb35f340..1da210b5a 100644 --- a/app/javascript/controllers/better_together/form_validation_controller.js +++ b/app/javascript/controllers/better_together/form_validation_controller.js @@ -75,9 +75,11 @@ export default class extends Controller { } handleFormSubmit(event) { - if (!this.element.checkValidity()) { + // Validate all fields (including trix editors) via checkAllFields + const allValid = this.checkAllFields(); + + if (!allValid) { event.preventDefault(); - this.checkAllFields(); return; } @@ -107,46 +109,78 @@ export default class extends Controller { } checkAllFields() { - const fields = this.element.querySelectorAll("input, select, textarea"); - fields.forEach(field => this.checkValidity({ target: field })); + // Include trix-editor elements so checkValidity routes appropriately + const fields = this.element.querySelectorAll("input, select, textarea, trix-editor"); + let allValid = true; + fields.forEach(field => { + const valid = this.checkValidity({ target: field }); + if (!valid) allValid = false; + }); + return allValid; } checkValidity(event) { const field = event.target; - if (field.closest("trix-editor")) { - return this.checkTrixValidity(event); + // If the target is a trix-editor itself, or it's the hidden input + // backing a trix-editor, route to the trix validity checker. + let trixEditorElem = null; + if (field && field.tagName && field.tagName.toLowerCase() === 'trix-editor') { + trixEditorElem = field; + } else if (field && field.tagName && field.tagName.toLowerCase() === 'input' && (field.type === 'hidden' || field.getAttribute('type') === 'hidden') && field.id) { + trixEditorElem = this.element.querySelector(`trix-editor[input="${field.id}"]`); + } + + if (trixEditorElem) { + return this.checkTrixValidity({ target: trixEditorElem }); } - if (field.checkValidity() && field.value.trim() === "") { + if (field.checkValidity && field.checkValidity() && field.value && field.value.trim() === "") { field.classList.remove("is-valid", "is-invalid"); this.hideErrorMessage(field); - } else if (field.checkValidity()) { + return true; + } else if (field.checkValidity && field.checkValidity()) { field.classList.remove("is-invalid"); field.classList.add("is-valid"); this.hideErrorMessage(field); + return true; } else { - field.classList.add("is-invalid"); + if (field.classList) field.classList.add("is-invalid"); this.showErrorMessage(field); + return false; } } checkTrixValidity(event) { const editor = event.target; const field = editor.closest("trix-editor"); - const editorContent = editor.editor.getDocument().toString().trim(); + const editorContent = (editor && editor.editor && typeof editor.editor.getDocument === 'function') ? editor.editor.getDocument().toString().trim() : (editor.textContent || '').trim(); + + // If the editor has no meaningful content, mark it invalid and show the + // associated .invalid-feedback so client-side validation blocks submit. + if (!editorContent || editorContent === "") { + if (field && field.classList) { + field.classList.remove("is-valid"); + field.classList.add("is-invalid"); + } + this.showErrorMessage(field); + return false; + } - if (editorContent === "") { - field.classList.remove("is-valid", "is-invalid"); - this.hideErrorMessage(field); - } else if (editorContent.length > 0) { - field.classList.remove("is-invalid"); - field.classList.add("is-valid"); + // Non-empty content -> valid + if (editorContent.length > 0) { + if (field && field.classList) { + field.classList.remove("is-invalid"); + field.classList.add("is-valid"); + } this.hideErrorMessage(field); - } else { - field.classList.add("is-invalid"); - this.showErrorMessage(field); + return true; } + + // Fallback: mark invalid + if (field && field.classList) field.classList.add("is-invalid"); + this.showErrorMessage(field); + return false; } resetValidation() { @@ -212,16 +246,25 @@ export default class extends Controller { } showErrorMessage(field) { - const errorMessage = field.nextElementSibling; - if (errorMessage?.classList.contains("invalid-feedback")) { - errorMessage.style.display = "block"; + // Trix editors may not place the invalid-feedback as the direct + // next sibling; search for a nearby .invalid-feedback first. + let errorMessage = field.nextElementSibling; + if (!errorMessage || !errorMessage.classList.contains('invalid-feedback')) { + // Try parent container + errorMessage = field.parentElement && field.parentElement.querySelector('.invalid-feedback'); + } + if (errorMessage && errorMessage.classList.contains('invalid-feedback')) { + errorMessage.style.display = 'block'; } } hideErrorMessage(field) { - const errorMessage = field.nextElementSibling; - if (errorMessage?.classList.contains("invalid-feedback")) { - errorMessage.style.display = "none"; + let errorMessage = field.nextElementSibling; + if (!errorMessage || !errorMessage.classList.contains('invalid-feedback')) { + errorMessage = field.parentElement && field.parentElement.querySelector('.invalid-feedback'); + } + if (errorMessage && errorMessage.classList.contains('invalid-feedback')) { + errorMessage.style.display = 'none'; } } } diff --git a/app/javascript/controllers/better_together/message_visibility_controller.js b/app/javascript/controllers/better_together/message_visibility_controller.js index b149f63df..50e41923a 100644 --- a/app/javascript/controllers/better_together/message_visibility_controller.js +++ b/app/javascript/controllers/better_together/message_visibility_controller.js @@ -20,7 +20,7 @@ export default class extends Controller { entries.forEach(entry => { if (entry.isIntersecting) { const messageId = this.element.dataset.messageId; - console.log(`Message ${messageId} is on screen.`); + // console.log(`Message ${messageId} is on screen.`); if (this.element.dataset.readStatus === 'unread') { this.markAsRead(messageId); } diff --git a/app/javascript/controllers/better_together/slim_select_controller.js b/app/javascript/controllers/better_together/slim_select_controller.js index d7c57ea7f..a8bd7706d 100644 --- a/app/javascript/controllers/better_together/slim_select_controller.js +++ b/app/javascript/controllers/better_together/slim_select_controller.js @@ -58,6 +58,19 @@ export default class extends Controller { select: this.element, ...options }); + + // Ensure SlimSelect reflects any pre-selected options rendered by the server + // (useful when Turbo or server-side rendering supplies selected attributes) + try { + if (this.slimSelect && typeof this.slimSelect.set === 'function') { + // Pass current selected values from the underlying select + const selected = Array.from(this.element.selectedOptions).map(o => o.value); + this.slimSelect.set(selected); + } + } catch (e) { + // Fail silently - SlimSelect might not support set() in some versions + console.warn('Unable to refresh SlimSelect selected values:', e); + } } disconnect() { diff --git a/app/models/better_together/conversation.rb b/app/models/better_together/conversation.rb index c206087f8..14077646d 100644 --- a/app/models/better_together/conversation.rb +++ b/app/models/better_together/conversation.rb @@ -6,14 +6,80 @@ class Conversation < ApplicationRecord encrypts :title, deterministic: true belongs_to :creator, class_name: 'BetterTogether::Person' has_many :messages, dependent: :destroy + accepts_nested_attributes_for :messages, allow_destroy: false has_many :conversation_participants, dependent: :destroy has_many :participants, through: :conversation_participants, source: :person validate :at_least_one_participant + # Define permitted attributes for controller strong parameters + def self.permitted_attributes + [ + :title, + { participant_ids: [] }, + { messages_attributes: BetterTogether::Message.permitted_attributes } + ] + end + + # Require participants on creation so the form helper `required_label` + # can detect required fields and the form-validation Stimulus controller + # will flag them client-side. Do NOT require a nested message on every + # conversation create: nested messages are optional unless a nested + # message was provided by the form. If a nested message is present, we + # validate its content below. + validates :participant_ids, presence: true, on: :create + + # Provide a helper for the first message content so views/tests can + # access it easily and a custom validator can assert its presence + # (useful for nested attributes where Message validations may not yet + # surface on the parent object in some flows). + def first_message_content + first_msg = messages.first + return nil unless first_msg + + if first_msg.respond_to?(:content) && first_msg.content.respond_to?(:to_plain_text) + first_msg.content.to_plain_text + else + first_msg.content + end + end + + # Only validate the first nested message's content when a nested + # message actually exists (i.e., was provided via nested attributes or + # built in the controller). This avoids blocking conversation creation + # when no message is intended. + validate :first_message_content_present, on: :create + + def first_message_content_present + return if messages.blank? + + content = first_message_content + return unless content.nil? || content.to_s.strip.empty? + + errors.add(:messages, :blank) + end + def to_s title end + # Safely add a person as a participant, retrying once if the person record + # raises an ActiveRecord::StaleObjectError due to an outdated lock_version. + # This centralizes optimistic-lock retry logic for participant additions. + def add_participant_safe(person) + return if person.nil? + + attempts = 0 + begin + participants << person unless participants.exists?(person.id) + rescue ActiveRecord::StaleObjectError + attempts += 1 + raise unless attempts <= 1 + + person.reload + retry + end + end + private def at_least_one_participant diff --git a/app/models/better_together/message.rb b/app/models/better_together/message.rb index ec3157fe3..660d3bf8e 100644 --- a/app/models/better_together/message.rb +++ b/app/models/better_together/message.rb @@ -12,6 +12,11 @@ class Message < ApplicationRecord after_create_commit -> { broadcast_append_later_to conversation, target: 'conversation_messages' } + # Attributes permitted for strong parameters + def self.permitted_attributes + # include id and _destroy for nested attributes handling + %i[id content _destroy] + end # def content # super || self[:content] # end diff --git a/app/models/better_together/navigation_area.rb b/app/models/better_together/navigation_area.rb index 8437fd27c..7821a6a77 100644 --- a/app/models/better_together/navigation_area.rb +++ b/app/models/better_together/navigation_area.rb @@ -47,5 +47,16 @@ def top_level_nav_items_includes_children def to_s name end + + def self.permitted_attributes(id: false, destroy: false) + # Allow core fields for creating/updating navigation areas + attrs = %i[ + name + style + visible + ] + + super + attrs + end end end diff --git a/app/models/better_together/navigation_item.rb b/app/models/better_together/navigation_item.rb index 49a6136db..8cb795658 100644 --- a/app/models/better_together/navigation_item.rb +++ b/app/models/better_together/navigation_item.rb @@ -196,6 +196,24 @@ def to_s title end + def self.permitted_attributes(id: false, destroy: false) # rubocop:todo Metrics/MethodLength + # Base attributes used when creating/updating navigation items + attrs = %i[ + url + icon + position + visible + item_type + parent_id + route_name + linkable_type + linkable_id + navigation_area_id + ] + + super + attrs + end + def url fallback_url = "##{identifier}" diff --git a/app/policies/better_together/conversation_policy.rb b/app/policies/better_together/conversation_policy.rb index af45aa27f..bb93066f9 100644 --- a/app/policies/better_together/conversation_policy.rb +++ b/app/policies/better_together/conversation_policy.rb @@ -7,8 +7,19 @@ def index? user.present? end - def create? - user.present? + # Determines whether the current user can create a conversation. + # When `participants:` are provided, ensures they are within the permitted set. + def create?(participants: nil) + return false unless user.present? + + return true if participants.nil? + + permitted = permitted_participants + # Allow arrays of ids or Person records + Array(participants).all? do |p| + person_id = p.is_a?(::BetterTogether::Person) ? p.id : p + permitted.exists?(id: person_id) + end end def update? @@ -30,10 +41,12 @@ def permitted_participants else role = BetterTogether::Role.find_by(identifier: 'platform_manager') manager_ids = BetterTogether::PersonPlatformMembership.where(role_id: role.id).pluck(:member_id) - BetterTogether::Person.where(id: manager_ids) - .or(BetterTogether::Person.where('preferences @> ?', - { receive_messages_from_members: true }.to_json)) - .distinct + # Include platform managers and any person who has explicitly opted in to receive messages + opted_in = BetterTogether::Person.where( + 'preferences @> ?', { receive_messages_from_members: true }.to_json + ) + + BetterTogether::Person.where(id: manager_ids).or(opted_in).distinct end end diff --git a/app/views/better_together/conversations/_conversation_content.html.erb b/app/views/better_together/conversations/_conversation_content.html.erb index 0391c7775..1720bf57f 100644 --- a/app/views/better_together/conversations/_conversation_content.html.erb +++ b/app/views/better_together/conversations/_conversation_content.html.erb @@ -7,7 +7,7 @@ <% if conversation.title.present? %> -

+

<%= conversation.title %>

<% end %> diff --git a/app/views/better_together/conversations/_form.html.erb b/app/views/better_together/conversations/_form.html.erb index 9192cb121..986b773e8 100644 --- a/app/views/better_together/conversations/_form.html.erb +++ b/app/views/better_together/conversations/_form.html.erb @@ -1,20 +1,36 @@ <%= form_with(model: conversation, data: { turbo: false, controller: 'better_together--form-validation' }) do |form| %> - <%= turbo_frame_tag 'form_errors' do %> - <%= render 'layouts/better_together/errors', object: conversation %> - <% end %> + <%= turbo_frame_tag 'form_errors' do %> + <%= render 'layouts/better_together/errors', object: conversation %> + <% end %> + +
+ <%= form.label :title %> + <%= form.text_field :title, class: 'form-control' %> + <%= t('.title_hint', default: 'Optional. A short descriptive title can help participants find this conversation later') %> +
-
- <%= form.label :participant_ids, t('.add_participants') %> - <%= form.collection_select :participant_ids, available_participants, :id, :select_option_title, {}, { multiple: - true, class: 'form-select' , required: true, data: { controller: 'better_together--slim-select' } } %> -
+
+ <%= required_label form, :participant_ids, label_text: t('.add_participants'), class: 'form-label' %> + <%= form.collection_select :participant_ids, available_participants, :id, :select_option_title, {}, { multiple: true, class: 'form-select', required: true, data: { controller: 'better_together--slim-select' } } %> + <%= t('.participants_hint', default: 'Select one or more people to include in the conversation') %> + +
-
- <%= form.label :title %> - <%= form.text_field :title, class: 'form-control' %> -
+ <% unless form.object.persisted? %> +
+ <%= form.fields_for :messages do |m| %> + +
+ <%= required_label m, :content, label_text: t('.first_message', default: 'First message'), class: 'form-label' %> + <%= m.rich_text_area :content, class: 'form-control', rows: 2, placeholder: t('better_together.messages.form.placeholder'), required: true %> + <%= t('.first_message_hint', default: 'Write the opening message for the conversation') %> + +
+ <% end %> +
+ <% end %> -
- <%= form.submit class: 'btn btn-sm btn-primary' %> -
- <% end %> +
+ <%= form.submit class: 'btn btn-sm btn-primary' %> +
+<% end %> diff --git a/app/views/better_together/events/show.html.erb b/app/views/better_together/events/show.html.erb index c7a658e27..2122d927d 100644 --- a/app/views/better_together/events/show.html.erb +++ b/app/views/better_together/events/show.html.erb @@ -34,10 +34,9 @@ edit_aria_label: 'Edit Partner', destroy_path: policy(@resource).destroy? ? event_path(@resource) : nil, destroy_confirm: t('globals.confirm_delete'), - destroy_aria_label: 'Delete Record' %> -
+ destroy_aria_label: 'Delete Record' do %> <%= link_to t('better_together.events.add_to_calendar', default: 'Add to calendar (.ics)'), ics_event_path(@event), class: 'btn btn-outline-secondary btn-sm' %> -
+ <% end %> diff --git a/app/views/better_together/people/show.html.erb b/app/views/better_together/people/show.html.erb index ef7c607cd..dd214c6a9 100644 --- a/app/views/better_together/people/show.html.erb +++ b/app/views/better_together/people/show.html.erb @@ -31,11 +31,12 @@ edit_aria_label: 'Edit Profile', destroy_path: policy(@person).destroy? ? person_path(@person) : nil, destroy_confirm: t('people.confirm_delete'), - destroy_aria_label: 'Delete Profile' %> - <% conversation = BetterTogether::Conversation.new %> - <% if policy(conversation).create? && policy(conversation).permitted_participants.include?(@person) %> - <%= link_to new_conversation_path(conversation: { participant_ids: [@person.id] }), class: 'btn btn-outline-primary btn-sm me-2', 'aria-label' => t('globals.message') do %> - <%= t('globals.message') %> + destroy_aria_label: 'Delete Profile' do %> + <% conversation = BetterTogether::Conversation.new %> + <% if policy(conversation).create?(participants: [@person.id]) %> + <%= link_to new_conversation_path(conversation: { participant_ids: [@person.id] }), class: 'btn btn-outline-primary btn-sm me-2', 'aria-label' => t('globals.message') do %> + <%= t('globals.message') %> + <% end %> <% end %> <% end %> diff --git a/app/views/better_together/posts/show.html.erb b/app/views/better_together/posts/show.html.erb index 31362fdad..4307ca9ab 100644 --- a/app/views/better_together/posts/show.html.erb +++ b/app/views/better_together/posts/show.html.erb @@ -77,11 +77,8 @@
- <% if policy(resource_class).index? %> - <%= link_to t('better_together.posts.back_to_posts', default: 'Back to Posts'), posts_path, class: 'btn btn-sm btn-outline-secondary me-2', aria: { label: t('better_together.posts.back_to_posts', default: 'Back to Posts') } %> - <% end %> - <%= render 'shared/resource_toolbar', + back_to_list_path: (policy(resource_class).index? ? posts_path : nil), edit_path: policy(@post).edit? ? edit_post_path(@post) : nil, edit_aria_label: t('better_together.posts.edit', default: 'Edit Post'), destroy_path: policy(@post).destroy? ? post_path(@post) : nil, diff --git a/app/views/shared/_resource_toolbar.html.erb b/app/views/shared/_resource_toolbar.html.erb index cff3e5cd0..6d96f2ebf 100644 --- a/app/views/shared/_resource_toolbar.html.erb +++ b/app/views/shared/_resource_toolbar.html.erb @@ -12,33 +12,42 @@ - destroy_aria_label: aria-label for destroy button (defaults to t('globals.delete')) Usage: - <%# render 'shared/resource_toolbar', back_to_list_path: ..., edit_path: ..., destroy_path: ... do %> - - <%# link_to 'Custom', custom_path, class: 'btn btn-outline-info btn-sm me-2' %> - <%# end %> + <%# + render 'shared/resource_toolbar', back_to_list_path: ..., edit_path: ..., destroy_path: ... do + # Additional custom buttons rendered in a separate, right‑aligned section + link_to 'Custom', custom_path, class: 'btn btn-outline-info btn-sm me-2' + end + %> -
- <% if local_assigns[:back_to_list_path] %> - <%= link_to back_to_list_path, - class: 'btn btn-outline-secondary btn-sm me-2', - 'aria-label' => (local_assigns[:back_to_list_aria_label] || t('globals.back')) do %> - <%= t('globals.back', default: 'Back') %> +
+
+ <% if local_assigns[:back_to_list_path] %> + <%= link_to back_to_list_path, + class: 'btn btn-outline-secondary btn-sm me-2', + 'aria-label' => (local_assigns[:back_to_list_aria_label] || t('globals.back')) do %> + <%= t('globals.back', default: 'Back') %> + <% end %> <% end %> - <% end %> - <% if local_assigns[:edit_path] %> - <%= link_to edit_path, class: 'btn btn-outline-primary btn-sm me-2', 'aria-label' => (local_assigns[:edit_aria_label] || t('globals.edit')) do %> - <%= t('globals.edit') %> + <% if local_assigns[:edit_path] %> + <%= link_to edit_path, class: 'btn btn-outline-primary btn-sm me-2', 'aria-label' => (local_assigns[:edit_aria_label] || t('globals.edit')) do %> + <%= t('globals.edit') %> + <% end %> <% end %> - <% end %> - <% if local_assigns[:view_path] %> - <%= link_to view_path, class: 'btn btn-outline-secondary btn-sm me-2', 'aria-label' => (local_assigns[:view_aria_label] || t('globals.view')) do %> - <%= t('globals.view') %> + <% if local_assigns[:view_path] %> + <%= link_to view_path, class: 'btn btn-outline-secondary btn-sm me-2', 'aria-label' => (local_assigns[:view_aria_label] || t('globals.view')) do %> + <%= t('globals.view') %> + <% end %> <% end %> - <% end %> - <% if local_assigns[:destroy_path] %> - <%= link_to destroy_path, data: { turbo_method: :delete, turbo_confirm: (local_assigns[:destroy_confirm] || t('globals.confirm_delete')) }, class: 'btn btn-outline-danger btn-sm', 'aria-label' => (local_assigns[:destroy_aria_label] || t('globals.delete')) do %> - <%= t('globals.delete') %> + <% if local_assigns[:destroy_path] %> + <%= link_to destroy_path, data: { turbo_method: :delete, turbo_confirm: (local_assigns[:destroy_confirm] || t('globals.confirm_delete')) }, class: 'btn btn-outline-danger btn-sm', 'aria-label' => (local_assigns[:destroy_aria_label] || t('globals.delete')) do %> + <%= t('globals.delete') %> + <% end %> <% end %> +
+ + <% if block_given? %> +
+ <%= yield %> +
<% end %> - <%= yield if block_given? %>
diff --git a/bin/bundle-audit b/bin/bundle-audit new file mode 100755 index 000000000..a0e7ba0e8 --- /dev/null +++ b/bin/bundle-audit @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle-audit' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("bundler-audit", "bundle-audit") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 000000000..334a73784 --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundler-audit' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("bundler-audit", "bundler-audit") diff --git a/config/locales/en.yml b/config/locales/en.yml index 9bc144ddf..d71f0c226 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -719,6 +719,11 @@ en: form: add_participants: Add participants create_conversation: Create conversation + first_message: First message + first_message_hint: Write the opening message for the conversation + participants_hint: Select one or more people to include in the conversation + title_hint: Optional. A short descriptive title can help participants find + this conversation later index: conversations: Conversations new: New @@ -1847,6 +1852,7 @@ en: profile_image: Upload a profile image for the person. show_conversation_details: Show conversation title and sender name in notifications. slug: A URL-friendly identifier, typically auto-generated. + required_info: This field is required label: person: cover_image: Cover Image @@ -1856,6 +1862,7 @@ en: slug: Slug language_select: prompt: Prompt + required_info: This field is required select: prompt: Please select submit: diff --git a/config/locales/es.yml b/config/locales/es.yml index cbaa3dde5..782150ca8 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -722,6 +722,11 @@ es: form: add_participants: Agregar participantes create_conversation: Crear conversación + first_message: Primer mensaje + first_message_hint: Escribe el mensaje inicial de la conversación + participants_hint: Selecciona una o más personas para incluir en la conversación + title_hint: Opcional. Un título corto y descriptivo puede ayudar a los participantes + a encontrar esta conversación más tarde index: conversations: Conversaciones new: Nuevo @@ -1846,6 +1851,7 @@ es: show_conversation_details: Mostrar el título de la conversación y el nombre del remitente en las notificaciones. slug: Un identificador compatible con URL, generalmente generado automáticamente. + required_info: Este campo es obligatorio label: person: cover_image: Imagen de portada @@ -1855,6 +1861,7 @@ es: slug: Identificador language_select: prompt: Selecciona un idioma + required_info: Este campo es obligatorio select: prompt: Por favor seleccione submit: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 5d5780679..6cb0f34f5 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -727,6 +727,12 @@ fr: form: add_participants: Ajouter des participants create_conversation: Créer une conversation + first_message: Premier message + first_message_hint: Rédigez le message d'ouverture de la conversation + participants_hint: Sélectionnez une ou plusieurs personnes à inclure dans + la conversation + title_hint: Optionnel. Un court titre descriptif peut aider les participants + à retrouver cette conversation plus tard index: conversations: Conversations new: New @@ -1877,6 +1883,7 @@ fr: show_conversation_details: Afficher le titre de la conversation et le nom de l'expediteur dans les notifications. slug: Un identifiant compatible avec les URL, généralement généré automatiquement. + required_info: Ce champ est obligatoire label: person: cover_image: Image de couverture @@ -1886,6 +1893,7 @@ fr: slug: Identifiant URL language_select: prompt: Sélectionnez une langue + required_info: Ce champ est obligatoire select: prompt: Veuillez sélectionner submit: diff --git a/docs/developers/resource_controller_patterns.md b/docs/developers/resource_controller_patterns.md new file mode 100644 index 000000000..a6173d72c --- /dev/null +++ b/docs/developers/resource_controller_patterns.md @@ -0,0 +1,47 @@ +## ResourceController & FriendlyResourceController patterns + +This document explains how `ResourceController` and `FriendlyResourceController` (which inherits from it) centralize common controller behavior for CRUD resources in the Better Together engine, including how permitted attributes are resolved. + +Key points + +- Resource controllers should set `resource_class` to the model they manage. The base `ResourceController` provides `resource_params` which calls `resource_class.permitted_attributes` to produce a strong-parameters list. +- If your model defines `self.permitted_attributes` on the model (recommended), the base controller will automatically use that list when permitting params. This avoids duplication and centralizes permitted attribute definitions. +- For ad-hoc controllers that do not inherit from `ResourceController` (for example, controller actions that are not full CRUD or need custom param handling), call `Model.permitted_attributes` directly when building permit lists. + +Example: model-level permitted attributes composition + +```ruby +class BetterTogether::Message < ApplicationRecord + def self.permitted_attributes + %i[id sender_id content _destroy] + end +end + +class BetterTogether::Conversation < ApplicationRecord + def self.permitted_attributes + [ :title, { participant_ids: [] }, { messages_attributes: BetterTogether::Message.permitted_attributes } ] + end +end +``` + +Flow diagram + +```mermaid +flowchart TD + A[HTTP request params] --> B[Controller resource_params] + B --> C{Does controller inherit ResourceController?} + C -- Yes --> D[resource_class.permitted_attributes] + C -- No --> E[Call Model.permitted_attributes manually] + D & E --> F[params.require(...).permit(...)] + F --> G[Model.new/Model.update] + G --> H[Model validations & save] +``` + +When to add `self.permitted_attributes` to a model + +- New models or models used in forms that accept nested attributes should expose a `self.permitted_attributes` class method. +- Prefer composing nested attributes using other models' permitted attributes rather than repeating nested keys inline. + +Testing note + +- Controllers inheriting from `ResourceController` do not need to implement `*_params` methods unless special handling is required. For request/controller/feature specs you can rely on model-level permitted attributes to validate parameter handling. diff --git a/docs/developers/resource_permitted_attributes.md b/docs/developers/resource_permitted_attributes.md new file mode 100644 index 000000000..501f16945 --- /dev/null +++ b/docs/developers/resource_permitted_attributes.md @@ -0,0 +1,52 @@ +## ResourceController & model-level permitted_attributes + +This document explains how the `ResourceController` and `FriendlyResourceController` cooperate with model-level `permitted_attributes` to centralize strong parameter definitions. + +Key points +- `ResourceController#permitted_attributes` returns `resource_class.permitted_attributes`. +- Controllers inheriting from `ResourceController` or `FriendlyResourceController` use `resource_params` which delegates to `permitted_attributes` so you normally don't need to implement `*_params` methods in those controllers. +- For controllers that do not inherit from `ResourceController`, prefer calling `Model.permitted_attributes` directly, for example: + +```ruby +def conversation_params + params.require(:conversation).permit(*BetterTogether::Conversation.permitted_attributes) +end +``` + +Composing nested permitted attributes +- Instead of hard-coding nested permit lists, compose them by referencing other models' `permitted_attributes`. Example: + +```ruby +class BetterTogether::Message < ApplicationRecord + def self.permitted_attributes + %i[id sender_id content _destroy] + end +end + +class BetterTogether::Conversation < ApplicationRecord + def self.permitted_attributes + [:title, { participant_ids: [] }, { messages_attributes: BetterTogether::Message.permitted_attributes }] + end +end +``` + +Flow diagram + +```mermaid +flowchart TD + A[Request arrives at Controller] --> B{Controller type} + B -->|Inherits ResourceController| C[resource_params -> resource_class.permitted_attributes] + B -->|Custom Controller| D[Call Model.permitted_attributes directly] + C --> E[Strong parameters applied] + D --> E + E --> F[Save/Update model] +``` + +Why this pattern +- Single source of truth for strong parameters. +- Easier to compose nested attributes. +- Reduces duplication and accidental permission mismatches. + +Where to update +- If you add nested attributes, update the child model with `self.permitted_attributes` and reference it from the parent model. +- When adding controllers, prefer inheriting `ResourceController` if the resource fits that pattern; otherwise call `Model.permitted_attributes` explicitly. diff --git a/docs/diagrams/exports/png/content_publish_timeline.png b/docs/diagrams/exports/png/content_publish_timeline.png new file mode 100644 index 000000000..d9fde7dae Binary files /dev/null and b/docs/diagrams/exports/png/content_publish_timeline.png differ diff --git a/docs/diagrams/exports/png/content_schema_erd.png b/docs/diagrams/exports/png/content_schema_erd.png new file mode 100644 index 000000000..3c72c4daf Binary files /dev/null and b/docs/diagrams/exports/png/content_schema_erd.png differ diff --git a/docs/diagrams/exports/png/conversations_messaging_flow.png b/docs/diagrams/exports/png/conversations_messaging_flow.png index 56f39e430..02a14a2fd 100644 Binary files a/docs/diagrams/exports/png/conversations_messaging_flow.png and b/docs/diagrams/exports/png/conversations_messaging_flow.png differ diff --git a/docs/diagrams/exports/png/events_attendee_journey.png b/docs/diagrams/exports/png/events_attendee_journey.png new file mode 100644 index 000000000..f6ad4020d Binary files /dev/null and b/docs/diagrams/exports/png/events_attendee_journey.png differ diff --git a/docs/diagrams/exports/png/events_flow.png b/docs/diagrams/exports/png/events_flow.png index 48e23ae38..ebbc4aa08 100644 Binary files a/docs/diagrams/exports/png/events_flow.png and b/docs/diagrams/exports/png/events_flow.png differ diff --git a/docs/diagrams/exports/png/events_location_selector_flow.png b/docs/diagrams/exports/png/events_location_selector_flow.png new file mode 100644 index 000000000..83d2f15b3 Binary files /dev/null and b/docs/diagrams/exports/png/events_location_selector_flow.png differ diff --git a/docs/diagrams/exports/png/events_organizer_journey.png b/docs/diagrams/exports/png/events_organizer_journey.png new file mode 100644 index 000000000..a61d6a9c6 Binary files /dev/null and b/docs/diagrams/exports/png/events_organizer_journey.png differ diff --git a/docs/diagrams/exports/png/events_reminders_timeline.png b/docs/diagrams/exports/png/events_reminders_timeline.png new file mode 100644 index 000000000..257b24a66 Binary files /dev/null and b/docs/diagrams/exports/png/events_reminders_timeline.png differ diff --git a/docs/diagrams/exports/png/events_rsvp_flow.png b/docs/diagrams/exports/png/events_rsvp_flow.png new file mode 100644 index 000000000..062137eff Binary files /dev/null and b/docs/diagrams/exports/png/events_rsvp_flow.png differ diff --git a/docs/diagrams/exports/png/events_schema_erd.png b/docs/diagrams/exports/png/events_schema_erd.png new file mode 100644 index 000000000..b779e388f Binary files /dev/null and b/docs/diagrams/exports/png/events_schema_erd.png differ diff --git a/docs/diagrams/exports/png/events_technical_architecture.png b/docs/diagrams/exports/png/events_technical_architecture.png new file mode 100644 index 000000000..f9ed709c3 Binary files /dev/null and b/docs/diagrams/exports/png/events_technical_architecture.png differ diff --git a/docs/diagrams/exports/png/geography_system_flow.png b/docs/diagrams/exports/png/geography_system_flow.png index 567489d35..7436c0786 100644 Binary files a/docs/diagrams/exports/png/geography_system_flow.png and b/docs/diagrams/exports/png/geography_system_flow.png differ diff --git a/docs/diagrams/exports/png/models_and_concerns_diagram.png b/docs/diagrams/exports/png/models_and_concerns_diagram.png index 075e31b9c..290a8851b 100644 Binary files a/docs/diagrams/exports/png/models_and_concerns_diagram.png and b/docs/diagrams/exports/png/models_and_concerns_diagram.png differ diff --git a/docs/diagrams/exports/png/platform_manager_admin_flow.png b/docs/diagrams/exports/png/platform_manager_admin_flow.png new file mode 100644 index 000000000..581ce46d7 Binary files /dev/null and b/docs/diagrams/exports/png/platform_manager_admin_flow.png differ diff --git a/docs/diagrams/exports/png/platform_manager_invitations_flow.png b/docs/diagrams/exports/png/platform_manager_invitations_flow.png new file mode 100644 index 000000000..374c2b74a Binary files /dev/null and b/docs/diagrams/exports/png/platform_manager_invitations_flow.png differ diff --git a/docs/diagrams/exports/png/platform_manager_support_flow.png b/docs/diagrams/exports/png/platform_manager_support_flow.png new file mode 100644 index 000000000..f1a787529 Binary files /dev/null and b/docs/diagrams/exports/png/platform_manager_support_flow.png differ diff --git a/docs/diagrams/exports/png/security_monitoring_flow.png b/docs/diagrams/exports/png/security_monitoring_flow.png new file mode 100644 index 000000000..c248ff4d3 Binary files /dev/null and b/docs/diagrams/exports/png/security_monitoring_flow.png differ diff --git a/docs/diagrams/exports/png/user_authentication_flow.png b/docs/diagrams/exports/png/user_authentication_flow.png new file mode 100644 index 000000000..e17775ded Binary files /dev/null and b/docs/diagrams/exports/png/user_authentication_flow.png differ diff --git a/docs/diagrams/exports/png/user_management_flow.png b/docs/diagrams/exports/png/user_management_flow.png new file mode 100644 index 000000000..4864fc78d Binary files /dev/null and b/docs/diagrams/exports/png/user_management_flow.png differ diff --git a/docs/diagrams/exports/png/user_profile_management_flow.png b/docs/diagrams/exports/png/user_profile_management_flow.png new file mode 100644 index 000000000..67d693bff Binary files /dev/null and b/docs/diagrams/exports/png/user_profile_management_flow.png differ diff --git a/docs/diagrams/exports/png/user_registration_flow.png b/docs/diagrams/exports/png/user_registration_flow.png new file mode 100644 index 000000000..9b3cadd71 Binary files /dev/null and b/docs/diagrams/exports/png/user_registration_flow.png differ diff --git a/docs/diagrams/exports/svg/content_publish_timeline.svg b/docs/diagrams/exports/svg/content_publish_timeline.svg new file mode 100644 index 000000000..e7bf3b2fd --- /dev/null +++ b/docs/diagrams/exports/svg/content_publish_timeline.svg @@ -0,0 +1 @@ +DraftingCreate pagepublished_at is nil(draft)Add content blocksviacontent_page_blocksSchedulingSet published_at >nowscheduledUpdateprivacy/layout/slugcached keys includeupdated_atPublishedpublished_at <= nowpublishedVisible if policyallowsprivacy public orauthorizedPage Publish Lifecycle \ No newline at end of file diff --git a/docs/diagrams/exports/svg/content_schema_erd.svg b/docs/diagrams/exports/svg/content_schema_erd.svg new file mode 100644 index 000000000..fb54e8bcb --- /dev/null +++ b/docs/diagrams/exports/svg/content_schema_erd.svg @@ -0,0 +1 @@ +hasappears_inhas

BETTER_TOGETHER_PAGES

uuid

id

PK

string

identifier

string

privacy

string

slug

datetime

published_at

string

layout

uuid

sidebar_nav_id

FK

uuid

creator_id

FK

uuid

community_id

FK

boolean

protected

integer

lock_version

datetime

created_at

datetime

updated_at

BETTER_TOGETHER_CONTENT_PAGE_BLOCKS

uuid

id

PK

uuid

page_id

FK

uuid

block_id

FK

integer

position

integer

lock_version

datetime

created_at

datetime

updated_at

BETTER_TOGETHER_CONTENT_BLOCKS

uuid

id

PK

string

type

string

identifier

uuid

creator_id

FK

string

privacy

boolean

visible

jsonb

content_data

jsonb

css_settings

jsonb

media_settings

jsonb

layout_settings

jsonb

accessibility_attributes

jsonb

data_attributes

jsonb

html_attributes

jsonb

content_settings

jsonb

content_area_settings

integer

lock_version

datetime

created_at

datetime

updated_at

BETTER_TOGETHER_PLATFORMS

BETTER_TOGETHER_CONTENT_PLATFORM_BLOCKS

uuid

id

PK

uuid

platform_id

FK

uuid

block_id

FK

integer

lock_version

datetime

created_at

datetime

updated_at

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/conversations_messaging_flow.svg b/docs/diagrams/exports/svg/conversations_messaging_flow.svg index 45ede1add..0ae283f0e 100644 --- a/docs/diagrams/exports/svg/conversations_messaging_flow.svg +++ b/docs/diagrams/exports/svg/conversations_messaging_flow.svg @@ -1 +1 @@ -Participant ManagementRead Status ManagementConversation ViewingNotification SystemMessage Creation &DeliveryConversation CreationYesNoNoYesNoYesNoYesYesNoYesNoReal-time UI Updates

Turbo Stream response

Replace message form

Append new message

Remove empty state

Update conversation list

Scroll to new message

Apply sender styling

Email DeduplicationNoYesYesNo

NewMessageNotifier email check

Find unread notifications

Filter by conversation_id

Order by created_at desc

Any unread notifications?

Don't send email

Check if current message is latest

Message is last unread?

Send email

Authorization & PolicyUser is participantPlatform managerOtherwiseCreator or participantOtherwiseParticipant & not lastOtherwise

Controller action

Pundit policy check

Conversation policy

show?

Allow access

Deny access

update?

leave_conversation?

User initiates conversation

Select participants

Platform manager?

Can message anyone

Limited to platform managers

Create conversation

Add creator as participant

Conversation created

User sends message

Validate conversation access

Create encrypted message

Set sender to current person

Save message with rich text content

Touch conversation timestamp

Broadcast via Action Cable

ConversationsChannel stream

Real-time DOM update

Auto-scroll to new message

Mark sender's message styling

Message created

NewMessageNotifier triggered

Get conversation participants

Exclude sender from recipients

Deliver notifications

Action Cable delivery

NotificationsChannel stream

Browser/desktop notification

Flash message display

Update unread count badge

Email delivery check

User allows email?

Skip email

Check deduplication

First unread in conversation?

Schedule email 15min delay

ConversationMailer delivery

Localized email content

User opens conversation

Load conversation with messages

Check participant membership

Authorized access?

Access denied

Display conversation content

Mark notifications as read

Update unread count

Real-time message subscription

Enable message composer

View conversation with messages

mark_notifications_read_for_event_records

Find unread NewMessageNotifier events

Filter by message IDs in conversation

Update read_at timestamp

Broadcast unread count update

Update conversation participants

Validate new participants

Platform restrictions?

Filter to allowed participants

Accept all participants

Update participant list

Save conversation changes

Notify participants of changes

User leaves conversation

Remove ConversationParticipant

Last participant?

Cannot leave - validation error

Remove user from conversation

Redirect to conversations list

\ No newline at end of file +Participant ManagementRead Status ManagementConversation ViewingNotification SystemMessage Creation &DeliveryConversation CreationYesNoStaleObjectErrorSuccessSuccessNoYesNoYesNoYesYesNoYesNoReal-time UI Updates

Turbo Stream response

Replace message form

Append new message

Remove empty state

Update conversation list

Scroll to new message

Apply sender styling

Email DeduplicationNoYesYesNo

NewMessageNotifier email check

Find unread notifications

Filter by conversation_id

Order by created_at desc

Any unread notifications?

Don't send email

Check if current message is latest

Message is last unread?

Send email

Authorization & PolicyUser is participantPlatform managerOtherwiseCreator or participantOtherwiseParticipant & not lastOtherwise

Controller action

Pundit policy check

Conversation policy

show?

Allow access

Deny access

update?

leave_conversation?

User initiates conversation

Select participants

Platform manager?

Can message anyone

Limited to platform managers

Create conversation

Add creator as participant

Attempt to add participant

Reload person and retry once

Conversation created

User sends message

Validate conversation access

Create encrypted message

Set sender to current person

Save message with rich text content

Touch conversation timestamp

Broadcast via Action Cable

ConversationsChannel stream

Real-time DOM update

Auto-scroll to new message

Mark sender's message styling

Message created

NewMessageNotifier triggered

Get conversation participants

Exclude sender from recipients

Deliver notifications

Action Cable delivery

NotificationsChannel stream

Browser/desktop notification

Flash message display

Update unread count badge

Email delivery check

User allows email?

Skip email

Check deduplication

First unread in conversation?

Schedule email 15min delay

ConversationMailer delivery

Localized email content

User opens conversation

Load conversation with messages

Check participant membership

Authorized access?

Access denied

Display conversation content

Mark notifications as read

Update unread count

Real-time message subscription

Enable message composer

View conversation with messages

mark_notifications_read_for_event_records

Find unread NewMessageNotifier events

Filter by message IDs in conversation

Update read_at timestamp

Broadcast unread count update

Update conversation participants

Validate new participants

Platform restrictions?

Filter to allowed participants

Accept all participants

Update participant list

Save conversation changes

Notify participants of changes

User leaves conversation

Remove ConversationParticipant

Last participant?

Cannot leave - validation error

Remove user from conversation

Redirect to conversations list

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/events_attendee_journey.svg b/docs/diagrams/exports/svg/events_attendee_journey.svg new file mode 100644 index 000000000..498646a16 --- /dev/null +++ b/docs/diagrams/exports/svg/events_attendee_journey.svg @@ -0,0 +1 @@ +User
Discovery
Discovery
User
Browse Events
Browse Events
User
Search by Category
Search by Category
User
Read Event Details
Read Event Details
User
Check Location/Time
Check Location/Time
User
View Host Information
View Host Information
Decision
Decision
User
Consider Interest
Consider Interest
User
Check Schedule
Check Schedule
User
RSVP as Interested
RSVP as Interested
User
RSVP as Going
RSVP as Going
User
Add to Calendar
Add to Calendar
Preparation
Preparation
User
Receive 24h Reminder
Receive 24h Reminder
User
Plan Transportation
Plan Transportation
User
Invite Friends
Invite Friends
User
Receive 1h Reminder
Receive 1h Reminder
Attendance
Attendance
User
Receive Start Reminder
Receive Start Reminder
User
Travel to Event
Travel to Event
User
Check-in at Event
Check-in at Event
User
Participate in Event
Participate in Event
User
Network with Others
Network with Others
Follow-up
Follow-up
User
Leave Event
Leave Event
User
Share Experience
Share Experience
User
Connect with New Contacts
Connect with New Contacts
User
Provide Feedback
Provide Feedback
User
Look for Similar Events
Look for Similar Events
Community Building
Community Building
User
Join Related Groups
Join Related Groups
User
Become Event Organizer
Become Event Organizer
User
Help Promote Events
Help Promote Events
User
Mentor New Members
Mentor New Members
Event Attendee Journey
\ No newline at end of file diff --git a/docs/diagrams/exports/svg/events_flow.svg b/docs/diagrams/exports/svg/events_flow.svg index 88b4e8380..fb9fccda2 100644 --- a/docs/diagrams/exports/svg/events_flow.svg +++ b/docs/diagrams/exports/svg/events_flow.svg @@ -1 +1 @@ -NoYesYesNonil>= now&lt; nowYesNoYesNoPublic or authorizedNot authorized

404 Not Found

New Event

Set name, starts_at, ends_at?

ends_at > starts_at?

Validation error

Save

Assign Event Hosts

Set creator as default host

Additional hosts?

Validate host types\nHostsEvents concern

Hosts configured

Create EventHost records

Assign categories

Attach cover image

privacy

starts_at timing

Draft

Upcoming

Past

Optional: geocoding job

LocatableLocation\npolymorphic

EventReminderSchedulerJob

Calculate reminder times

Schedule 24h reminder

Schedule 1h reminder

Schedule start reminder

EventReminderJob\n24 hours before

EventReminderJob\n1 hour before

EventReminderJob\nat start time

Process attendees

Filter 'going' status

EventReminderNotifier

Action Cable\nReal-time notification

Email enabled?

EventMailer\n15min delay

Skip email

Event updated

EventUpdateNotifier

Show page with\nvisible_event_hosts

Can manage event?

Event creator

EventHost authorization\nevent_host_member?

Host can manage

Edit/Delete event

User authenticated?

RSVP Actions

Guest view only

Mark Interested

Mark Going

Cancel RSVP

EventAttendance record

Delete attendance

Reschedule reminders\nif going

ICS Export

Authorization check

Generate .ics file

Calendar application

Check user preferences:\nevent_reminders, notify_by_email

\ No newline at end of file +NoYesYesNoSimpleAddressBuildingYesNoYesNoYesNonil>= now&lt; nowYesNoYesNoPublic or authorizedNot authorized

404 Not Found

New Event

Set name, starts_at, ends_at?

ends_at > starts_at?

Validation error

Save

Assign Event Hosts

Set creator as default host

Additional hosts?

Validate host types\nHostsEvents concern

Hosts configured

Create EventHost records

Location Selector\nStimulus Controller

Location Type?

Simple location name

Address selection

Building selection

Existing address?

Select from dropdown

Inline address creation

Fill address fields

Validate address data

Address location set

Existing building?

Select from dropdown

Inline building creation

Fill building + address fields

Validate building data

Building location set

Process location_attributes=

Create LocatableLocation

Address geocoding needed?

Schedule geocoding job

Location finalized

Assign categories

Attach cover image

privacy

starts_at timing

Draft

Upcoming

Past

EventReminderSchedulerJob

Calculate reminder times

Schedule 24h reminder

Schedule 1h reminder

Schedule start reminder

EventReminderJob\n24 hours before

EventReminderJob\n1 hour before

EventReminderJob\nat start time

Process attendees

Filter 'going' status

EventReminderNotifier

Action Cable\nReal-time notification

Email enabled?

EventMailer\n15min delay

Skip email

Event updated

EventUpdateNotifier

Show page with\nvisible_event_hosts

Can manage event?

Event creator

EventHost authorization\nevent_host_member?

Host can manage

Edit/Delete event

User authenticated?

RSVP Actions

Guest view only

Mark Interested

Mark Going

Cancel RSVP

EventAttendance record

Delete attendance

Reschedule reminders\nif going

ICS Export

Authorization check

Generate .ics file

Calendar application

Check user preferences:\nevent_reminders, notify_by_email

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/events_location_selector_flow.svg b/docs/diagrams/exports/svg/events_location_selector_flow.svg new file mode 100644 index 000000000..53c8bf772 --- /dev/null +++ b/docs/diagrams/exports/svg/events_location_selector_flow.svg @@ -0,0 +1 @@ +SimpleAddressExistingNewBuildingExistingNewSimpleAddress dataBuilding dataYesNoInvalidInvalid

Event Form Load

Location Selector\nStimulus Controller

Detect existing location type

Show appropriate UI section

User selects location type

Show simple location field

Enter location name

Simple location ready

Show address section

Use existing or create new?

Select from address dropdown

Click 'New' button

Show inline address form

Fill address fields:\nline1, city, postal_code, etc.

Set physical/postal flags

Address data ready

Show building section

Use existing or create new?

Select from building dropdown

Click 'New' button

Show inline building form

Fill building fields:\nname, description

Fill nested address fields:\nline1, city, postal_code

Set physical/postal flags

Building + Address ready

Submit Event Form

EventsController#create/update

location_attributes= setter

Determine location type

Set name on LocatableLocation

Create Address record

Create Building + Address

Validate address data

Save Address

Create LocatableLocation\npointing to Address

Normalize address_attributes\nnesting for Building

Validate building + address

Save Building with Address

Create LocatableLocation\npointing to Building

Location saved

Address needs geocoding?

Schedule geocoding job

Event with location saved

Address validation error

Building validation error

Show form errors

Hide other location sections

Focus first address field

Focus first building field

connect() method

toggleLocationType()

showNewAddress()

showNewBuilding()

hideAllLocationTypes()

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/events_organizer_journey.svg b/docs/diagrams/exports/svg/events_organizer_journey.svg new file mode 100644 index 000000000..11dc3d27a --- /dev/null +++ b/docs/diagrams/exports/svg/events_organizer_journey.svg @@ -0,0 +1 @@ +Organizer
Planning
Planning
Organizer
Idea Generation
Idea Generation
Organizer
Community Research
Community Research
Organizer
Venue Coordination
Venue Coordination
Organizer
Date Selection
Date Selection
Event Creation
Event Creation
Organizer
Access Platform
Access Platform
Organizer
Create New Event
Create New Event
Organizer
Add Event Details
Add Event Details
Organizer
Set Location
Set Location
Organizer
Upload Images
Upload Images
Organizer
Add Co-hosts
Add Co-hosts
Organizer
Set Privacy
Set Privacy
Organizer
Publish Event
Publish Event
Promotion
Promotion
Organizer
Share Event Link
Share Event Link
Organizer
Social Media Posts
Social Media Posts
Organizer
Community Announcements
Community Announcements
Organizer
Follow-up Reminders
Follow-up Reminders
Management
Management
Organizer
Monitor RSVPs
Monitor RSVPs
Organizer
Answer Questions
Answer Questions
Organizer
Update Details
Update Details
Organizer
Coordinate with Co-hosts
Coordinate with Co-hosts
Event Day
Event Day
Organizer
Final Preparations
Final Preparations
Organizer
Check-in Attendees
Check-in Attendees
Organizer
Host Event
Host Event
Organizer
Handle Issues
Handle Issues
Follow-up
Follow-up
Organizer
Thank Attendees
Thank Attendees
Organizer
Gather Feedback
Gather Feedback
Organizer
Share Photos
Share Photos
Organizer
Plan Next Event
Plan Next Event
Event Organizer Journey
\ No newline at end of file diff --git a/docs/diagrams/exports/svg/events_reminders_timeline.svg b/docs/diagrams/exports/svg/events_reminders_timeline.svg new file mode 100644 index 000000000..cdbb86b93 --- /dev/null +++ b/docs/diagrams/exports/svg/events_reminders_timeline.svg @@ -0,0 +1 @@ +Create/Update EventSave Eventtriggers SchedulerEvaluate ConditionsHas attendees?yes/noStarts in >24h?schedule 24h jobStarts in >1h?schedule 1h jobStarts in future?schedule start-timejobDeliveryReminder job runsloads goingattendeesFor each attendeeNoticed =>ActionCable + Email(batched)Event Reminder Scheduling \ No newline at end of file diff --git a/docs/diagrams/exports/svg/events_rsvp_flow.svg b/docs/diagrams/exports/svg/events_rsvp_flow.svg new file mode 100644 index 000000000..bf23c531d --- /dev/null +++ b/docs/diagrams/exports/svg/events_rsvp_flow.svg @@ -0,0 +1 @@ +Policy: show?Not visibleInterestedGoingCancel

Authenticated User

View Event

Event visible

Denied

Choose RSVP

Create/Update EventAttendance status=interested

Create/Update EventAttendance status=going

Destroy EventAttendance

Redirect to event with notice

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/events_schema_erd.svg b/docs/diagrams/exports/svg/events_schema_erd.svg new file mode 100644 index 000000000..8fa95be22 --- /dev/null +++ b/docs/diagrams/exports/svg/events_schema_erd.svg @@ -0,0 +1 @@ +hashasappears_inhaspolymorphic

BETTER_TOGETHER_EVENTS

uuid

id

PK

string

type

uuid

creator_id

FK

string

identifier

string

privacy

datetime

starts_at

datetime

ends_at

decimal

duration_minutes

string

registration_url

integer

lock_version

datetime

created_at

datetime

updated_at

BETTER_TOGETHER_EVENT_ATTENDANCES

uuid

id

PK

uuid

event_id

FK

uuid

person_id

FK

string

status

integer

lock_version

datetime

created_at

datetime

updated_at

BETTER_TOGETHER_EVENT_HOSTS

uuid

id

PK

uuid

event_id

FK

uuid

host_id

string

host_type

integer

lock_version

datetime

created_at

datetime

updated_at

BETTER_TOGETHER_CALENDAR_ENTRIES

uuid

id

PK

uuid

calendar_id

FK

uuid

event_id

FK

datetime

starts_at

datetime

ends_at

decimal

duration_minutes

integer

lock_version

datetime

created_at

datetime

updated_at

BETTER_TOGETHER_CALENDARS

uuid

id

PK

uuid

community_id

FK

uuid

creator_id

FK

string

identifier

string

locale

string

privacy

boolean

protected

integer

lock_version

datetime

created_at

datetime

updated_at

HOSTS

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/events_technical_architecture.svg b/docs/diagrams/exports/svg/events_technical_architecture.svg new file mode 100644 index 000000000..b696e20f5 --- /dev/null +++ b/docs/diagrams/exports/svg/events_technical_architecture.svg @@ -0,0 +1 @@ +DatabaseExternal ServicesBackground JobsNotification SystemLocation SystemCore ModelsController LayerFrontend Layer

Event UI Components

Event Forms

Event Listings

Event Detail Pages

Stimulus Controllers

EventsController

EventAttendancesController

ICS Export Controller

Event Model

EventAttendance

EventHost

Calendar

CalendarEntry

LocatableLocation

Address

Building

Geocoding Service

EventReminderNotifier

EventUpdateNotifier

EventReminderJob

EventReminderSchedulerJob

EventMailer

Sidekiq Queue

GeocodingJob

NotificationJob

Geocoding API

Email Service

Action Cable

PostgreSQL

Redis Cache

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/geography_system_flow.svg b/docs/diagrams/exports/svg/geography_system_flow.svg index 30fe72c44..9a6a77764 100644 --- a/docs/diagrams/exports/svg/geography_system_flow.svg +++ b/docs/diagrams/exports/svg/geography_system_flow.svg @@ -1 +1 @@ -

Yes

No

Cache Miss

Cache Hit

User Input

Import Data

API Data

Simple Location
String Name

Structured Location
Address/Building

Address Form
Line1, Line2, City
State, Postal, Country

Address Model
Validation & Storage

Geocoding
Needed?

Geocoding Job
Background Processing

External Geocoding API
Google/OpenStreetMap

Geocoding Cache
Rails.cache

Geography::Space
Lat, Lng, Elevation

Geography::GeospatialSpace
Join Table

Coordinate Validation
-90≤lat≤90, -180≤lng≤180

Geography::Continent
ISO Regions

Geography::Country
ISO 3166-1 Codes

Geography::State
ISO 3166-2 Codes

Geography::Region
Custom Divisions

Geography::Settlement
Cities/Towns

Geography::LocatableLocation
Polymorphic Join

Location Selector
Frontend Interface

Geography::Map
Interactive Maps

Map Center
PostGIS ST_POINT

Map Viewport
PostGIS ST_POLYGON

Leaflet.js Output
Interactive Map

PostGIS Database
Spatial Extensions

Spatial Indexing
Geographic Queries

Proximity Search
ST_DWithin Queries

JSON API Output

Map Visualization

Location Display

Geocoding Error
Retry Logic

Validation Error
User Feedback

Fallback to
String Location

\ No newline at end of file +YesNoCache MissCache Hit

User Input

Import Data

API Data

Simple Location
String Name

Structured Location
Address/Building

Address Form
Line1, Line2, City
State, Postal, Country

Address Model
Validation & Storage

Geocoding
Needed?

Geocoding Job
Background Processing

External Geocoding API
Google/OpenStreetMap

Geocoding Cache
Rails.cache

Geography::Space
Lat, Lng, Elevation

Geography::GeospatialSpace
Join Table

Coordinate Validation
-90≤lat≤90, -180≤lng≤180

Geography::Continent
ISO Regions

Geography::Country
ISO 3166-1 Codes

Geography::State
ISO 3166-2 Codes

Geography::Region
Custom Divisions

Geography::Settlement
Cities/Towns

Geography::LocatableLocation
Polymorphic Join

Location Selector
Frontend Interface

Geography::Map
Interactive Maps

Map Center
PostGIS ST_POINT

Map Viewport
PostGIS ST_POLYGON

Leaflet.js Output
Interactive Map

PostGIS Database
Spatial Extensions

Spatial Indexing
Geographic Queries

Proximity Search
ST_DWithin Queries

JSON API Output

Map Visualization

Location Display

Geocoding Error
Retry Logic

Validation Error
User Feedback

Fallback to
String Location

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/models_and_concerns_diagram.svg b/docs/diagrams/exports/svg/models_and_concerns_diagram.svg index 27a32e888..389708307 100644 --- a/docs/diagrams/exports/svg/models_and_concerns_diagram.svg +++ b/docs/diagrams/exports/svg/models_and_concerns_diagram.svg @@ -1 +1 @@ -has_oneagentsenderentry111*1*11111*1*1****

Person

<

FriendlySlug,Privacy,Viewable,RemoveableAttachment>>

«DeviseUser»

User

Identification

Community

<

Protected,Privacy,Permissible,RemoveableAttachment>>

Platform

<

Protected,Privacy,Permissible>>

«Membership»

PersonCommunityMembership

«Membership»

PersonPlatformMembership

Post

<

FriendlySlug>>

Page

<

FriendlySlug>>

Category

Categorization

Conversation

ConversationParticipant

Message

Event

<

FriendlySlug,Geospatial::One,Locatable::One,Identifier,

Privacy,TrackedActivity,Viewable>>

EventCategory

Calendar

CalendarEntry

«BuildingConnections»

Building

Floor

Room

Address

ContactDetail

\ No newline at end of file +has_oneagentsenderentry111*1*11111*1*1****

Person

<

FriendlySlug,Privacy,Viewable,RemoveableAttachment>>

«DeviseUser»

User

Identification

Community

<

Protected,Privacy,Permissible,RemoveableAttachment>>

Platform

<

Protected,Privacy,Permissible>>

«Membership»

PersonCommunityMembership

«Membership»

PersonPlatformMembership

Post

<

FriendlySlug>>

Page

<

FriendlySlug>>

Category

Categorization

Conversation

ConversationParticipant

Message

Event

<

FriendlySlug,Geospatial::One,Locatable::One,Identifier,

Privacy,TrackedActivity,Viewable>>

EventCategory

Calendar

CalendarEntry

«BuildingConnections»

Building

Floor

Room

Address

ContactDetail

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/platform_manager_admin_flow.svg b/docs/diagrams/exports/svg/platform_manager_admin_flow.svg new file mode 100644 index 000000000..2d892b676 --- /dev/null +++ b/docs/diagrams/exports/svg/platform_manager_admin_flow.svg @@ -0,0 +1 @@ +Platform Manager - UserAdministrationView DetailsEdit AccountRole ManagementDelete Account

User Administration Access

User Directory

View All Users List

Select User Action?

User Profile View

Admin Edit Access

Role Assignment Interface

Account Deletion Workflow

Review User Activity

Community Memberships

Content History

Support History

Modify Account Details

Update Profile Information

Audit Log Entry

Platform Role Assignment

Community Role Assignment

Permission Recalculation

Cache Permission Updates

Data Export Option

Confirmation Required

Account Deletion

Data Cleanup Jobs

Audit Trail Update

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/platform_manager_invitations_flow.svg b/docs/diagrams/exports/svg/platform_manager_invitations_flow.svg new file mode 100644 index 000000000..7bd1b08c4 --- /dev/null +++ b/docs/diagrams/exports/svg/platform_manager_invitations_flow.svg @@ -0,0 +1 @@ +Platform Manager -Invitation SystemYesNoView URLResendDelete

Platform Manager Access

Host Dashboard

Platform Management

Create New Invitation?

New Invitation Form

View Existing Invitations

Set Invitation Details

Assign Roles

Set Validity Period

Add Personal Message

Create Invitation

Generate Invitation Token

Queue Invitation Email

Background Email Job

Email Delivered

Invitation List View

Invitation Actions?

Copy Invitation Link

Resend Email Job

Remove Invitation

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/platform_manager_support_flow.svg b/docs/diagrams/exports/svg/platform_manager_support_flow.svg new file mode 100644 index 000000000..dd2957e24 --- /dev/null +++ b/docs/diagrams/exports/svg/platform_manager_support_flow.svg @@ -0,0 +1 @@ +Platform Manager - UserSupportAuthenticationProfileCommunity AccessTechnical

Support Request Received

Categorize Issue

Issue Type?

Check Account Status

Review Profile Data

Check Memberships

System Diagnostics

Password Reset Tools

Email Verification

Account Unlock

Edit Profile Access

Privacy Settings

Username Changes

Role Assignment

Community Membership

Permission Updates

Error Log Analysis

System Health Check

Escalate to Tech Team

Implement Solution

Test Resolution

Notify User

Document Solution

Close Support Ticket

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/security_monitoring_flow.svg b/docs/diagrams/exports/svg/security_monitoring_flow.svg new file mode 100644 index 000000000..b4a371617 --- /dev/null +++ b/docs/diagrams/exports/svg/security_monitoring_flow.svg @@ -0,0 +1 @@ +Security and MonitoringYesNoYesNo

Security Monitoring

Failed Login Detection

Suspicious Activity?

Account Lockout

Normal Activity Logging

Security Alert

Platform Manager Notification

Investigation Required

Activity Analytics

User Engagement Tracking

Platform Health Monitoring

Account Analysis

Security Risk?

Enhanced Security Measures

Account Recovery Process

Password Reset Required

User Notification

Security Incident Documentation

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/user_authentication_flow.svg b/docs/diagrams/exports/svg/user_authentication_flow.svg new file mode 100644 index 000000000..1f4204757 --- /dev/null +++ b/docs/diagrams/exports/svg/user_authentication_flow.svg @@ -0,0 +1 @@ +User AuthenticationNoYesNoYesNoYesYesNoYesNo

User Sign In Attempt

Enter Email/Password

Credentials Valid?

Show Login Error

Too Many Attempts?

Account Lockout

Account Confirmed?

Resend Confirmation

Load User Session

Check Platform Privacy

Private Platform?

Valid Invitation?

Access Denied

Grant Access

Load User Context

Cache Permissions

Redirect to Dashboard

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/user_management_flow.svg b/docs/diagrams/exports/svg/user_management_flow.svg new file mode 100644 index 000000000..04439be84 --- /dev/null +++ b/docs/diagrams/exports/svg/user_management_flow.svg @@ -0,0 +1 @@ +

User Registration

Platform Manager Invitations

User Authentication

Platform Manager Support

User Profile Management

Platform Manager Administration

Security & Monitoring

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/user_profile_management_flow.svg b/docs/diagrams/exports/svg/user_profile_management_flow.svg new file mode 100644 index 000000000..f193e7d4a --- /dev/null +++ b/docs/diagrams/exports/svg/user_profile_management_flow.svg @@ -0,0 +1 @@ +User Profile ManagementNoYesNoNoYesYesNoYes

User Profile Access

View Profile Page

Edit Profile?

View Only Mode

User Owns Profile?

Permission Check

Admin Access?

Admin Edit Mode

User Edit Mode

Edit Profile Form

Update Information

Changes Valid?

Show Validation Errors

Save Changes

Update Search Index

Broadcast Updates

Show Success Message

\ No newline at end of file diff --git a/docs/diagrams/exports/svg/user_registration_flow.svg b/docs/diagrams/exports/svg/user_registration_flow.svg new file mode 100644 index 000000000..679298a54 --- /dev/null +++ b/docs/diagrams/exports/svg/user_registration_flow.svg @@ -0,0 +1 @@ +User Registration FlowPublicPrivate/Invitation-OnlyNoYesNoYesYesNo

User Initiates Registration

Platform Privacy?

Direct Registration Form

Invitation Code Required

Valid Invitation Code?

Show Code Entry Form

Pre-filled Registration Form

User Enters Code

Registration Form

User Fills Form

Form Valid?

Show Validation Errors

Create User Account

Create Person Profile

Process Legal Agreements

Invitation Present?

Apply Invitation Roles

Default Community Member

Mark Invitation Accepted

Send Confirmation Email

User Checks Email

Click Confirmation Link

Account Activated

Sign In Available

\ No newline at end of file diff --git a/docs/diagrams/source/conversations_messaging_flow.mmd b/docs/diagrams/source/conversations_messaging_flow.mmd index 4af1aceaf..ebd8b21d1 100644 --- a/docs/diagrams/source/conversations_messaging_flow.mmd +++ b/docs/diagrams/source/conversations_messaging_flow.mmd @@ -9,7 +9,10 @@ flowchart TD C4 --> C6[Create conversation] C5 --> C6 C6 --> C7[Add creator as participant] - C7 --> C8[Conversation created] + C7 --> C7R[Attempt to add participant] + C7R -->|StaleObjectError| C7RR[Reload person and retry once] + C7R -->|Success| C8[Conversation created] + C7RR -->|Success| C8 end %% Message Flow diff --git a/docs/diagrams/source/events_location_selector_flow.mmd b/docs/diagrams/source/events_location_selector_flow.mmd index 83102e1e0..f4f36a508 100644 --- a/docs/diagrams/source/events_location_selector_flow.mmd +++ b/docs/diagrams/source/events_location_selector_flow.mmd @@ -88,11 +88,11 @@ flowchart TD CREATEBLDG --> FOCUS2[Focus first building field] %% Stimulus Controller Methods - INIT -.-> CONNECT[connect() method] - USERCHOICE -.-> TOGGLE[toggleLocationType()] - CREATEADDR -.-> SHOWNEW[showNewAddress()] - CREATEBLDG -.-> SHOWNEWB[showNewBuilding()] - HIDE -.-> HIDEALL[hideAllLocationTypes()] + INIT -.-> CONNECT["connect() method"] + USERCHOICE -.-> TOGGLE["toggleLocationType()"] + CREATEADDR -.-> SHOWNEW["showNewAddress()"] + CREATEBLDG -.-> SHOWNEWB["showNewBuilding()"] + HIDE -.-> HIDEALL["hideAllLocationTypes()"] %% CSS Classes for Visual Distinction classDef stimulus fill:#e3f2fd diff --git a/docs/diagrams/source/platform_manager_admin_flow.mmd b/docs/diagrams/source/platform_manager_admin_flow.mmd new file mode 100644 index 000000000..8798ce1bb --- /dev/null +++ b/docs/diagrams/source/platform_manager_admin_flow.mmd @@ -0,0 +1,28 @@ +%%{init: {"flowchart": {"diagramPadding": 40, "nodeSpacing": 160, "rankSpacing": 120}}}%% +graph TD + subgraph "Platform Manager - User Administration" + ADM1[User Administration Access] --> ADM2[User Directory] + ADM2 --> ADM3[View All Users List] + ADM3 --> ADM4{Select User Action?} + ADM4 -->|View Details| ADM5[User Profile View] + ADM4 -->|Edit Account| ADM6[Admin Edit Access] + ADM4 -->|Role Management| ADM7[Role Assignment Interface] + ADM4 -->|Delete Account| ADM8[Account Deletion Workflow] + ADM5 --> ADM9[Review User Activity] + ADM9 --> ADM10[Community Memberships] + ADM10 --> ADM11[Content History] + ADM11 --> ADM12[Support History] + ADM6 --> ADM13[Modify Account Details] + ADM13 --> ADM14[Update Profile Information] + ADM14 --> ADM15[Audit Log Entry] + ADM7 --> ADM16[Platform Role Assignment] + ADM7 --> ADM17[Community Role Assignment] + ADM16 --> ADM18[Permission Recalculation] + ADM17 --> ADM18 + ADM18 --> ADM19[Cache Permission Updates] + ADM8 --> ADM20[Data Export Option] + ADM20 --> ADM21[Confirmation Required] + ADM21 --> ADM22[Account Deletion] + ADM22 --> ADM23[Data Cleanup Jobs] + ADM23 --> ADM24[Audit Trail Update] + end diff --git a/docs/diagrams/source/platform_manager_invitations_flow.mmd b/docs/diagrams/source/platform_manager_invitations_flow.mmd new file mode 100644 index 000000000..89329866a --- /dev/null +++ b/docs/diagrams/source/platform_manager_invitations_flow.mmd @@ -0,0 +1,24 @@ +%%{init: {"flowchart": {"diagramPadding": 40, "nodeSpacing": 160, "rankSpacing": 120}}}%% +graph TD + subgraph "Platform Manager - Invitation System" + PM1[Platform Manager Access] --> PM2[Host Dashboard] + PM2 --> PM3[Platform Management] + PM3 --> PM4{Create New Invitation?} + PM4 -->|Yes| PM5[New Invitation Form] + PM4 -->|No| PM6[View Existing Invitations] + PM5 --> PM7[Set Invitation Details] + PM7 --> PM8[Assign Roles] + PM8 --> PM9[Set Validity Period] + PM9 --> PM10[Add Personal Message] + PM10 --> PM11[Create Invitation] + PM11 --> PM12[Generate Invitation Token] + PM12 --> PM13[Queue Invitation Email] + PM13 --> PM14[Background Email Job] + PM14 --> PM15[Email Delivered] + PM6 --> PM16[Invitation List View] + PM16 --> PM17{Invitation Actions?} + PM17 -->|View URL| PM18[Copy Invitation Link] + PM17 -->|Resend| PM19[Resend Email Job] + PM17 -->|Delete| PM20[Remove Invitation] + PM19 --> PM14 + end diff --git a/docs/diagrams/source/platform_manager_support_flow.mmd b/docs/diagrams/source/platform_manager_support_flow.mmd new file mode 100644 index 000000000..3f15988ac --- /dev/null +++ b/docs/diagrams/source/platform_manager_support_flow.mmd @@ -0,0 +1,37 @@ +%%{init: {"flowchart": {"diagramPadding": 40, "nodeSpacing": 160, "rankSpacing": 120}}}%% +graph TD + subgraph "Platform Manager - User Support" + SUP1[Support Request Received] --> SUP2[Categorize Issue] + SUP2 --> SUP3{Issue Type?} + SUP3 -->|Authentication| SUP4[Check Account Status] + SUP3 -->|Profile| SUP5[Review Profile Data] + SUP3 -->|Community Access| SUP6[Check Memberships] + SUP3 -->|Technical| SUP7[System Diagnostics] + SUP4 --> SUP8[Password Reset Tools] + SUP4 --> SUP9[Email Verification] + SUP4 --> SUP10[Account Unlock] + SUP5 --> SUP11[Edit Profile Access] + SUP5 --> SUP12[Privacy Settings] + SUP5 --> SUP13[Username Changes] + SUP6 --> SUP14[Role Assignment] + SUP6 --> SUP15[Community Membership] + SUP6 --> SUP16[Permission Updates] + SUP7 --> SUP17[Error Log Analysis] + SUP7 --> SUP18[System Health Check] + SUP7 --> SUP19[Escalate to Tech Team] + SUP8 --> SUP20[Implement Solution] + SUP9 --> SUP20 + SUP10 --> SUP20 + SUP11 --> SUP20 + SUP12 --> SUP20 + SUP13 --> SUP20 + SUP14 --> SUP20 + SUP15 --> SUP20 + SUP16 --> SUP20 + SUP17 --> SUP20 + SUP18 --> SUP20 + SUP20 --> SUP21[Test Resolution] + SUP21 --> SUP22[Notify User] + SUP22 --> SUP23[Document Solution] + SUP23 --> SUP24[Close Support Ticket] + end diff --git a/docs/diagrams/source/security_monitoring_flow.mmd b/docs/diagrams/source/security_monitoring_flow.mmd new file mode 100644 index 000000000..79212f9c4 --- /dev/null +++ b/docs/diagrams/source/security_monitoring_flow.mmd @@ -0,0 +1,21 @@ +%%{init: {"flowchart": {"diagramPadding": 40, "nodeSpacing": 160, "rankSpacing": 120}}}%% +graph TD + subgraph "Security and Monitoring" + SEC1[Security Monitoring] --> SEC2[Failed Login Detection] + SEC2 --> SEC3{Suspicious Activity?} + SEC3 -->|Yes| SEC4[Account Lockout] + SEC3 -->|No| SEC5[Normal Activity Logging] + SEC4 --> SEC6[Security Alert] + SEC6 --> SEC7[Platform Manager Notification] + SEC7 --> SEC8[Investigation Required] + SEC5 --> SEC9[Activity Analytics] + SEC9 --> SEC10[User Engagement Tracking] + SEC10 --> SEC11[Platform Health Monitoring] + SEC8 --> SEC12[Account Analysis] + SEC12 --> SEC13{Security Risk?} + SEC13 -->|Yes| SEC14[Enhanced Security Measures] + SEC13 -->|No| SEC15[Account Recovery Process] + SEC14 --> SEC16[Password Reset Required] + SEC15 --> SEC17[User Notification] + SEC16 --> SEC18[Security Incident Documentation] + end diff --git a/docs/diagrams/source/user_authentication_flow.mmd b/docs/diagrams/source/user_authentication_flow.mmd new file mode 100644 index 000000000..3c53ed1ea --- /dev/null +++ b/docs/diagrams/source/user_authentication_flow.mmd @@ -0,0 +1,22 @@ +%%{init: {"flowchart": {"diagramPadding": 40, "nodeSpacing": 160, "rankSpacing": 120}}}%% +graph TD + subgraph "User Authentication" + AUTH1[User Sign In Attempt] --> AUTH2[Enter Email/Password] + AUTH2 --> AUTH3{Credentials Valid?} + AUTH3 -->|No| AUTH4[Show Login Error] + AUTH4 --> AUTH5{Too Many Attempts?} + AUTH5 -->|Yes| AUTH6[Account Lockout] + AUTH5 -->|No| AUTH2 + AUTH3 -->|Yes| AUTH7{Account Confirmed?} + AUTH7 -->|No| AUTH8[Resend Confirmation] + AUTH7 -->|Yes| AUTH9[Load User Session] + AUTH9 --> AUTH10[Check Platform Privacy] + AUTH10 --> AUTH11{Private Platform?} + AUTH11 -->|Yes| AUTH12{Valid Invitation?} + AUTH12 -->|No| AUTH13[Access Denied] + AUTH12 -->|Yes| AUTH14[Grant Access] + AUTH11 -->|No| AUTH14 + AUTH14 --> AUTH15[Load User Context] + AUTH15 --> AUTH16[Cache Permissions] + AUTH16 --> AUTH17[Redirect to Dashboard] + end diff --git a/docs/diagrams/source/user_management_flow.mmd b/docs/diagrams/source/user_management_flow.mmd index 4bcada925..bcbda646b 100644 --- a/docs/diagrams/source/user_management_flow.mmd +++ b/docs/diagrams/source/user_management_flow.mmd @@ -1,239 +1,9 @@ -graph TB - %% User Management Flow - End User and Platform Manager Perspectives - %% Better Together Community Engine - - %% User Registration Flow - subgraph "User Registration Flow" - A[User Initiates Registration] --> B{Platform Privacy?} - B -->|Public| C[Direct Registration Form] - B -->|Private/Invitation-Only| D[Invitation Code Required] - - D --> E{Valid Invitation Code?} - E -->|No| F[Show Code Entry Form] - E -->|Yes| G[Pre-filled Registration Form] - F --> H[User Enters Code] - H --> E - - C --> I[Registration Form] - G --> I - - I --> J[User Fills Form] - J --> K{Form Valid?} - K -->|No| L[Show Validation Errors] - L --> J - - K -->|Yes| M[Create User Account] - M --> N[Create Person Profile] - N --> O[Process Legal Agreements] - - O --> P{Invitation Present?} - P -->|Yes| Q[Apply Invitation Roles] - P -->|No| R[Default Community Member] - - Q --> S[Mark Invitation Accepted] - R --> T[Send Confirmation Email] - S --> T - - T --> U[User Checks Email] - U --> V[Click Confirmation Link] - V --> W[Account Activated] - W --> X[Sign In Available] - end - - %% Platform Manager Invitation Management - subgraph "Platform Manager - Invitation System" - PM1[Platform Manager Access] --> PM2[Host Dashboard] - PM2 --> PM3[Platform Management] - PM3 --> PM4{Create New Invitation?} - - PM4 -->|Yes| PM5[New Invitation Form] - PM4 -->|No| PM6[View Existing Invitations] - - PM5 --> PM7[Set Invitation Details] - PM7 --> PM8[Assign Roles] - PM8 --> PM9[Set Validity Period] - PM9 --> PM10[Add Personal Message] - PM10 --> PM11[Create Invitation] - - PM11 --> PM12[Generate Invitation Token] - PM12 --> PM13[Queue Invitation Email] - PM13 --> PM14[Background Email Job] - PM14 --> PM15[Email Delivered] - - PM6 --> PM16[Invitation List View] - PM16 --> PM17{Invitation Actions?} - PM17 -->|View URL| PM18[Copy Invitation Link] - PM17 -->|Resend| PM19[Resend Email Job] - PM17 -->|Delete| PM20[Remove Invitation] - - PM19 --> PM14 - end - - %% User Authentication Flow - subgraph "User Authentication" - AUTH1[User Sign In Attempt] --> AUTH2[Enter Email/Password] - AUTH2 --> AUTH3{Credentials Valid?} - - AUTH3 -->|No| AUTH4[Show Login Error] - AUTH4 --> AUTH5{Too Many Attempts?} - AUTH5 -->|Yes| AUTH6[Account Lockout] - AUTH5 -->|No| AUTH2 - - AUTH3 -->|Yes| AUTH7{Account Confirmed?} - AUTH7 -->|No| AUTH8[Resend Confirmation] - AUTH7 -->|Yes| AUTH9[Load User Session] - - AUTH9 --> AUTH10[Check Platform Privacy] - AUTH10 --> AUTH11{Private Platform?} - AUTH11 -->|Yes| AUTH12{Valid Invitation?} - AUTH12 -->|No| AUTH13[Access Denied] - AUTH12 -->|Yes| AUTH14[Grant Access] - AUTH11 -->|No| AUTH14 - - AUTH14 --> AUTH15[Load User Context] - AUTH15 --> AUTH16[Cache Permissions] - AUTH16 --> AUTH17[Redirect to Dashboard] - end - - %% Platform Manager User Support - subgraph "Platform Manager - User Support" - SUP1[Support Request Received] --> SUP2[Categorize Issue] - SUP2 --> SUP3{Issue Type?} - - SUP3 -->|Authentication| SUP4[Check Account Status] - SUP3 -->|Profile| SUP5[Review Profile Data] - SUP3 -->|Community Access| SUP6[Check Memberships] - SUP3 -->|Technical| SUP7[System Diagnostics] - - SUP4 --> SUP8[Password Reset Tools] - SUP4 --> SUP9[Email Verification] - SUP4 --> SUP10[Account Unlock] - - SUP5 --> SUP11[Edit Profile Access] - SUP5 --> SUP12[Privacy Settings] - SUP5 --> SUP13[Username Changes] - - SUP6 --> SUP14[Role Assignment] - SUP6 --> SUP15[Community Membership] - SUP6 --> SUP16[Permission Updates] - - SUP7 --> SUP17[Error Log Analysis] - SUP7 --> SUP18[System Health Check] - SUP7 --> SUP19[Escalate to Tech Team] - - SUP8 --> SUP20[Implement Solution] - SUP9 --> SUP20 - SUP10 --> SUP20 - SUP11 --> SUP20 - SUP12 --> SUP20 - SUP13 --> SUP20 - SUP14 --> SUP20 - SUP15 --> SUP20 - SUP16 --> SUP20 - SUP17 --> SUP20 - SUP18 --> SUP20 - - SUP20 --> SUP21[Test Resolution] - SUP21 --> SUP22[Notify User] - SUP22 --> SUP23[Document Solution] - SUP23 --> SUP24[Close Support Ticket] - end - - %% User Profile Management - subgraph "User Profile Management" - PROF1[User Profile Access] --> PROF2[View Profile Page] - PROF2 --> PROF3{Edit Profile?} - PROF3 -->|No| PROF4[View Only Mode] - PROF3 -->|Yes| PROF5{User Owns Profile?} - - PROF5 -->|No| PROF6[Permission Check] - PROF6 --> PROF7{Admin Access?} - PROF7 -->|No| PROF4 - PROF7 -->|Yes| PROF8[Admin Edit Mode] - - PROF5 -->|Yes| PROF9[User Edit Mode] - - PROF8 --> PROF10[Edit Profile Form] - PROF9 --> PROF10 - - PROF10 --> PROF11[Update Information] - PROF11 --> PROF12{Changes Valid?} - PROF12 -->|No| PROF13[Show Validation Errors] - PROF13 --> PROF10 - - PROF12 -->|Yes| PROF14[Save Changes] - PROF14 --> PROF15[Update Search Index] - PROF15 --> PROF16[Broadcast Updates] - PROF16 --> PROF17[Show Success Message] - end - - %% Platform Manager User Administration - subgraph "Platform Manager - User Administration" - ADM1[User Administration Access] --> ADM2[User Directory] - ADM2 --> ADM3[View All Users List] - ADM3 --> ADM4{Select User Action?} - - ADM4 -->|View Details| ADM5[User Profile View] - ADM4 -->|Edit Account| ADM6[Admin Edit Access] - ADM4 -->|Role Management| ADM7[Role Assignment Interface] - ADM4 -->|Delete Account| ADM8[Account Deletion Workflow] - - ADM5 --> ADM9[Review User Activity] - ADM9 --> ADM10[Community Memberships] - ADM10 --> ADM11[Content History] - ADM11 --> ADM12[Support History] - - ADM6 --> ADM13[Modify Account Details] - ADM13 --> ADM14[Update Profile Information] - ADM14 --> ADM15[Audit Log Entry] - - ADM7 --> ADM16[Platform Role Assignment] - ADM7 --> ADM17[Community Role Assignment] - ADM16 --> ADM18[Permission Recalculation] - ADM17 --> ADM18 - ADM18 --> ADM19[Cache Permission Updates] - - ADM8 --> ADM20[Data Export Option] - ADM20 --> ADM21[Confirmation Required] - ADM21 --> ADM22[Account Deletion] - ADM22 --> ADM23[Data Cleanup Jobs] - ADM23 --> ADM24[Audit Trail Update] - end - - %% Security and Monitoring - subgraph "Security and Monitoring" - SEC1[Security Monitoring] --> SEC2[Failed Login Detection] - SEC2 --> SEC3{Suspicious Activity?} - SEC3 -->|Yes| SEC4[Account Lockout] - SEC3 -->|No| SEC5[Normal Activity Logging] - - SEC4 --> SEC6[Security Alert] - SEC6 --> SEC7[Platform Manager Notification] - SEC7 --> SEC8[Investigation Required] - - SEC5 --> SEC9[Activity Analytics] - SEC9 --> SEC10[User Engagement Tracking] - SEC10 --> SEC11[Platform Health Monitoring] - - SEC8 --> SEC12[Account Analysis] - SEC12 --> SEC13{Security Risk?} - SEC13 -->|Yes| SEC14[Enhanced Security Measures] - SEC13 -->|No| SEC15[Account Recovery Process] - - SEC14 --> SEC16[Password Reset Required] - SEC15 --> SEC17[User Notification] - SEC16 --> SEC18[Security Incident Documentation] - end - - %% Styling - classDef userAction fill:#e1f5fe,stroke:#01579b,stroke-width:2px - classDef adminAction fill:#f3e5f5,stroke:#4a148c,stroke-width:2px - classDef systemProcess fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px - classDef errorState fill:#ffebee,stroke:#b71c1c,stroke-width:2px - classDef securityProcess fill:#fff3e0,stroke:#e65100,stroke-width:2px - - class A,J,U,V,X,AUTH1,AUTH2,PROF1,PROF2,PROF3 userAction - class PM1,PM5,PM6,SUP1,SUP2,ADM1,ADM2,ADM3 adminAction - class M,N,O,T,PM11,PM12,PM13,PM14,AUTH9,AUTH15,AUTH16 systemProcess - class L,AUTH4,AUTH6,AUTH13,PROF13,SEC4,SEC6 errorState - class SEC1,SEC2,SEC8,SEC12,SEC14,SEC16 securityProcess +%% Index diagram that links to the user-management sub-diagrams. Use the split diagrams in the `diagrams/exports` folder for detail. +graph TD + UR[User Registration] --> INV[Platform Manager Invitations] + INV --> AUTH[User Authentication] + AUTH --> SUP[Platform Manager Support] + SUP --> PROF[User Profile Management] + PROF --> ADM[Platform Manager Administration] + ADM --> SEC[Security & Monitoring] + class UR,INV,AUTH,SUP,PROF,ADM,SEC userAction diff --git a/docs/diagrams/source/user_profile_management_flow.mmd b/docs/diagrams/source/user_profile_management_flow.mmd new file mode 100644 index 000000000..ece2bb810 --- /dev/null +++ b/docs/diagrams/source/user_profile_management_flow.mmd @@ -0,0 +1,23 @@ +%%{init: {"flowchart": {"diagramPadding": 40, "nodeSpacing": 160, "rankSpacing": 120}}}%% +graph TD + subgraph "User Profile Management" + PROF1[User Profile Access] --> PROF2[View Profile Page] + PROF2 --> PROF3{Edit Profile?} + PROF3 -->|No| PROF4[View Only Mode] + PROF3 -->|Yes| PROF5{User Owns Profile?} + PROF5 -->|No| PROF6[Permission Check] + PROF6 --> PROF7{Admin Access?} + PROF7 -->|No| PROF4 + PROF7 -->|Yes| PROF8[Admin Edit Mode] + PROF5 -->|Yes| PROF9[User Edit Mode] + PROF8 --> PROF10[Edit Profile Form] + PROF9 --> PROF10 + PROF10 --> PROF11[Update Information] + PROF11 --> PROF12{Changes Valid?} + PROF12 -->|No| PROF13[Show Validation Errors] + PROF13 --> PROF10 + PROF12 -->|Yes| PROF14[Save Changes] + PROF14 --> PROF15[Update Search Index] + PROF15 --> PROF16[Broadcast Updates] + PROF16 --> PROF17[Show Success Message] + end diff --git a/docs/diagrams/source/user_registration_flow.mmd b/docs/diagrams/source/user_registration_flow.mmd new file mode 100644 index 000000000..2cf2d9268 --- /dev/null +++ b/docs/diagrams/source/user_registration_flow.mmd @@ -0,0 +1,31 @@ +%%{init: {"flowchart": {"diagramPadding": 40, "nodeSpacing": 50, "rankSpacing": 80}}}%% +graph TD + subgraph "User Registration Flow" + A[User Initiates Registration] --> B{Platform Privacy?} + B -->|Public| C[Direct Registration Form] + B -->|Private/Invitation-Only| D[Invitation Code Required] + D --> E{Valid Invitation Code?} + E -->|No| F[Show Code Entry Form] + E -->|Yes| G[Pre-filled Registration Form] + F --> H[User Enters Code] + H --> E + C --> I[Registration Form] + G --> I + I --> J[User Fills Form] + J --> K{Form Valid?} + K -->|No| L[Show Validation Errors] + L --> J + K -->|Yes| M[Create User Account] + M --> N[Create Person Profile] + N --> O[Process Legal Agreements] + O --> P{Invitation Present?} + P -->|Yes| Q[Apply Invitation Roles] + P -->|No| R[Default Community Member] + Q --> S[Mark Invitation Accepted] + R --> T[Send Confirmation Email] + S --> T + T --> U[User Checks Email] + U --> V[Click Confirmation Link] + V --> W[Account Activated] + W --> X[Sign In Available] + end diff --git a/docs/ui/resource_toolbar.md b/docs/ui/resource_toolbar.md index 442ec6fc0..59cfe8e91 100644 --- a/docs/ui/resource_toolbar.md +++ b/docs/ui/resource_toolbar.md @@ -6,17 +6,21 @@ A shared partial for rendering edit, view, and destroy buttons for a resource. ``` <%= render 'shared/resource_toolbar', + back_to_list_path: posts_path, edit_path: edit_post_path(@post), view_path: post_path(@post), destroy_path: post_path(@post), destroy_confirm: t('globals.confirm_delete'), edit_aria_label: 'Edit Post', view_aria_label: 'View Post', - destroy_aria_label: 'Delete Post' %> + destroy_aria_label: 'Delete Post' do %> + <%= link_to 'Publish', publish_post_path(@post), class: 'btn btn-outline-success btn-sm' %> +<% end %> ``` ## Locals +- `back_to_list_path` – link for a back action (optional) - `edit_path` – link for the edit action (optional) - `view_path` – link for the view action (optional) - `destroy_path` – link for the destroy action (optional) @@ -24,3 +28,7 @@ A shared partial for rendering edit, view, and destroy buttons for a resource. - `edit_aria_label`, `view_aria_label`, `destroy_aria_label` – ARIA labels for accessibility. Buttons render only when the corresponding path is provided. Defaults use the global translations for button text and ARIA labels. + +### Block Content + +When a block is given, its content renders in a separate, right-aligned toolbar section, allowing additional actions to be appended without mixing with the primary actions. diff --git a/scripts/local_ci_runner.sh b/scripts/local_ci_runner.sh new file mode 100755 index 000000000..3cd1a393e --- /dev/null +++ b/scripts/local_ci_runner.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# Local CI runner that mirrors .github/workflows/rubyonrails.yml pass conditions +# Usage: ./scripts/local_ci_runner.sh [--no-services] [--no-parallel] [--timeout-es 60] + +set -u + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" || exit 1 + +NO_SERVICES=0 +NO_PARALLEL=0 +ES_TIMEOUT=60 + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-services) NO_SERVICES=1; shift ;; + --no-parallel) NO_PARALLEL=1; shift ;; + --timeout-es) ES_TIMEOUT="$2"; shift 2 ;; + -h|--help) + cat </dev/null 2>&1; then + if docker compose version >/dev/null 2>&1; then + log "Starting services via 'docker compose'" + # Start services detached; avoid attaching or waiting for input + DOCKER_TIMEOUT=30 + docker compose up -d --remove-orphans postgres elasticsearch || return 1 + elif command -v docker-compose >/dev/null 2>&1; then + log "Starting services via 'docker-compose'" + docker-compose up -d --remove-orphans postgres elasticsearch || return 1 + else + log "docker compose not available; please start Postgres and Elasticsearch manually" + return 1 + fi + else + log "docker not found; please start Postgres and Elasticsearch manually" + return 1 + fi +} + +wait_for_elasticsearch() { + local timeout=${ES_TIMEOUT:-60} + local deadline=$((SECONDS + timeout)) + log "Waiting up to ${timeout}s for Elasticsearch to be healthy on http://localhost:9200" + while [[ $SECONDS -lt $deadline ]]; do + if curl -s "http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=1s" >/dev/null 2>&1; then + log "Elasticsearch reported healthy" + return 0 + fi + sleep 1 + done + log "Timed out waiting for Elasticsearch" + return 1 +} + +prepare_db_schema() { + # Matches workflow: bundle exec rake -f spec/dummy/Rakefile db:schema:load + if [[ -x ./bin/dc-run ]]; then + run_cmd "Prepare DB schema (via bin/dc-run)" ./bin/dc-run bundle exec rake -f spec/dummy/Rakefile db:schema:load + else + run_cmd "Prepare DB schema (native)" bundle exec rake -f spec/dummy/Rakefile db:schema:load + fi +} + +run_rspec() { + if [[ -x ./bin/dc-run ]]; then + run_cmd "Run RSpec (via bin/dc-run)" ./bin/dc-run bundle exec rspec + else + run_cmd "Run RSpec (native)" bundle exec rspec + fi +} + +run_rubocop() { + if [[ -x ./bin/dc-run ]]; then + run_cmd "Rubocop" ./bin/dc-run bundle exec rubocop --parallel + else + run_cmd "Rubocop" bundle exec rubocop --parallel + fi +} + +run_security_checks() { + # bundler-audit + brakeman (as in workflow) + if [[ -x ./bin/dc-run ]]; then + # Ensure non-interactive environment + export CI=1 + + # Don't attempt to install binstubs (can be interactive); run bundler-audit directly + ./bin/dc-run bundle exec bundler-audit --update >/dev/null 2>&1 || log "bundler-audit update completed (advisories may exist)" + run_cmd "Run bundler-audit" ./bin/dc-run bundle exec bundler-audit --quiet || true + # Run brakeman non-interactively: force no pager and limited verbosity + PAGER=cat TERM=dumb run_cmd "Run brakeman" ./bin/dc-run bundle exec brakeman --no-pager -q -w2 + else + export CI=1 + + # Don't attempt to install binstubs (can be interactive); run bundler-audit directly + bundle exec bundler-audit --update >/dev/null 2>&1 || log "bundler-audit update completed (advisories may exist)" + run_cmd "Run bundler-audit (native)" bundle exec bundler-audit --quiet || true + PAGER=cat TERM=dumb run_cmd "Run brakeman (native)" bundle exec brakeman --no-pager -q -w2 + fi +} + +# Main orchestration +log "Local CI runner starting" + +# Step 1: start services (non-blocking) if needed +if start_services; then + STEP_STATUS[start_services]=0 +else + STEP_STATUS[start_services]=1 +fi + +# Step 2: run rubocop and security in parallel (these don't require DB/ES) +PIDS=() +if [[ $NO_PARALLEL -eq 0 ]]; then + run_rubocop & + PIDS+=("$!") + run_security_checks & + PIDS+=("$!") +else + run_rubocop + STEP_STATUS[rubocop]=$? + run_security_checks + STEP_STATUS[security]=$? +fi + +# Step 3: ensure ES healthy before DB/RSPEC steps (skip when --no-services) +if [[ $NO_SERVICES -eq 1 ]]; then + log "Skipping Elasticsearch health check because --no-services was passed" + STEP_STATUS[elasticsearch]=0 +else + if wait_for_elasticsearch; then + STEP_STATUS[elasticsearch]=0 + else + STEP_STATUS[elasticsearch]=1 + fi +fi + +# Step 4: prepare DB schema +prepare_db_schema +STEP_STATUS[db_prepare]=$? + +# Step 5: run rspec (this is the heavy step) +run_rspec +STEP_STATUS[rspec]=$? + +# Wait for background jobs if any +if [[ ${#PIDS[@]} -gt 0 ]]; then + for pid in "${PIDS[@]}"; do + if wait "$pid"; then + log "Background job (pid $pid) finished OK" + else + log "Background job (pid $pid) failed" + fi + done + # Capture their exit statuses via jobs' outputs are already logged by run_cmd +fi + +# Summarize +log "Local CI run summary:" +for key in start_services elasticsearch db_prepare rspec rubocop security; do + if [[ -v STEP_STATUS[$key] ]]; then + status=${STEP_STATUS[$key]} + if [[ "$status" -eq 0 ]]; then + printf " %-15s : OK\n" "$key" + else + printf " %-15s : FAIL (exit %d)\n" "$key" "$status" + fi + else + printf " %-15s : SKIPPED/UNKNOWN\n" "$key" + fi +done + +# Exit non-zero if rspec or rubocop or security or db_prepare failed (mimic CI strictness) +if [[ ${STEP_STATUS[rspec]:-0} -ne 0 || ${STEP_STATUS[rubocop]:-0} -ne 0 || ${STEP_STATUS[security]:-0} -ne 0 || ${STEP_STATUS[db_prepare]:-0} -ne 0 ]]; then + log "One or more critical steps failed. See logs above." + exit 2 +fi + +log "All critical steps passed (rspec, rubocop, security, db_prepare)" +exit 0 diff --git a/spec/factories/better_together/conversations.rb b/spec/factories/better_together/conversations.rb index 70b2fe548..c7c9604d8 100644 --- a/spec/factories/better_together/conversations.rb +++ b/spec/factories/better_together/conversations.rb @@ -9,6 +9,10 @@ after(:build) do |conversation| conversation.participants << conversation.creator unless conversation.participants.include?(conversation.creator) + # Build an initial message so model-level presence validations pass during factory#create + if conversation.messages.empty? + conversation.messages.build(sender: conversation.creator, content: 'Initial factory message') + end end after(:create) do |conversation| diff --git a/spec/factories/better_together/people.rb b/spec/factories/better_together/people.rb index c5a58ef54..6742b9af1 100644 --- a/spec/factories/better_together/people.rb +++ b/spec/factories/better_together/people.rb @@ -6,7 +6,7 @@ module BetterTogether FactoryBot.define do factory :better_together_person, class: Person, aliases: %i[person inviter invitee creator author] do id { Faker::Internet.uuid } - name { Faker::Name.name } + name { Faker::Name.unique.name } description { Faker::Lorem.paragraph(sentence_count: 3) } identifier { Faker::Internet.unique.username(specifier: 10..20) } diff --git a/spec/features/conversations/create_spec.rb b/spec/features/conversations/create_spec.rb index 81f702792..bece0038a 100644 --- a/spec/features/conversations/create_spec.rb +++ b/spec/features/conversations/create_spec.rb @@ -3,13 +3,17 @@ require 'rails_helper' RSpec.describe 'creating a new conversation', :as_platform_manager do + include BetterTogether::ConversationHelpers + let!(:user) { create(:better_together_user, :confirmed) } - scenario 'between a platform manager and normal user' do - visit new_conversation_path(locale: I18n.default_locale) - select "#{user.person.name} - @#{user.person.identifier}", from: 'conversation[participant_ids][]' - fill_in 'conversation[title]', with: Faker::Lorem.sentence(word_count: 3) - click_button 'Create Conversation' + before do + # Ensure this person can be messaged by members so they appear in permitted_participants + user.person.update!(preferences: (user.person.preferences || {}).merge('receive_messages_from_members' => true)) + end + + scenario 'between a platform manager and normal user', :js do + create_conversation([user.person], first_message: Faker::Lorem.sentence(word_count: 8)) expect(BetterTogether::Conversation.count).to eq(1) end @@ -20,6 +24,19 @@ let(:user2) { create(:better_together_user) } + # rubocop:todo RSpec/ExampleLength + scenario 'can create a conversation with a public person who opted into messages', :js do + target = create(:better_together_user, :confirmed) + # Ensure target is public and opted-in to receive messages from members + target.person.update!(privacy: 'public', + preferences: (target.person.preferences || {}).merge('receive_messages_from_members' => true)) # rubocop:disable Layout/LineLength + + expect do + create_conversation([target.person], first_message: 'Hi there') + end.to change(BetterTogether::Conversation, :count).by(1) + end + # rubocop:enable RSpec/ExampleLength + it 'cannot create conversations with private users' do visit new_conversation_path(locale: I18n.default_locale) expect('conversation[participant_ids][]').not_to have_content(user2.person.name) # rubocop:todo RSpec/ExpectActual diff --git a/spec/features/conversations_client_validation_spec.rb b/spec/features/conversations_client_validation_spec.rb new file mode 100644 index 000000000..1ed471335 --- /dev/null +++ b/spec/features/conversations_client_validation_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Conversation client-side validation', :js do + include Capybara::DSL + + let!(:user) { create(:user, :confirmed, email: 'feature_user@example.test') } + let!(:other_person) do + create(:better_together_person, preferences: { receive_messages_from_members: true }, name: 'Target Person') + end + + before do + login_as(user, scope: :user) + end + + # rubocop:todo RSpec/ExampleLength + # rubocop:todo RSpec/MultipleExpectations + it 'prevents submission and shows client-side validation when first message is empty' do + # rubocop:enable RSpec/MultipleExpectations + visit better_together.new_conversation_path(locale: I18n.default_locale, + conversation: { participant_ids: [other_person.id] }) + + # Attempt to submit the form (use a robust selector for submit button) + form = page.find('form') + submit = form.first(:button, type: 'submit') || form.first(:xpath, ".//input[@type='submit']") + submit.click + + # The JS form-validation controller should mark the message field invalid + # Look for an element with is-invalid class near trix-editor + expect(page).to have_selector('.trix-editor.is-invalid, .is-invalid', wait: 5) + + # Ensure we are still on the new conversation form (no redirect) + expect(page).to have_current_path(%r{/conversations/new}, ignore_query: true) + end + # rubocop:enable RSpec/ExampleLength +end diff --git a/spec/models/better_together/conversation_spec.rb b/spec/models/better_together/conversation_spec.rb index 21d304af1..d7e160eac 100644 --- a/spec/models/better_together/conversation_spec.rb +++ b/spec/models/better_together/conversation_spec.rb @@ -2,10 +2,39 @@ require 'rails_helper' -module BetterTogether - RSpec.describe Conversation do - it 'exists' do - expect(described_class).to be # rubocop:todo RSpec/Be +RSpec.describe BetterTogether::Conversation do + describe '#add_participant_safe' do + let(:conversation) { create(:better_together_conversation) } + let(:person) { create(:better_together_person) } + + it 'adds a participant when not present' do # rubocop:todo RSpec/MultipleExpectations + expect do + conversation.add_participant_safe(person) + end.to change { conversation.participants.count }.by(1) + expect(conversation.participants).to include(person) + end + + # rubocop:todo RSpec/MultipleExpectations + it 'retries once on ActiveRecord::StaleObjectError and succeeds' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations + # rubocop:enable RSpec/MultipleExpectations + # Simulate the association raising once, then succeeding on retry. + proxy = conversation.participants + + call_count = 0 + allow(proxy).to receive(:<<) do |p| + call_count += 1 + raise ActiveRecord::StaleObjectError.new(p, :update) if call_count == 1 + + # perform the actual append on retry + ActiveRecord::Associations::CollectionProxy.instance_method(:<<).bind(proxy).call(p) + end + + expect do + conversation.add_participant_safe(person) + end.to change { conversation.participants.count }.by(1) + + expect(call_count).to eq(2) + expect(conversation.participants).to include(person) end end end diff --git a/spec/policies/better_together/conversation_policy_spec.rb b/spec/policies/better_together/conversation_policy_spec.rb index c3041bd18..470bee6bc 100644 --- a/spec/policies/better_together/conversation_policy_spec.rb +++ b/spec/policies/better_together/conversation_policy_spec.rb @@ -38,4 +38,22 @@ end end end + + describe '#create? with participants kwarg' do # rubocop:todo RSpec/MultipleMemoizedHelpers + let(:regular_user) { create(:user, :confirmed, password: 'password12345') } + let(:policy) { described_class.new(regular_user, BetterTogether::Conversation.new) } + + it 'allows create when user present and participants are permitted' do + # opted_in_person should be allowed for regular users + expect(policy.create?(participants: [opted_in_person])).to be true + end + + it 'denies create when any participant is not permitted' do + expect(policy.create?(participants: [non_opted_person])).to be false + end + + it 'defaults to basic presence check when participants nil' do + expect(policy.create?).to be true + end + end end diff --git a/spec/requests/better_together/conversation_message_protection_spec.rb b/spec/requests/better_together/conversation_message_protection_spec.rb new file mode 100644 index 000000000..a02f1a614 --- /dev/null +++ b/spec/requests/better_together/conversation_message_protection_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Conversation message protection' do + include RequestSpecHelper + + # rubocop:todo RSpec/ExampleLength + # rubocop:todo RSpec/MultipleExpectations + it "prevents a user from altering another user's message via conversation update" do + # rubocop:enable RSpec/MultipleExpectations + # Setup: ensure host platform exists and create users with known passwords + configure_host_platform + + # Setup: create a manager user (owner of the conversation) and another user + manager_user = create(:user, :confirmed, :platform_manager, email: 'owner@example.test', password: 'password12345') + other_user = create(:user, :confirmed, email: 'attacker@example.test', password: 'password12345') + + # Create a conversation as the manager with a nested message + login(manager_user.email, 'password12345') + + post better_together.conversations_path(locale: I18n.default_locale), params: { + conversation: { + title: 'Protected convo', + participant_ids: [manager_user.person.id, other_user.person.id], + messages_attributes: [ + { content: 'Original message' } + ] + } + } + + expect(response).to have_http_status(:found) + conversation = BetterTogether::Conversation.order(created_at: :desc).first + message = conversation.messages.first + expect(message.content.to_plain_text).to include('Original message') + + # Now sign in as other_user and attempt to change manager's message via PATCH + logout + login(other_user.email, 'password12345') + + patch better_together.conversation_path(conversation, locale: I18n.default_locale), params: { + conversation: { + title: conversation.title, + messages_attributes: [ + { id: message.id, content: 'Tampered message', sender_id: other_user.person.id } + ] + } + } + + # Reload message and assert it was not changed + message.reload + expect(message.content.to_plain_text).to include('Original message') + end + # rubocop:enable RSpec/ExampleLength +end diff --git a/spec/requests/better_together/conversations_create_with_message_spec.rb b/spec/requests/better_together/conversations_create_with_message_spec.rb new file mode 100644 index 000000000..b0c937f4c --- /dev/null +++ b/spec/requests/better_together/conversations_create_with_message_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Create Conversation with initial message' do + include RequestSpecHelper + + before do + configure_host_platform + # Ensure the test user exists and is confirmed + unless BetterTogether::User.find_by(email: 'user@example.test') + create(:user, :confirmed, email: 'user@example.test', + password: 'password12345') + end + login('user@example.test', 'password12345') + end + + # rubocop:todo RSpec/MultipleExpectations + it 'creates conversation and nested message with sender set' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations + # rubocop:enable RSpec/MultipleExpectations + user = BetterTogether::User.find_by(email: 'user@example.test') + person = user.person || create(:better_together_person, user: user) + + post better_together.conversations_path(locale: I18n.default_locale), params: { + conversation: { + title: 'Hello', + participant_ids: [person.id], + messages_attributes: [{ content: 'First message' }] + } + } + + expect(response).to redirect_to(/conversations/) + + conv = BetterTogether::Conversation.order(:created_at).last + expect(conv).to be_present + expect(conv.messages.count).to eq(1) + msg = conv.messages.first + expect(msg.content.to_s).to include('First message') + expect(msg.sender).to eq(user.person) + end +end diff --git a/spec/requests/better_together/navigation_areas_controller_spec.rb b/spec/requests/better_together/navigation_areas_controller_spec.rb index b03324185..abb9ba13b 100644 --- a/spec/requests/better_together/navigation_areas_controller_spec.rb +++ b/spec/requests/better_together/navigation_areas_controller_spec.rb @@ -20,7 +20,7 @@ describe 'POST /:locale/.../navigation_areas' do # rubocop:todo RSpec/MultipleExpectations - it 'creates and redirects on valid params' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations + it 'creates and redirects on valid params, persisting permitted fields' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations # rubocop:enable RSpec/MultipleExpectations post better_together.navigation_areas_path(locale:), params: { navigation_area: { @@ -33,6 +33,11 @@ expect(response).to have_http_status(:found) follow_redirect! expect(response).to have_http_status(:ok) + + area = BetterTogether::NavigationArea.find_by(identifier: 'main-nav') + expect(area).to be_present + expect(area.style).to eq('primary') + expect(area.visible).to be(true) end it 'renders new on invalid params (HTML 200)' do @@ -45,14 +50,17 @@ let!(:area) { create(:better_together_navigation_area, protected: false) } # rubocop:todo RSpec/MultipleExpectations - it 'updates and redirects on valid params' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations + it 'updates and redirects on valid params, applying changes' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations # rubocop:enable RSpec/MultipleExpectations patch better_together.navigation_area_path(locale:, id: area.slug), params: { - navigation_area: { style: 'secondary' } + navigation_area: { style: 'secondary', visible: false } } expect(response).to have_http_status(:found) follow_redirect! expect(response).to have_http_status(:ok) + + expect(area.reload.style).to eq('secondary') + expect(area.reload.visible).to be(false) end it 'renders edit on invalid params (HTML 200)' do diff --git a/spec/requests/better_together/navigation_items_controller_spec.rb b/spec/requests/better_together/navigation_items_controller_spec.rb index 5c4859ff5..761a2231d 100644 --- a/spec/requests/better_together/navigation_items_controller_spec.rb +++ b/spec/requests/better_together/navigation_items_controller_spec.rb @@ -30,7 +30,7 @@ end # rubocop:todo RSpec/MultipleExpectations - it 'creates a navigation item and redirects (HTML)' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations + it 'creates a navigation item and redirects (HTML), persisting permitted fields' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations # rubocop:enable RSpec/MultipleExpectations post better_together.navigation_area_navigation_items_path( locale:, @@ -40,6 +40,15 @@ expect(response).to have_http_status(:found) follow_redirect! expect(response).to have_http_status(:ok) + + # Verify attributes were persisted via strong params + created = BetterTogether::NavigationItem.order(:created_at).last + expect(created).to be_present + expect(created.navigation_area_id).to eq(navigation_area.id) + expect(created.title(locale:)).to eq(params[:navigation_item]["title_#{locale}"]) + expect(created.url).to eq(params[:navigation_item][:url]) + expect(created.visible).to eq(params[:navigation_item][:visible]) + expect(created.item_type).to eq(params[:navigation_item][:item_type]) end it 'renders errors on invalid params' do @@ -62,7 +71,7 @@ end # rubocop:todo RSpec/MultipleExpectations - it 'updates with valid params then redirects' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations + it 'updates with valid params then redirects and applies changes' do # rubocop:todo RSpec/ExampleLength, RSpec/MultipleExpectations # rubocop:enable RSpec/MultipleExpectations put better_together.navigation_area_navigation_item_path( locale:, @@ -73,6 +82,8 @@ expect(response).to have_http_status(:found) follow_redirect! expect(response).to have_http_status(:ok) + + expect(item.reload.title(locale:)).to eq('Updated Title') end it 'renders edit on invalid params (422)' do # rubocop:todo RSpec/ExampleLength diff --git a/spec/requests/better_together/profile_message_prefill_spec.rb b/spec/requests/better_together/profile_message_prefill_spec.rb new file mode 100644 index 000000000..c9fb9d3b5 --- /dev/null +++ b/spec/requests/better_together/profile_message_prefill_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Profile message prefill' do + include RequestSpecHelper + + let!(:user) { create(:user, :confirmed, email: 'user@example.test') } + let!(:other_person) do + create(:better_together_person, preferences: { receive_messages_from_members: true }, name: 'Target Person') + end + + before do + login_as(user, scope: :user) + end + + # rubocop:todo RSpec/MultipleExpectations + it 'preselects the person when visiting new conversation via profile message link' do + # rubocop:enable RSpec/MultipleExpectations + # Simulate clicking the profile message link which sends conversation[participant_ids] in params + get better_together.new_conversation_path(locale: I18n.default_locale, + conversation: { participant_ids: [other_person.id] }) + + expect(response).to have_http_status(:ok) + + # Ensure that the option for the target person is rendered and marked selected. + # The option may include a handle suffix and selected may be rendered as selected="selected" or selected alone. + # rubocop:todo Layout/LineLength + expect(response.body).to match(%r{]+value="#{other_person.id}"[^>]*(selected(="selected")?)?[^>]*>[\s\S]*?Target Person[\s\S]*?}) + # rubocop:enable Layout/LineLength + end +end diff --git a/spec/support/better_together/conversation_helpers.rb b/spec/support/better_together/conversation_helpers.rb index a90695a09..fc78c14ac 100644 --- a/spec/support/better_together/conversation_helpers.rb +++ b/spec/support/better_together/conversation_helpers.rb @@ -5,19 +5,55 @@ module ConversationHelpers include Rails.application.routes.url_helpers include BetterTogether::Engine.routes.url_helpers - def create_conversation(participants) + # participants - array of Person-like objects (respond_to? :slug) + # options - optional hash: :title, :first_message + def create_conversation(participants, options = {}) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength visit new_conversation_path(locale: I18n.default_locale) - slim_select = find('select[name="conversation[participant_ids][]"]+div.form-select') - - slim_select.click + # Wait for the participants select control to render (slim-select wrapper) + select_wrapper = find('select[name="conversation[participant_ids][]"]+div.form-select', match: :first) + select_wrapper.click participants.each do |participant| - find('.ss-content > .ss-list > .ss-option', - text: Regexp.new(participant.slug)).click + # pick option by slug (keeps existing behaviour) but wait for it to appear + option = find('.ss-content > .ss-list > .ss-option', text: Regexp.new(Regexp.escape(participant.slug.to_s))) + option.click + end + + # Give the widget a moment to update (widget reflects selections visually) + # (Do not assert on the hidden