diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ac3bad19a..775899998 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,14 +4,16 @@ This repository contains the **Better Together Community Engine** (an isolated R ## Core Principles +- **Security first**: Run `bundle exec brakeman --quiet --no-pager` before generating code; fix high-confidence vulnerabilities - **Accessibility first** (WCAG AA/AAA): semantic HTML, ARIA roles, keyboard nav, proper contrast. - **Hotwire everywhere**: Turbo for navigation/updates; Stimulus controllers for interactivity. - **Keep controllers thin**; move business logic to POROs/service objects or concerns. - **Prefer explicit join models** over polymorphic associations when validation matters. -- **Avoid the term “STI”** in code/comments; use “single-table inheritance” or alternate designs. +- **Avoid the term "STI"** in code/comments; use "single-table inheritance" or alternate designs. - **Use `ENV.fetch`** rather than `ENV[]`. - **Always add policy/authorization checks** on links/buttons to controller actions. - **i18n & Mobility**: every user-facing string must be translatable; include missing keys. +- Provide translations for all available locales (e.g., en, es, fr) when adding new strings. ## Technology Stack @@ -34,9 +36,38 @@ This repository contains the **Better Together Community Engine** (an isolated R > Dev DB: PostgreSQL (not SQLite). Production: PostgreSQL. PostGIS enabled for geospatial needs. +## Documentation & Diagrams Policy + +- For any new functionality, routes, background jobs, or changes to models/associations: + - Update or add documentation under `docs/` describing the behavior and flows. + - Maintain Mermaid diagrams (`.mmd`) reflecting new or changed relationships and process flows. + - Regenerate PNGs from `.mmd` sources using `bin/render_diagrams`. +- Ensure PRs include docs/diagrams updates when applicable; missing updates should be treated as a review blocker. +- When modifying exchange (Joatu) features (Offers, Requests, Agreements, Notifications), keep both the process doc and flow diagram in sync. + ## Coding Guidelines +### Security Requirements +- **Run Brakeman before generating code**: `bundle exec brakeman --quiet --no-pager` +- **Fix high-confidence vulnerabilities immediately** - never ignore security warnings with "High" confidence +- **Review and address medium-confidence warnings** that are security-relevant +- **Safe coding practices when generating code:** + - **No unsafe reflection**: Never use `constantize`, `safe_constantize`, or `eval` on user input + - **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 + - **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 +- **For reflection-based features**: Create concerns with `included_in_models` class methods for safe dynamic class resolution +- **Post-generation security check**: Run `bundle exec brakeman --quiet --no-pager -c UnsafeReflection,SQL,CrossSiteScripting` after major code changes + +## Test Environment Setup +- Configure the host Platform in a before block for controller/request/feature tests. + - Create/set a Platform as host (with community) before requests. + - Toggle requires_invitation and provide invitation_code when needed. + - **Ruby/Rails** - 2-space indent, snake_case methods, Rails conventions - Service objects in `app/services/` @@ -48,6 +79,7 @@ This repository contains the **Better Together Community Engine** (an isolated R - Bootstrap utility classes; respect prefers-reduced-motion & other a11y prefs - Avoid inline JS; use Stimulus - External links in `.trix-content` get FA external-link icon unless internal/mailto/tel/pdf + - All user-facing copy must use t("...") and include keys across all locales (add to config/locales/en.yml, es.yml, fr.yml). - **Hotwire** - Use Turbo Streams for CRUD updates - Stimulus controllers in `app/javascript/controllers/` @@ -55,6 +87,7 @@ This repository contains the **Better Together Community Engine** (an isolated R - **Background Jobs** - Sidekiq jobs under appropriate queues (`:default`, `:mailers`, `:metrics`, etc.) - Idempotent job design; handle retries + - When generating emails/notifications, localize both subject and body for all locales. - **Search** - Update `as_indexed_json` to include translated/plain-text fields as needed - **Encryption & Privacy** @@ -62,10 +95,74 @@ This repository contains the **Better Together Community Engine** (an isolated R - Ensure blobs are encrypted at rest - **Testing** - RSpec (if present) or Minitest – follow existing test framework + - **Generate comprehensive test coverage for all changes**: Every modification must include RSpec tests covering the new functionality - All RSpec specs **must use FactoryBot factories** for model instances (do not use `Model.create` or `Model.new` directly in specs). - **A FactoryBot factory must exist for every model**. When generating a new model, also generate a factory for it. - **Factories must use the Faker gem** to provide realistic, varied test data for all attributes (e.g., names, emails, addresses, etc.). + - **Test all layers**: models, controllers, mailers, jobs, JavaScript/Stimulus controllers, and integration workflows - System tests for Turbo flows where possible + - **Session-based testing**: When working on existing code modifications, generate tests that cover all unstaged changes and related functionality + +## Test Generation Strategy + +### Mandatory Test Creation +When modifying existing code or adding new features, always generate RSpec tests that provide comprehensive coverage: + +1. **Model Tests**: + - Validations, associations, scopes, callbacks + - Instance methods, class methods, delegations + - Business logic and calculated attributes + - Security-related functionality (encryption, authorization) + +2. **Controller Tests**: + - All CRUD actions and custom endpoints + - Authorization policy checks (Pundit/equivalent) + - Parameter handling and strong params + - Response formats (HTML, JSON, Turbo Stream) + - Error handling and edge cases + +3. **Background Job Tests**: + - Job execution and success scenarios + - Retry logic and error handling + - Side effects and state changes + - Queue assignment and timing + +4. **Mailer Tests**: + - Email content and formatting + - Recipient handling and localization + - Attachment and delivery configurations + - Multi-locale support + +5. **JavaScript/Stimulus Tests**: + - Controller initialization and teardown + - User interaction handlers + - Form state management and dynamic updates + - Target and action mappings + +6. **Integration Tests**: + - Complete user workflows + - Cross-model interactions + - End-to-end feature functionality + - Authentication and authorization flows + +### Session-Specific Test Coverage +For this codebase, ensure tests cover all recent changes including: +- Enhanced LocatableLocation model with polymorphic associations +- Event model with notification callbacks and location integration +- Calendar and CalendarEntry associations +- Event notification system (EventReminderNotifier, EventUpdateNotifier) +- Background jobs for event reminders and scheduling +- EventMailer with localized content +- Dynamic location selector JavaScript controller +- Form enhancements with location type selection + +### Test Quality Standards +- Use descriptive test names that explain the expected behavior +- Follow AAA pattern (Arrange, Act, Assert) in test structure +- Mock external dependencies and network calls +- Test both success and failure scenarios +- Use shared examples for common behavior patterns +- Ensure tests are deterministic and can run independently ## Project Architecture Notes @@ -94,3 +191,13 @@ This repository contains the **Better Together Community Engine** (an isolated R --- _If you generate code that touches any of these areas, consult the relevant instruction file and follow it._ + +## Internationalization & Translation Normalization +- Use the `i18n-tasks` gem to: + - Normalize locale files (`i18n-tasks normalize`). + - Identify and add missing keys (`i18n-tasks missing`, `i18n-tasks add-missing`). + - Ensure all user-facing strings are present in all supported locales (en, fr, es, etc.). + - Add new keys in English first, then translate. + - Review translation health regularly (`i18n-tasks health`). +- All new/changed strings must be checked with `i18n-tasks` before merging. +- See `.github/instructions/i18n-mobility.instructions.md` for details. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..23a7127ec --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + +Describe the change and the motivation. + +## Checklist + +- [ ] Tests added/updated and passing (`bin/ci`). +- [ ] Lint and security checks (`rubocop`, `brakeman`, `bundler-audit`). +- [ ] Documentation updated under `docs/` describing new/changed functionality. +- [ ] Mermaid diagrams (`docs/*.mmd`) updated to reflect changes. +- [ ] Rendered PNGs regenerated with `bin/render_diagrams` and committed. +- [ ] For DB changes, included any needed backfills/dedupes and noted risks. + +## Screenshots / Diagrams + +If applicable, include screenshots or link to updated diagrams. + +## Notes + +Anything reviewers should be aware of (migration order, flags, feature toggles). + diff --git a/.github/workflows/diagrams.yml b/.github/workflows/diagrams.yml new file mode 100644 index 000000000..88328a7c7 --- /dev/null +++ b/.github/workflows/diagrams.yml @@ -0,0 +1,34 @@ +name: Diagrams Check + +on: + pull_request: + branches: [ "**" ] + +jobs: + render-and-verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Render Mermaid diagrams + run: | + chmod +x bin/render_diagrams || true + ./bin/render_diagrams || true + + - name: Verify rendered PNGs are up to date + run: | + CHANGED=$(git status --porcelain -- docs/*.png | wc -l) + if [ "$CHANGED" -gt 0 ]; then + echo "Diagrams PNGs are out of date. Please run bin/render_diagrams and commit the updated PNGs." >&2 + echo "Changed files:" >&2 + git status --porcelain -- docs/*.png >&2 || true + exit 1 + fi + shell: bash + diff --git a/.github/workflows/i18n-health.yml b/.github/workflows/i18n-health.yml new file mode 100644 index 000000000..0c4def198 --- /dev/null +++ b/.github/workflows/i18n-health.yml @@ -0,0 +1,48 @@ +name: i18n Translation Health Report + +on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev + +jobs: + i18n-health: + runs-on: ubuntu-latest + env: + # Ensure dev/test gems (incl. i18n-tasks) are installed + BUNDLE_WITHOUT: "" + BUNDLE_WITH: "development:test" + if: github.event_name == 'push' || github.event_name == 'pull_request' + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + env: + # Ensure bundler installs dev/test groups during caching step + BUNDLE_WITHOUT: "" + BUNDLE_WITH: "development:test" + with: + ruby-version: '3.4.4' + bundler-cache: true + - name: Verify i18n-tasks + run: bundle exec i18n-tasks --version + - name: Normalize locale files + run: bin/i18n normalize + - name: Run i18n checks + run: bin/i18n check + - name: Upload i18n health report + if: always() + run: bin/i18n health > i18n-health.txt + continue-on-error: true + - name: Archive i18n health report + uses: actions/upload-artifact@v4 + with: + name: i18n-health-report + path: i18n-health.txt + continue-on-error: true diff --git a/.github/workflows/rubyonrails.yml b/.github/workflows/rubyonrails.yml index af93ab012..83c10bf57 100644 --- a/.github/workflows/rubyonrails.yml +++ b/.github/workflows/rubyonrails.yml @@ -87,6 +87,7 @@ jobs: echo "Waiting for Elasticsearch to be healthy..." curl -s "http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=60s" || (echo "Elasticsearch not healthy" && exit 1) + - name: Run RSpec if: (matrix.rails == '7.1.5.2') || steps.update.outcome == 'success' env: @@ -96,6 +97,14 @@ jobs: bundle exec rspec continue-on-error: ${{ matrix.allowed_failure }} + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report-ruby-${{ matrix.ruby }}-rails-${{ matrix.rails }} + path: | + coverage/ + - name: Generate coverage badge if: ${{ github.ref == 'refs/heads/main' && success() }} continue-on-error: true diff --git a/AGENTS.md b/AGENTS.md index 335940d8d..3fbc88564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,11 +21,124 @@ Instructions for GitHub Copilot and other automated contributors working in this - **Tests:** `bin/ci` (Equivalent: `cd spec/dummy && bundle exec rspec`) - **Lint:** `bundle exec rubocop` -- **Security:** `bundle exec brakeman -q -w2` and `bundle exec bundler-audit --update` +- **Security:** `bundle exec brakeman --quiet --no-pager` and `bundle exec bundler-audit --update` - **Style:** `bin/codex_style_guard` +- **I18n:** `bin/i18n [normalize|check|health|all]` (runs normalize + missing + interpolation checks by default) + +## Security Requirements +## Security Requirements +- **Run Brakeman before generating code**: `bundle exec brakeman --quiet --no-pager` +- **Fix high-confidence vulnerabilities immediately** - never ignore security warnings with "High" confidence +- **Review and address medium-confidence warnings** that are security-relevant +- **Safe coding practices when generating code:** + - Never use `constantize`, `safe_constantize`, or `eval` on user input + - Use allow-lists for dynamic class resolution (see `joatu_source_class` pattern) + - Sanitize and validate all user inputs + - Use strong parameters in controllers + - 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 `bundle exec brakeman --quiet --no-pager -c UnsafeReflection,SQL,CrossSiteScripting` after major code changes ## Conventions - Make incremental changes with passing tests. +- **Security first**: Run `bundle exec brakeman --quiet --no-pager` before committing code changes. +- **Test every change**: Generate RSpec tests for all code modifications, including models, controllers, mailers, jobs, and JavaScript. +- **Test coverage requirements**: All new features, bug fixes, and refactors must include comprehensive test coverage. - Avoid introducing new external services in tests; stub where possible. - If RuboCop reports offenses after autocorrect, update and rerun until clean. - Keep commit messages and PR descriptions concise and informative. + +## Documentation & Diagrams +- Always update documentation when adding new functionality or changing data relationships. + - For new features or flows: add/update a process doc under `docs/` that explains intent, actors, states, and key branch points. + - For model/association changes: update Mermaid diagrams (e.g., `docs/*_diagram.mmd` or add a new one alongside related docs). +- Keep diagrams in Mermaid (`.mmd`) and render PNGs for convenience. + - Preferred: run `bin/render_diagrams` to regenerate images for all `docs/*.mmd` files. + - Fallback: `npx -y @mermaid-js/mermaid-cli -i docs/your_diagram.mmd -o docs/your_diagram.png`. +- PRs that add/modify models, associations, or flows must include corresponding docs and diagrams. +- When notifications, policies, or routes change, ensure affected docs and diagrams are updated to match behavior. + +## Platform Registration Mode +- Invitation-required: Platforms support `requires_invitation` (see `BetterTogether::Platform#settings`). When enabled, users must supply a valid invitation code to register. This is the default for hosted deployments. +- Where to change: Host Dashboard → Platforms → Edit → “Requires Invitation”. +- Effects: + - Devise registration page prompts for an invitation code when none is present. + - Accepted invitations prefill email, apply community/platform roles, and are marked accepted on successful sign‑up. + +## Privacy Practices for Hosts (Admin Ops) +- Default posture: keep `requires_invitation` enabled unless there is a clear, consented need to open registration. +- Privacy policy: publish and maintain a platform‑specific privacy policy; disclose any third‑party trackers (e.g., GA, Sentry) and their purposes. +- Consent/cookies: add a cookie/consent banner before enabling third‑party trackers; anonymize IPs; disable ad personalization; respect regional requirements. +- Data minimization: + - Avoid placing PII in URLs, block identifiers, or public content. + - Do not add user identifiers to metrics — the engine’s built‑in metrics are event‑only by design. +- Retention & deletion: + - Define retention periods for metrics and exports (e.g., 90 days for CSV exports; 180 days for raw events). + - Regularly purge report files (Active Storage) and delete old metrics in batches. + - Honor data deletion requests: remove user content and related exports; avoid exporting PII. +- Environments: do not copy production data to development/staging; use seeded, synthetic content for testing. + +## Translations & Locales +- All user‑facing text must use I18n — do not hard‑code strings in views, controllers, models, or JS. +- When adding new text, add translation keys for all available locales in this repo (e.g., `config/locales/en.yml`, `es.yml`, `fr.yml`). +- Include translations for: + - Flash messages, validation errors, button/label text, email subjects/bodies, and Action Cable payloads. + - Any UI strings rendered from background jobs or notifiers. +- Prefer existing keys where possible; group new keys under appropriate namespaces. +- If a locale is missing a translation at review time, translate the English copy rather than leaving it undefined. + +# Translation Normalization & Coverage + +We use the `i18n-tasks` gem to ensure all translation keys are present, normalized, and up-to-date across all supported locales (en, fr, es, etc.). + +## Workflow +- Run `i18n-tasks normalize` to sort and format locale files. +- Run `i18n-tasks missing` to identify missing keys and add them in English first. +- Use `i18n-tasks add-missing` to auto-populate missing keys with English values, then translate as needed. +- Review and improve translation quality regularly. +- All new user-facing strings must be added to locale files and checked with `i18n-tasks` before merging. + +## Example Commands +```bash +i18n-tasks normalize +i18n-tasks missing +i18n-tasks add-missing +i18n-tasks health +``` + +## CI Note +- The i18n GitHub Action installs dev/test gem groups to make `i18n-tasks` available. Locally, you can mirror CI with `bin/i18n`, which sets `BUNDLE_WITH=development:test` automatically. + +See `.github/instructions/i18n-mobility.instructions.md` for additional translation rules. + +# Testing Requirements + +## Mandatory Test Generation +- **Every code change must include RSpec tests** covering the new or modified functionality. +- **Generate factories for new models** using FactoryBot with realistic Faker-generated test data. +- **Test all layers**: models (validations, associations, methods), controllers (actions, authorization), services, mailers, jobs, and view components. +- **JavaScript/Stimulus testing**: Include feature specs that exercise dynamic behaviors like form interactions and AJAX updates. + +## 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. +- **Mailers**: Test email content, recipients, localization, and delivery configurations. +- **Jobs**: Test job execution, retry behavior, error handling, and side effects. +- **JavaScript**: Test Stimulus controller behavior, form interactions, and dynamic content updates. +- **Integration**: Test complete user workflows and cross-model interactions. + +## Session Coverage Requirements +When making changes to existing code, generate tests that cover: +- All modified models and their new/changed methods, associations, and validations +- Any new background jobs, mailers, and notification systems +- Controller actions that handle the new functionality +- JavaScript controllers and dynamic form behaviors +- Integration tests for complete user workflows +- Edge cases and error conditions + +## Test Organization +- Follow the existing RSpec structure and naming conventions. +- Use FactoryBot factories instead of direct model creation. +- Group related tests with descriptive context blocks. +- Use shared examples for common behavior patterns. +- Mock external dependencies and network calls. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..25bd4729b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing + +Thank you for contributing to Better Together Community Engine! + +## Expectations for Changes + +- Tests: Keep tests green (`bin/ci`). Add/adjust specs for new behavior. +- Lint & Security: Run `bundle exec rubocop`, `bundle exec brakeman -q -w2`, and `bundle exec bundler-audit --update`. +- Documentation & Diagrams: + - Update or add docs under `docs/` for any new functionality, routes, background jobs, or changes to models/associations. + - Maintain Mermaid diagrams (`docs/*.mmd`) that reflect updated relationships and process flows. + - Render PNGs from `.mmd` using `bin/render_diagrams` and commit the outputs. + - PRs that change models, associations, or flows must include docs/diagram updates. +- Accessibility, i18n, and policies: Follow AGENTS.md and `.github/instructions/*` guides. + +## Development + +- Use the setup in README/AGENTS.md (Docker or local). +- Test app lives in `spec/dummy`. +- For exchange (Joatu) features, see `docs/joatu/*` and `docs/exchange_process.md`. + +## Pull Requests + +- Keep PRs focused and small where possible. +- Include a brief description of why, how, and impact. +- If adding migrations: note any data backfills or dedupe steps. +- If modifying notifications: describe dedupe/throttling strategy. + +## Questions + +Open a discussion or issue if you’re unsure about direction or scope. diff --git a/README.md b/README.md index 0163d7f32..efa21dfb9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Better Together Community Engine +[➡️ View the Documentation Index](docs/README.md) + ## Overview The Better Together Community Engine is a transformative platform designed to unite communities through the power of collaboration and shared resources. Our core intention is to provide an inclusive, accessible space where individuals and groups from diverse backgrounds can come together to share knowledge, engage in meaningful dialogue, and develop innovative solutions to common challenges. By leveraging the collective wisdom and experience of its members, the platform aims to foster a culture of mutual support, learning, and sustainable growth. @@ -10,6 +12,13 @@ This project embodies our vision of a world where collaboration leads to greater This project is the core community building portion of the Better Together platform. +## Documentation + +For system overviews, flows, and diagrams, see the docs index: + +- docs: docs/README.md +- Exchange (Joatu), Notifications, Models & Concerns, and more with Mermaid diagrams (PNG rendered). + ## Dependencies In addition to other dependencies, the Better Together Community Engine relies on Action Text and Action Storage, which are part of the Rails framework. These dependencies are essential for handling rich text content and file storage within the platform. diff --git a/app/controllers/better_together/calendars_controller.rb b/app/controllers/better_together/calendars_controller.rb index 539b23a51..6b1c0a31e 100644 --- a/app/controllers/better_together/calendars_controller.rb +++ b/app/controllers/better_together/calendars_controller.rb @@ -4,6 +4,12 @@ module BetterTogether # CRUD for calendars class CalendarsController < FriendlyResourceController # GET /better_together/calendars + def show + @calendar = set_resource_instance + authorize @calendar + @upcoming_events = @calendar.events.upcoming.order(:starts_at) + @past_events = @calendar.events.past.order(starts_at: :desc) + end # GET /better_together/calendars/new def new @@ -36,7 +42,9 @@ def edit; end # DELETE /better_together/calendars/1 def destroy @calendar.destroy! - redirect_to better_together_calendars_url, notice: 'Calendar was successfully destroyed.', status: :see_other + redirect_to better_together_calendars_url, + notice: t('flash.generic.destroyed', resource: t('resources.calendar')), + status: :see_other end private diff --git a/app/controllers/better_together/communities_controller.rb b/app/controllers/better_together/communities_controller.rb index 0c26e66c8..4e5ec9926 100644 --- a/app/controllers/better_together/communities_controller.rb +++ b/app/controllers/better_together/communities_controller.rb @@ -78,7 +78,8 @@ def update # rubocop:todo Metrics/AbcSize, Metrics/MethodLength # DELETE /communities/1 def destroy @community.destroy - redirect_to communities_url, notice: 'Community was successfully destroyed.', status: :see_other + redirect_to communities_url, notice: t('flash.generic.destroyed', resource: t('resources.community')), + status: :see_other end private diff --git a/app/controllers/better_together/content/blocks_controller.rb b/app/controllers/better_together/content/blocks_controller.rb index 003086f8a..3ab90a193 100644 --- a/app/controllers/better_together/content/blocks_controller.rb +++ b/app/controllers/better_together/content/blocks_controller.rb @@ -20,16 +20,18 @@ def create @block = resource_class.new(block_params) if @block.save - redirect_to content_block_path(@block), notice: 'Block was successfully created.' + redirect_to content_block_path(@block), + notice: t('flash.generic.created', resource: t('resources.block')) else render :new end end - def update + def update # rubocop:todo Metrics/MethodLength respond_to do |format| if @block.update(block_params) - redirect_to edit_content_block_path(@block), notice: 'Block was successfully updated.' + redirect_to edit_content_block_path(@block), + notice: t('flash.generic.updated', resource: t('resources.block')) else format.turbo_stream do render turbo_stream: turbo_stream.replace(helpers.dom_id(@block, 'form'), partial: 'form', @@ -48,7 +50,7 @@ def new def destroy @block.destroy unless @block.pages.any? - redirect_to content_blocks_path, notice: 'Block was sucessfully deleted' + redirect_to content_blocks_path, notice: t('flash.generic.destroyed', resource: t('resources.block')) end private diff --git a/app/controllers/better_together/conversations_controller.rb b/app/controllers/better_together/conversations_controller.rb index ad436ff63..d6d48d17f 100644 --- a/app/controllers/better_together/conversations_controller.rb +++ b/app/controllers/better_together/conversations_controller.rb @@ -3,6 +3,8 @@ module BetterTogether # Handles managing conversations class ConversationsController < ApplicationController # rubocop:todo Metrics/ClassLength + include BetterTogether::NotificationReadable + before_action :authenticate_user! before_action :disallow_robots before_action :set_conversations, only: %i[index new show] @@ -103,11 +105,8 @@ def show # rubocop:todo Metrics/MethodLength, Metrics/AbcSize @message = @conversation.messages.build if @messages.any? - # Move this to separate action/bg process only activated when the messages are actually read. - events = BetterTogether::NewMessageNotifier.where(record_id: @messages.pluck(:id)).select(:id) - - notifications = helpers.current_person.notifications.unread.where(event_id: events.pluck(:id)) - notifications.update_all(read_at: Time.current) + mark_notifications_read_for_event_records(BetterTogether::NewMessageNotifier, @messages.pluck(:id), + recipient: helpers.current_person) end respond_to do |format| diff --git a/app/controllers/better_together/events_controller.rb b/app/controllers/better_together/events_controller.rb index 098641135..16a9cc834 100644 --- a/app/controllers/better_together/events_controller.rb +++ b/app/controllers/better_together/events_controller.rb @@ -16,6 +16,33 @@ def index @past_events = @events.past end + def show + super + end + + def ics + send_data @event.to_ics, + filename: "#{@event.slug}.ics", + type: 'text/calendar; charset=UTF-8' + end + + # RSVP actions + def rsvp_interested + rsvp_update('interested') + end + + def rsvp_going + rsvp_update('going') + end + + def rsvp_cancel + @event = set_resource_instance + authorize @event, :show? + attendance = BetterTogether::EventAttendance.find_by(event: @event, person: helpers.current_person) + attendance&.destroy + redirect_to @event, notice: t('better_together.events.rsvp_cancelled', default: 'RSVP cancelled') + end + protected def build_event_hosts # rubocop:disable Metrics/AbcSize @@ -44,5 +71,20 @@ def event_host_class def resource_class ::BetterTogether::Event end + + private + + def rsvp_update(status) + @event = set_resource_instance + authorize @event, :show? + attendance = BetterTogether::EventAttendance.find_or_initialize_by(event: @event, person: helpers.current_person) + attendance.status = status + authorize attendance + if attendance.save + redirect_to @event, notice: t('better_together.events.rsvp_saved', default: 'RSVP saved') + else + redirect_to @event, alert: attendance.errors.full_messages.to_sentence + end + end end end diff --git a/app/controllers/better_together/geography/continents_controller.rb b/app/controllers/better_together/geography/continents_controller.rb index 8e2af4831..c527d8ed2 100644 --- a/app/controllers/better_together/geography/continents_controller.rb +++ b/app/controllers/better_together/geography/continents_controller.rb @@ -26,7 +26,7 @@ def create # rubocop:todo Metrics/MethodLength @geography_continent = Geography::Continent.new(geography_continent_params) if @geography_continent.save - redirect_to @geography_continent, notice: 'Continent was successfully created.' + redirect_to @geography_continent, notice: t('flash.generic.created', resource: t('resources.continent')) else respond_to do |format| format.turbo_stream do @@ -44,7 +44,8 @@ def create # rubocop:todo Metrics/MethodLength # PATCH/PUT /geography/continents/1 def update # rubocop:todo Metrics/MethodLength if @geography_continent.update(geography_continent_params) - redirect_to @geography_continent, notice: 'Continent was successfully updated.', status: :see_other + redirect_to @geography_continent, notice: t('flash.generic.updated', resource: t('resources.continent')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -62,7 +63,8 @@ def update # rubocop:todo Metrics/MethodLength # DELETE /geography/continents/1 def destroy @geography_continent.destroy - redirect_to geography_continents_url, notice: 'Continent was successfully destroyed.', status: :see_other + redirect_to geography_continents_url, notice: t('flash.generic.destroyed', resource: t('resources.continent')), + status: :see_other end private diff --git a/app/controllers/better_together/geography/countries_controller.rb b/app/controllers/better_together/geography/countries_controller.rb index c6d04fe3a..39529bef0 100644 --- a/app/controllers/better_together/geography/countries_controller.rb +++ b/app/controllers/better_together/geography/countries_controller.rb @@ -33,7 +33,8 @@ def create # rubocop:todo Metrics/MethodLength authorize_geography_country if @geography_country.save - redirect_to @geography_country, notice: 'Country was successfully created.', status: :see_other + redirect_to @geography_country, notice: t('flash.generic.created', resource: t('resources.country')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -51,7 +52,8 @@ def create # rubocop:todo Metrics/MethodLength # PATCH/PUT /geography/countries/1 def update # rubocop:todo Metrics/MethodLength if @geography_country.update(geography_country_params) - redirect_to @geography_country, notice: 'Country was successfully updated.', status: :see_other + redirect_to @geography_country, notice: t('flash.generic.updated', resource: t('resources.country')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -69,7 +71,8 @@ def update # rubocop:todo Metrics/MethodLength # DELETE /geography/countries/1 def destroy @geography_country.destroy - redirect_to geography_countries_url, notice: 'Country was successfully destroyed.', status: :see_other + redirect_to geography_countries_url, notice: t('flash.generic.destroyed', resource: t('resources.country')), + status: :see_other end private diff --git a/app/controllers/better_together/geography/region_settlements_controller.rb b/app/controllers/better_together/geography/region_settlements_controller.rb index f6102e713..0f48ce9d5 100644 --- a/app/controllers/better_together/geography/region_settlements_controller.rb +++ b/app/controllers/better_together/geography/region_settlements_controller.rb @@ -26,7 +26,8 @@ def create # rubocop:todo Metrics/MethodLength @geography_region_settlement = Geography::RegionSettlement.new(geography_region_settlement_params) if @geography_region_settlement.save - redirect_to @geography_region_settlement, notice: 'Region settlement was successfully created.' + redirect_to @geography_region_settlement, + notice: t('flash.generic.created', resource: t('resources.region_settlement')) else respond_to do |format| format.turbo_stream do @@ -44,8 +45,9 @@ def create # rubocop:todo Metrics/MethodLength # PATCH/PUT /geography/region_settlements/1 def update # rubocop:todo Metrics/MethodLength if @geography_region_settlement.update(geography_region_settlement_params) - redirect_to @geography_region_settlement, notice: 'Region settlement was successfully updated.', - status: :see_other + redirect_to @geography_region_settlement, + notice: t('flash.generic.updated', resource: t('resources.region_settlement')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -63,8 +65,9 @@ def update # rubocop:todo Metrics/MethodLength # DELETE /geography/region_settlements/1 def destroy @geography_region_settlement.destroy - redirect_to geography_region_settlements_url, notice: 'Region settlement was successfully destroyed.', - status: :see_other + redirect_to geography_region_settlements_url, + notice: t('flash.generic.destroyed', resource: t('resources.region_settlement')), + status: :see_other end private diff --git a/app/controllers/better_together/geography/regions_controller.rb b/app/controllers/better_together/geography/regions_controller.rb index 1ba7a12c8..f7673ce0c 100644 --- a/app/controllers/better_together/geography/regions_controller.rb +++ b/app/controllers/better_together/geography/regions_controller.rb @@ -33,7 +33,7 @@ def create # rubocop:todo Metrics/MethodLength authorize_geography_region if @geography_region.save - redirect_to @geography_region, notice: 'Region was successfully created.' + redirect_to @geography_region, notice: t('flash.generic.created', resource: t('resources.region')) else respond_to do |format| format.turbo_stream do @@ -51,7 +51,8 @@ def create # rubocop:todo Metrics/MethodLength # PATCH/PUT /geography/regions/1 def update # rubocop:todo Metrics/MethodLength if @geography_region.update(geography_region_params) - redirect_to @geography_region, notice: 'Region was successfully updated.', status: :see_other + redirect_to @geography_region, notice: t('flash.generic.updated', resource: t('resources.region')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -69,7 +70,8 @@ def update # rubocop:todo Metrics/MethodLength # DELETE /geography/regions/1 def destroy @geography_region.destroy - redirect_to geography_regions_url, notice: 'Region was successfully destroyed.', status: :see_other + redirect_to geography_regions_url, notice: t('flash.generic.destroyed', resource: t('resources.region')), + status: :see_other end private diff --git a/app/controllers/better_together/geography/settlements_controller.rb b/app/controllers/better_together/geography/settlements_controller.rb index 46938ae5c..66d7024ae 100644 --- a/app/controllers/better_together/geography/settlements_controller.rb +++ b/app/controllers/better_together/geography/settlements_controller.rb @@ -4,7 +4,8 @@ module BetterTogether module Geography class SettlementsController < FriendlyResourceController # rubocop:todo Style/Documentation before_action :set_geography_settlement, only: %i[show edit update destroy] - before_action :authorize_geography_settlement, only: %i[show edit update destroy] + before_action :authorize_geography_settlement, + only: %i[show edit update destroy] after_action :verify_authorized, except: :index # GET /geography/settlements @@ -33,7 +34,7 @@ def create # rubocop:todo Metrics/MethodLength authorize_geography_settlement if @geography_settlement.save - redirect_to @geography_settlement, notice: 'Settlement was successfully created.' + redirect_to @geography_settlement, notice: t('flash.generic.created', resource: t('resources.settlement')) else respond_to do |format| format.turbo_stream do @@ -51,7 +52,8 @@ def create # rubocop:todo Metrics/MethodLength # PATCH/PUT /geography/settlements/1 def update # rubocop:todo Metrics/MethodLength if @geography_settlement.update(geography_settlement_params) - redirect_to @geography_settlement, notice: 'Settlement was successfully updated.', status: :see_other + redirect_to @geography_settlement, notice: t('flash.generic.updated', resource: t('resources.settlement')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -69,7 +71,8 @@ def update # rubocop:todo Metrics/MethodLength # DELETE /geography/settlements/1 def destroy @geography_settlement.destroy - redirect_to geography_settlements_url, notice: 'Settlement was successfully destroyed.', status: :see_other + redirect_to geography_settlements_url, notice: t('flash.generic.destroyed', resource: t('resources.settlement')), # rubocop:disable Layout/LineLength + status: :see_other end private diff --git a/app/controllers/better_together/geography/states_controller.rb b/app/controllers/better_together/geography/states_controller.rb index f83965e7f..737edcea2 100644 --- a/app/controllers/better_together/geography/states_controller.rb +++ b/app/controllers/better_together/geography/states_controller.rb @@ -31,7 +31,8 @@ def create # rubocop:todo Metrics/MethodLength authorize_geography_state if @geography_state.save - redirect_to @geography_state, notice: 'State was successfully created.', status: :see_other + redirect_to @geography_state, notice: t('flash.generic.created', resource: t('resources.state')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -49,7 +50,8 @@ def create # rubocop:todo Metrics/MethodLength # PATCH/PUT /geography/states/1 def update # rubocop:todo Metrics/MethodLength if @geography_state.update(geography_state_params) - redirect_to @geography_state, notice: 'State was successfully updated.', status: :see_other + redirect_to @geography_state, notice: t('flash.generic.updated', resource: t('resources.state')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -67,7 +69,8 @@ def update # rubocop:todo Metrics/MethodLength # DELETE /geography/states/1 def destroy @geography_state.destroy - redirect_to geography_states_url, notice: 'State was successfully destroyed.', status: :see_other + redirect_to geography_states_url, notice: t('flash.generic.destroyed', resource: t('resources.state')), + status: :see_other end private diff --git a/app/controllers/better_together/joatu/agreements_controller.rb b/app/controllers/better_together/joatu/agreements_controller.rb index cdd9f3b9b..e09fa2fce 100644 --- a/app/controllers/better_together/joatu/agreements_controller.rb +++ b/app/controllers/better_together/joatu/agreements_controller.rb @@ -68,8 +68,14 @@ def show def accept @joatu_agreement = set_resource_instance authorize @joatu_agreement - @joatu_agreement.accept! - redirect_to joatu_agreement_path(@joatu_agreement), notice: 'Agreement accepted' + begin + @joatu_agreement.accept! + redirect_to joatu_agreement_path(@joatu_agreement), + notice: t('flash.joatu.agreement.accepted') + rescue ActiveRecord::RecordInvalid => e + redirect_to joatu_agreement_path(@joatu_agreement), + alert: e.record.errors.full_messages.to_sentence.presence || 'Unable to accept agreement' + end end # POST /joatu/agreements/:id/reject @@ -77,7 +83,8 @@ def reject @joatu_agreement = set_resource_instance authorize @joatu_agreement @joatu_agreement.reject! - redirect_to joatu_agreement_path(@joatu_agreement), notice: 'Agreement rejected' + redirect_to joatu_agreement_path(@joatu_agreement), + notice: t('flash.joatu.agreement.rejected') end private diff --git a/app/controllers/better_together/joatu/joatu_controller.rb b/app/controllers/better_together/joatu/joatu_controller.rb index 9c0469708..c74c37e66 100644 --- a/app/controllers/better_together/joatu/joatu_controller.rb +++ b/app/controllers/better_together/joatu/joatu_controller.rb @@ -4,6 +4,8 @@ module BetterTogether module Joatu # Base controller for Joatu resources, adds notification mark-as-read helpers class JoatuController < BetterTogether::FriendlyResourceController + include BetterTogether::NotificationReadable + # Normalize translated params so base keys are populated for current locale. # This helps presence validations (esp. for ActionText) during create/update # when forms submit locale-suffixed fields like `description_en`. @@ -24,36 +26,17 @@ def resource_params # rubocop:todo Metrics/CyclomaticComplexity, Metrics/MethodL rp end - protected + private - # Mark Noticed notifications as read for a specific record-based event - def mark_notifications_read_for_record(record) - return unless helpers.current_person && record.respond_to?(:id) + # Safely resolve a source_type parameter to a valid Joatu model class + # Allow-list only classes that include the Exchange concern to prevent security issues + def joatu_source_class(source_type_param) + param_type = source_type_param.to_s - helpers.current_person.notifications.unread - .includes(:event) - .references(:event) - .where(event: { record_id: record.id }) - .update_all(read_at: Time.current) - end + # Dynamically build allow-list from models that include the Exchange concern + valid_source_types = BetterTogether::Joatu::Exchange.included_in_models - # Mark Joatu match notifications as read when viewing an offer or request - def mark_match_notifications_read_for(record) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity - return unless helpers.current_person && record.respond_to?(:id) - - helpers.current_person.notifications.unread.includes(:event).find_each do |notification| - event = notification.event - next unless event.is_a?(BetterTogether::Joatu::MatchNotifier) - - begin - ids = [] - ids << event.offer&.id if event.respond_to?(:offer) - ids << event.request&.id if event.respond_to?(:request) - notification.update(read_at: Time.current) if ids.compact.include?(record.id) - rescue StandardError - next - end - end + valid_source_types.find { |klass| klass.to_s == param_type } end end end diff --git a/app/controllers/better_together/joatu/offers_controller.rb b/app/controllers/better_together/joatu/offers_controller.rb index 4d0b7f21b..b891867e9 100644 --- a/app/controllers/better_together/joatu/offers_controller.rb +++ b/app/controllers/better_together/joatu/offers_controller.rb @@ -3,7 +3,7 @@ module BetterTogether module Joatu # CRUD for BetterTogether::Joatu::Offer - class OffersController < JoatuController + class OffersController < JoatuController # rubocop:todo Metrics/ClassLength def show super mark_match_notifications_read_for(resource_instance) @@ -13,11 +13,14 @@ def show # rubocop:todo Metrics/MethodLength # rubocop:todo Metrics/AbcSize def index # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + # Eager-load translated attributes (string/text/rich text) and the + # creator's string translations to avoid N+1 lookups when rendering + # lists and previews. @joatu_offers = BetterTogether::Joatu::SearchFilter.call( resource_class:, relation: resource_collection, params: params - ).includes(:categories, :creator) + ).with_translations.includes(categories: :string_translations, creator: %i[string_translations profile_image_attachment profile_image_blob]) # rubocop:disable Layout/LineLength # Build options for the filter form @category_options = BetterTogether::Joatu::CategoryOptions.call @@ -54,6 +57,8 @@ def index # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/ @offer_match_request_map = request_offer_map @aggregated_request_matches = if request_ids.any? BetterTogether::Joatu::Request.where(id: request_ids.uniq) + .with_translations + .includes(categories: :string_translations, creator: %i[string_translations profile_image_attachment profile_image_blob]) # rubocop:disable Layout/LineLength else BetterTogether::Joatu::Request.none end @@ -66,12 +71,151 @@ def index # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/ # rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/PerceivedComplexity + def respond_with_request + source = set_resource_instance + authorize_resource + redirect_to new_joatu_request_path(source_type: resource_class.to_s, source_id: source.id) + end + + # Render new with optional prefill from a source Request/Offer + def new # rubocop:todo Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity + resource_instance + # If source params were provided, load and authorize the source so the view can safely render it + if (source_type = params[:source_type].presence) && (source_id = params[:source_id].presence) + source_klass = joatu_source_class(source_type) + return unless source_klass + + @source = source_klass&.with_translations&.includes(:categories, :address, creator: :string_translations)&.find_by(id: source_id) # rubocop:disable Layout/LineLength,Style/SafeNavigationChainLength + begin + authorize @source if @source + rescue Pundit::NotAuthorizedError + render_not_found and return + end + + # Only allow responding to sources that are open or already matched + if @source.respond_to?(:status) && !%w[open matched].include?(@source.status) + redirect_to url_for(@source.becomes(@source.class)), + alert: 'Cannot create a response for a source that is not open or matched.' and return + end + + end + + apply_source_prefill_offer(resource_instance) + end + + # rubocop:todo Metrics/PerceivedComplexity + # rubocop:todo Metrics/MethodLength + # rubocop:todo Metrics/AbcSize + def create # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + resource_instance(resource_params) + authorize_resource + + respond_to do |format| # rubocop:todo Metrics/BlockLength + if @resource.save + # Controller-side fallback: if source params were provided but no nested response_link was created, + # create a ResponseLink linking the source Request -> this Offer. + source_type = params[:source_type] || params.dig(resource_name, :source_type) + source_id = params[:source_id] || params.dig(resource_name, :source_id) + + if source_type == 'BetterTogether::Joatu::Request' && source_id.present? + source = BetterTogether::Joatu::Request.find_by(id: source_id) + if source + # rubocop:todo Metrics/BlockNesting + if source.respond_to?(:status) && !%w[open matched].include?(source.status) + Rails.logger.warn( + "Not creating response link: source #{source.id} status #{source.status} not respondable" + ) + elsif !BetterTogether::Joatu::ResponseLink.exists?(source: source, response: @resource) + BetterTogether::Joatu::ResponseLink.create(source: source, response: @resource, + creator_id: helpers.current_person&.id) + end + # rubocop:enable Metrics/BlockNesting + end + end + + format.html do + redirect_to url_for(@resource.becomes(resource_class)), + notice: "#{resource_class.model_name.human} was successfully created." + end + format.turbo_stream do + flash.now[:notice] = "#{resource_class.model_name.human} was successfully created." + redirect_to url_for(@resource.becomes(resource_class)) + end + else + format.turbo_stream do + render status: :unprocessable_entity, turbo_stream: [ + turbo_stream.replace(helpers.dom_id(@resource, 'form'), + partial: 'form', + locals: { resource_name.to_sym => @resource }), + turbo_stream.update('form_errors', + partial: 'layouts/better_together/errors', + locals: { object: @resource }) + ] + end + format.html { render :new, status: :unprocessable_entity } + end + end + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity + + private + + # Build an offer prefilled from a source Request when source params are present + # rubocop:todo Metrics/PerceivedComplexity + # rubocop:todo Metrics/MethodLength + # rubocop:todo Metrics/AbcSize + def apply_source_prefill_offer(offer) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + return unless offer + + source_type = params[:source_type] || params.dig(resource_name, :source_type) + source_id = params[:source_id] || params.dig(resource_name, :source_id) + + return unless source_type == 'BetterTogether::Joatu::Request' && source_id.present? + + source = BetterTogether::Joatu::Request.with_translations.includes(:categories, :address, creator: :string_translations).find_by(id: source_id) # rubocop:disable Layout/LineLength + return unless source + # Do not build nested response_link if source is not respondable + return unless source.respond_to?(:status) ? %w[open matched].include?(source.status) : true + + offer.name ||= source.name + offer.description ||= source.description + offer.target_type ||= source.target_type if source.respond_to?(:target_type) + offer.target_id ||= source.target_id if source.respond_to?(:target_id) + offer.urgency ||= source.urgency if source.respond_to?(:urgency) + offer.address || offer.build_address + if source.respond_to?(:categories) && offer.category_ids.blank? + offer.category_ids = source.categories.pluck(:id) + end + + return unless offer.response_links_as_response.blank? + + offer.response_links_as_response.build(source_type: source.class.to_s, source_id: source.id, + creator_id: helpers.current_person&.id) + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity + protected def resource_class ::BetterTogether::Joatu::Offer end + # Override the base resource collection to eager-load translations and + # commonly accessed associations (categories, address) and the + # creator's string translations to avoid N+1 queries in views. + def resource_collection + @resources ||= policy_scope(resource_class.with_translations) + .includes(:address, + { categories: :string_translations }, + { creator: %i[string_translations profile_image_attachment profile_image_blob] }) + + instance_variable_set("@#{resource_name(plural: true)}", @resources) + end + def resource_params rp = super rp[:creator_id] ||= helpers.current_person&.id diff --git a/app/controllers/better_together/joatu/requests_controller.rb b/app/controllers/better_together/joatu/requests_controller.rb index d95063fcb..17e62e512 100644 --- a/app/controllers/better_together/joatu/requests_controller.rb +++ b/app/controllers/better_together/joatu/requests_controller.rb @@ -3,7 +3,7 @@ module BetterTogether module Joatu # CRUD for BetterTogether::Joatu::Request - class RequestsController < JoatuController + class RequestsController < JoatuController # rubocop:todo Metrics/ClassLength def show super mark_match_notifications_read_for(resource_instance) @@ -13,11 +13,13 @@ def show # rubocop:todo Metrics/MethodLength # rubocop:todo Metrics/AbcSize def index # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + # Eager-load translated attributes and creator string translations @joatu_requests = BetterTogether::Joatu::SearchFilter.call( resource_class:, relation: resource_collection, params: params - ).includes(:categories, :creator) + ).with_translations.includes(categories: :string_translations, creator: %i[string_translations + profile_image_attachment profile_image_blob]) # rubocop:disable Layout/LineLength # Build options for the filter form @category_options = BetterTogether::Joatu::CategoryOptions.call @@ -54,6 +56,8 @@ def index # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/ @request_match_offer_map = offer_request_map @aggregated_offer_matches = if offer_ids.any? BetterTogether::Joatu::Offer.where(id: offer_ids.uniq) + .with_translations + .includes(categories: :string_translations, creator: %i[string_translations profile_image_attachment profile_image_blob]) # rubocop:disable Layout/LineLength else BetterTogether::Joatu::Offer.none end @@ -62,9 +66,7 @@ def index # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/ @aggregated_offer_matches = BetterTogether::Joatu::Offer.none end end - # rubocop:enable Metrics/AbcSize - # rubocop:enable Metrics/MethodLength - # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity # GET /joatu/requests/:id/matches def matches @@ -73,12 +75,93 @@ def matches @matches = BetterTogether::Joatu::Matchmaker.match(@joatu_request) end - protected + # Redirect to new offer form prefilled from a source Request + def respond_with_offer + source = set_resource_instance + authorize_resource + redirect_to new_joatu_offer_path(source_type: BetterTogether::Joatu::Request.to_s, source_id: source.id) + end + + # Render new with optional prefill from a source Offer/Request + def new # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + resource_instance + # If source params were provided, load and authorize the source so the view can safely render it + if (source_type = params[:source_type].presence) && (source_id = params[:source_id].presence) + source_klass = joatu_source_class(source_type) + return unless source_klass + + @source = source_klass&.with_translations&.includes(:categories, :address, creator: :string_translations)&.find_by(id: source_id) # rubocop:disable Layout/LineLength,Style/SafeNavigationChainLength + begin + authorize @source if @source + rescue Pundit::NotAuthorizedError + render_not_found and return + end + + # Only allow responding to sources that are open or already matched + if @source.respond_to?(:status) && !%w[open matched].include?(@source.status) + redirect_to url_for(@source.becomes(@source.class)), + alert: 'Cannot create a response for a source that is not open or matched.' and return + end + end + + apply_source_prefill(resource_instance) + end + + private + + # rubocop:todo Metrics/PerceivedComplexity + # rubocop:todo Metrics/MethodLength + # rubocop:todo Metrics/AbcSize + def apply_source_prefill(request) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + return unless request + + # Accept source params either at top-level (hidden_field_tag in new) or nested inside the form params + source_type = params[:source_type] || params.dig(resource_name, :source_type) + source_id = params[:source_id] || params.dig(resource_name, :source_id) + + return unless source_type == 'BetterTogether::Joatu::Offer' && source_id.present? + + source = BetterTogether::Joatu::Offer.with_translations.includes(:categories, :address, creator: :string_translations).find_by(id: source_id) # rubocop:disable Layout/LineLength + return unless source + # Do not build nested response_link if source is not respondable + return unless source.respond_to?(:status) ? %w[open matched].include?(source.status) : true + + request.name ||= source.name + request.description ||= source.description + request.target_type ||= source.target_type if source.respond_to?(:target_type) + request.target_id ||= source.target_id if source.respond_to?(:target_id) + request.urgency ||= source.urgency if source.respond_to?(:urgency) + request.address || request.build_address + if source.respond_to?(:categories) && request.category_ids.blank? + request.category_ids = source.categories.pluck(:id) + end + + # Build a nested response_link so the form's fields_for will render hidden fields + return unless request.response_links_as_response.blank? + + request.response_links_as_response.build(source_type: source.class.to_s, source_id: source.id, + creator_id: helpers.current_person&.id) + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity def resource_class ::BetterTogether::Joatu::Request end + # Override the base resource collection to eager-load translations and + # commonly accessed associations (categories, address) and the + # creator's string translations to avoid N+1 queries in views. + def resource_collection + @resources ||= policy_scope(resource_class.with_translations) + .includes(:address, + { categories: :string_translations }, + { creator: %i[string_translations profile_image_attachment profile_image_blob] }) + + instance_variable_set("@#{resource_name(plural: true)}", @resources) + end + def resource_params rp = super rp[:creator_id] ||= helpers.current_person&.id diff --git a/app/controllers/better_together/joatu/response_links_controller.rb b/app/controllers/better_together/joatu/response_links_controller.rb new file mode 100644 index 000000000..f887f40b6 --- /dev/null +++ b/app/controllers/better_together/joatu/response_links_controller.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module BetterTogether + module Joatu + class ResponseLinksController < JoatuController # rubocop:todo Style/Documentation + before_action :authenticate_user! + # This controller doesn't call Pundit's `authorize` in the create flow + # because it builds responses from an existing source. The global + # `after_action :verify_authorized` from ResourceController would raise + # an AuthorizationNotPerformedError after a redirect which can lead to a + # DoubleRenderError (redirect then error render). Skip the check here. + skip_after_action :verify_authorized + + def create # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + # Create a request from offer or offer from request, linking them via ResponseLink + source_type = params[:source_type] + source_id = params[:source_id] + + unless source_type && source_id + return redirect_back fallback_location: joatu_hub_path, + alert: 'Invalid source' + end + + source_klass = joatu_source_class(source_type) + unless source_klass + return redirect_back fallback_location: joatu_hub_path, + alert: 'Invalid source type' + end + + source = source_klass.with_translations.includes(:categories, :address, creator: :string_translations).find_by(id: source_id) # rubocop:disable Layout/LineLength + return redirect_back fallback_location: joatu_hub_path, alert: 'Source not found' unless source + + # Only allow creating responses against sources that are open or already matched + if source.respond_to?(:status) && !%w[open matched].include?(source.status) + return redirect_back fallback_location: joatu_hub_path, + alert: 'Cannot respond to a source that is not open or matched.' + end + + if source.is_a?(BetterTogether::Joatu::Offer) + # Build a new Request from offer details + request = BetterTogether::Joatu::Request.new + request.name = source.name + request.description = source.description + request.creator_id = helpers.current_person&.id + request.target_type = source.target_type if source.respond_to?(:target_type) + request.target_id = source.target_id if source.respond_to?(:target_id) + request.urgency = source.urgency if source.respond_to?(:urgency) + request.address_id = source.address_id if source.respond_to?(:address_id) + request.category_ids = source.categories.pluck(:id) if source.respond_to?(:categories) + + if request.save + rl = ResponseLink.create(source: source, response: request, creator_id: helpers.current_person&.id) + if rl.persisted? + # mark_source_matched is handled in model callback + else + Rails.logger.error("Failed to create ResponseLink: #{rl.errors.full_messages.join(', ')}") + end + redirect_to joatu_request_path(request), + notice: t('flash.joatu.response_links.request_created') + else + redirect_back fallback_location: joatu_offer_path(source), + alert: request.errors.full_messages.to_sentence + end + elsif source.is_a?(BetterTogether::Joatu::Request) + offer = BetterTogether::Joatu::Offer.new + offer.name = source.name + offer.description = source.description + offer.creator_id = helpers.current_person&.id + offer.target_type = source.target_type if source.respond_to?(:target_type) + offer.target_id = source.target_id if source.respond_to?(:target_id) + offer.urgency = source.urgency if source.respond_to?(:urgency) + offer.address_id = source.address_id if source.respond_to?(:address_id) + offer.category_ids = source.categories.pluck(:id) if source.respond_to?(:categories) + + if offer.save + rl = ResponseLink.create(source: source, response: offer, creator_id: helpers.current_person&.id) + unless rl.persisted? + Rails.logger.error("Failed to create ResponseLink: #{rl.errors.full_messages.join(', ')}") + end + redirect_to joatu_offer_path(offer), + notice: t('flash.joatu.response_links.offer_created') + else + redirect_back fallback_location: joatu_request_path(source), + alert: offer.errors.full_messages.to_sentence + end + else + redirect_back fallback_location: joatu_hub_path, alert: 'Unsupported source type' + end + rescue StandardError => e + # Log full backtrace to surface errors during tests + Rails.logger.error( + "ResponseLinksController#create failed: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}" + ) + raise + end + end + end +end diff --git a/app/controllers/better_together/navigation_areas_controller.rb b/app/controllers/better_together/navigation_areas_controller.rb index 3dd4b8789..14727f87b 100644 --- a/app/controllers/better_together/navigation_areas_controller.rb +++ b/app/controllers/better_together/navigation_areas_controller.rb @@ -50,7 +50,8 @@ def create # rubocop:todo Metrics/MethodLength authorize @navigation_area if @navigation_area.save - redirect_to @navigation_area, only_path: true, notice: 'Navigation area was successfully created.' + redirect_to @navigation_area, only_path: true, + notice: t('flash.generic.created', resource: t('resources.navigation_area')) else respond_to do |format| format.turbo_stream do @@ -69,7 +70,8 @@ def update # rubocop:todo Metrics/MethodLength authorize @navigation_area if @navigation_area.update(navigation_area_params) - redirect_to @navigation_area, only_path: true, notice: 'Navigation area was successfully updated.' + redirect_to @navigation_area, only_path: true, + notice: t('flash.generic.updated', resource: t('resources.navigation_area')) else respond_to do |format| format.turbo_stream do @@ -87,7 +89,8 @@ def update # rubocop:todo Metrics/MethodLength def destroy authorize @navigation_area @navigation_area.destroy - redirect_to navigation_areas_url, notice: 'Navigation area was successfully destroyed.' + redirect_to navigation_areas_url, + notice: t('flash.generic.destroyed', resource: t('resources.navigation_area')) end private diff --git a/app/controllers/better_together/notifications_controller.rb b/app/controllers/better_together/notifications_controller.rb index 13bd4b36b..bd746474d 100644 --- a/app/controllers/better_together/notifications_controller.rb +++ b/app/controllers/better_together/notifications_controller.rb @@ -3,6 +3,8 @@ module BetterTogether # handles rendering and marking notifications as read class NotificationsController < ApplicationController + include BetterTogether::NotificationReadable + before_action :authenticate_user! before_action :disallow_robots @@ -43,10 +45,7 @@ def mark_notification_as_read(id) end def mark_record_notification_as_read(id) - @notifications = helpers.current_person.notifications.unread.includes( - :event - ).references(:event).where(event: { record_id: id }) - @notifications.update_all(read_at: Time.current) + mark_notifications_read_for_record(Struct.new(id: id), recipient: helpers.current_person) end end end diff --git a/app/controllers/better_together/people_controller.rb b/app/controllers/better_together/people_controller.rb index fcb97fcae..80cdda982 100644 --- a/app/controllers/better_together/people_controller.rb +++ b/app/controllers/better_together/people_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module BetterTogether - class PeopleController < FriendlyResourceController # rubocop:todo Style/Documentation + class PeopleController < FriendlyResourceController # rubocop:todo Style/Documentation, Metrics/ClassLength before_action :set_person, only: %i[show edit update destroy] # GET /people @@ -31,7 +31,9 @@ def create # rubocop:todo Metrics/MethodLength authorize_person if @person.save - redirect_to @person, only_path: true, notice: 'Person was successfully created.', status: :see_other + redirect_to @person, only_path: true, + notice: t('flash.generic.created', resource: t('resources.person')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -50,10 +52,12 @@ def create # rubocop:todo Metrics/MethodLength def edit; end # PATCH/PUT /people/1 - def update # rubocop:todo Metrics/MethodLength + def update # rubocop:todo Metrics/MethodLength, Metrics/AbcSize ActiveRecord::Base.transaction do if @person.update(person_params) - redirect_to @person, only_path: true, notice: 'Profile was successfully updated.', status: :see_other + redirect_to @person, only_path: true, + notice: t('flash.generic.updated', resource: t('resources.profile', default: t('resources.person'))), # rubocop:disable Layout/LineLength + status: :see_other else flash.now[:alert] = 'Please address the errors below.' respond_to do |format| @@ -73,7 +77,8 @@ def update # rubocop:todo Metrics/MethodLength # DELETE /people/1 def destroy @person.destroy - redirect_to people_url, notice: 'Person was successfully deleted.', status: :see_other + redirect_to people_url, notice: t('flash.generic.destroyed', resource: t('resources.person')), + status: :see_other end protected diff --git a/app/controllers/better_together/person_blocks_controller.rb b/app/controllers/better_together/person_blocks_controller.rb index 303f83f13..219b5dd84 100644 --- a/app/controllers/better_together/person_blocks_controller.rb +++ b/app/controllers/better_together/person_blocks_controller.rb @@ -15,7 +15,7 @@ def create authorize @person_block if @person_block.save - redirect_to blocks_path, notice: 'Person was successfully blocked.' + redirect_to blocks_path, notice: t('flash.person_block.blocked') else redirect_to blocks_path, alert: @person_block.errors.full_messages.to_sentence end @@ -24,7 +24,7 @@ def create def destroy authorize @person_block @person_block.destroy - redirect_to blocks_path, notice: 'Person was successfully unblocked.' + redirect_to blocks_path, notice: t('flash.person_block.unblocked') end private diff --git a/app/controllers/better_together/person_platform_memberships_controller.rb b/app/controllers/better_together/person_platform_memberships_controller.rb index 04382d1e1..a1eb3aff4 100644 --- a/app/controllers/better_together/person_platform_memberships_controller.rb +++ b/app/controllers/better_together/person_platform_memberships_controller.rb @@ -45,8 +45,9 @@ def create # rubocop:todo Metrics/MethodLength # PATCH/PUT /person_platform_memberships/1 def update # rubocop:todo Metrics/MethodLength if @person_platform_membership.update(person_platform_membership_params) - redirect_to @person_platform_membership, notice: 'Person platform membership was successfully updated.', - status: :see_other + redirect_to @person_platform_membership, + notice: t('flash.generic.updated', resource: t('resources.person_platform_membership')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -64,8 +65,9 @@ def update # rubocop:todo Metrics/MethodLength # DELETE /person_platform_memberships/1 def destroy @person_platform_membership.destroy - redirect_to person_platform_memberships_url, notice: 'Person platform membership was successfully destroyed.', - status: :see_other + redirect_to person_platform_memberships_url, + notice: t('flash.generic.destroyed', resource: t('resources.person_platform_membership')), + status: :see_other end private diff --git a/app/controllers/better_together/platforms_controller.rb b/app/controllers/better_together/platforms_controller.rb index 6ad197f5d..fb6d02e6d 100644 --- a/app/controllers/better_together/platforms_controller.rb +++ b/app/controllers/better_together/platforms_controller.rb @@ -41,7 +41,7 @@ def create # rubocop:todo Metrics/MethodLength authorize_platform if @platform.save - redirect_to @platform, notice: 'Platform was successfully created.' + redirect_to @platform, notice: t('flash.generic.created', resource: t('resources.platform')) else respond_to do |format| format.turbo_stream do @@ -60,7 +60,7 @@ def create # rubocop:todo Metrics/MethodLength def update # rubocop:todo Metrics/MethodLength authorize @platform if @platform.update(platform_params) - redirect_to @platform, notice: 'Platform was successfully updated.', status: :see_other + redirect_to @platform, notice: t('flash.generic.updated', resource: t('resources.platform')), status: :see_other else respond_to do |format| format.turbo_stream do @@ -79,7 +79,8 @@ def update # rubocop:todo Metrics/MethodLength def destroy authorize @platform @platform.destroy - redirect_to platforms_url, notice: 'Platform was successfully destroyed.', status: :see_other + redirect_to platforms_url, notice: t('flash.generic.destroyed', resource: t('resources.platform')), + status: :see_other end private diff --git a/app/controllers/better_together/resource_controller.rb b/app/controllers/better_together/resource_controller.rb index 123d5a1b9..93cacbf1f 100644 --- a/app/controllers/better_together/resource_controller.rb +++ b/app/controllers/better_together/resource_controller.rb @@ -3,8 +3,8 @@ module BetterTogether # Abstracts the retrieval of resources class ResourceController < ApplicationController # rubocop:todo Metrics/ClassLength - before_action :set_resource_instance, only: %i[show edit update destroy] - before_action :authorize_resource, only: %i[new show edit update destroy] + before_action :set_resource_instance, only: %i[show edit update destroy ics] + before_action :authorize_resource, only: %i[new show edit update destroy ics] before_action :resource_collection, only: %i[index] before_action :authorize_resource_class, only: %i[index] after_action :verify_authorized, except: :index diff --git a/app/controllers/better_together/resource_permissions_controller.rb b/app/controllers/better_together/resource_permissions_controller.rb index aaab353f2..3f7b8e554 100644 --- a/app/controllers/better_together/resource_permissions_controller.rb +++ b/app/controllers/better_together/resource_permissions_controller.rb @@ -33,7 +33,8 @@ def create # rubocop:todo Metrics/MethodLength authorize @resource_permission if @resource_permission.save - redirect_to @resource_permission, only_path: true, notice: 'Resource permission was successfully created.' + redirect_to @resource_permission, only_path: true, + notice: t('flash.generic.created', resource: t('resources.resource_permission')) # rubocop:disable Layout/LineLength else respond_to do |format| format.turbo_stream do @@ -53,7 +54,8 @@ def update # rubocop:todo Metrics/MethodLength authorize @resource_permission if @resource_permission.update(resource_permission_params) - redirect_to @resource_permission, only_path: true, notice: 'Resource permission was successfully updated.', + redirect_to @resource_permission, only_path: true, + notice: t('flash.generic.updated', resource: t('resources.resource_permission')), # rubocop:disable Layout/LineLength status: :see_other else respond_to do |format| @@ -73,8 +75,9 @@ def update # rubocop:todo Metrics/MethodLength def destroy authorize @resource_permission @resource_permission.destroy - redirect_to resource_permissions_url, notice: 'Resource permission was successfully destroyed.', - status: :see_other + redirect_to resource_permissions_url, + notice: t('flash.generic.destroyed', resource: t('resources.resource_permission')), + status: :see_other end private diff --git a/app/controllers/better_together/roles_controller.rb b/app/controllers/better_together/roles_controller.rb index bf5fcb13d..6cbf1ae71 100644 --- a/app/controllers/better_together/roles_controller.rb +++ b/app/controllers/better_together/roles_controller.rb @@ -34,7 +34,8 @@ def create # rubocop:todo Metrics/MethodLength authorize @role # Add authorization check if @role.save - redirect_to @role, only_path: true, notice: 'Role was successfully created.' + redirect_to @role, only_path: true, + notice: t('flash.generic.created', resource: t('resources.role')) else respond_to do |format| format.turbo_stream do @@ -54,7 +55,9 @@ def update # rubocop:todo Metrics/MethodLength authorize @role # Add authorization check if @role.update(role_params) - redirect_to @role, only_path: true, notice: 'Role was successfully updated.', status: :see_other + redirect_to @role, only_path: true, + notice: t('flash.generic.updated', resource: t('resources.role')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -73,7 +76,8 @@ def update # rubocop:todo Metrics/MethodLength def destroy authorize @role # Add authorization check @role.destroy - redirect_to roles_url, notice: 'Role was successfully destroyed.', status: :see_other + redirect_to roles_url, notice: t('flash.generic.destroyed', resource: t('resources.role')), + status: :see_other end private diff --git a/app/controllers/better_together/users_controller.rb b/app/controllers/better_together/users_controller.rb index 4e3d47a45..1e57388d3 100644 --- a/app/controllers/better_together/users_controller.rb +++ b/app/controllers/better_together/users_controller.rb @@ -27,7 +27,9 @@ def create # rubocop:todo Metrics/MethodLength authorize_user if @user.save - redirect_to @user, only_path: true, notice: 'User was successfully created.', status: :see_other + redirect_to @user, only_path: true, + notice: t('flash.generic.created', resource: t('resources.user')), + status: :see_other else respond_to do |format| format.turbo_stream do @@ -46,10 +48,12 @@ def create # rubocop:todo Metrics/MethodLength def edit; end # PATCH/PUT /users/1 - def update # rubocop:todo Metrics/MethodLength + def update # rubocop:todo Metrics/MethodLength, Metrics/AbcSize ActiveRecord::Base.transaction do if @user.update(user_params) - redirect_to @user, only_path: true, notice: 'Profile was successfully updated.', status: :see_other + redirect_to @user, only_path: true, + notice: t('flash.generic.updated', resource: t('resources.profile', default: t('resources.user'))), # rubocop:disable Layout/LineLength + status: :see_other else flash.now[:alert] = 'Please address the errors below.' respond_to do |format| @@ -69,7 +73,8 @@ def update # rubocop:todo Metrics/MethodLength # DELETE /users/1 def destroy @user.destroy - redirect_to users_url, notice: 'User was successfully deleted.', status: :see_other + redirect_to users_url, notice: t('flash.generic.destroyed', resource: t('resources.user')), + status: :see_other end private diff --git a/app/controllers/concerns/better_together/notification_readable.rb b/app/controllers/concerns/better_together/notification_readable.rb new file mode 100644 index 000000000..f093b7f95 --- /dev/null +++ b/app/controllers/concerns/better_together/notification_readable.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module BetterTogether + # Controller concern to mark Noticed notifications as read for a record + module NotificationReadable + extend ActiveSupport::Concern + + # Marks notifications (for the current person) as read for events bound to a record + # via Noticed::Event#record_id (generic helper used across features). + def mark_notifications_read_for_record(record, recipient: helpers.current_person) # rubocop:todo Metrics/AbcSize + return unless recipient && record.respond_to?(:id) + + nn = Noticed::Notification.arel_table + ne = Noticed::Event.arel_table + + join = nn.join(ne).on(ne[:id].eq(nn[:event_id])).join_sources + + relation = Noticed::Notification + .where(recipient:) + .where(nn[:read_at].eq(nil)) + .joins(join) + .where(ne[:record_id].eq(record.id)) + + relation.update_all(read_at: Time.current) + end + + # Marks notifications as read for a set of records associated to a given Noticed event class + # using the event's record_id field. + def mark_notifications_read_for_event_records(event_class, record_ids, recipient: helpers.current_person) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize + return unless recipient && record_ids.present? + + nn = Noticed::Notification.arel_table + ne = Noticed::Event.arel_table + + join = nn.join(ne).on(ne[:id].eq(nn[:event_id])).join_sources + + relation = Noticed::Notification + .where(recipient:) + .where(nn[:read_at].eq(nil)) + .joins(join) + .where(ne[:type].eq(event_class.to_s)) + .where(ne[:record_id].in(Array(record_ids))) + + relation.update_all(read_at: Time.current) + end + + # Marks Joatu match notifications as read for an Offer or Request record by matching + # the record's GlobalID against the Noticed::Event params (supports both direct string + # serialization and ActiveJob-style nested _aj_globalid key). + def mark_match_notifications_read_for(record, recipient: helpers.current_person) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + return unless recipient && record.respond_to?(:to_global_id) + + gid = record.to_global_id.to_s + return if gid.blank? + + nn = Noticed::Notification.arel_table + ne = Noticed::Event.arel_table + + join = nn.join(ne).on(ne[:id].eq(nn[:event_id])).join_sources + + relation = Noticed::Notification + .where(recipient:) + .where(nn[:read_at].eq(nil)) + .joins(join) + .where(ne[:type].eq('BetterTogether::Joatu::MatchNotifier')) + + # JSONB params filter (offer/request match on direct string or AJ global id) + json_filter_sql = <<~SQL.squish + (noticed_events.params ->> 'offer' = :gid OR + noticed_events.params -> 'offer' ->> '_aj_globalid' = :gid OR + noticed_events.params ->> 'request' = :gid OR + noticed_events.params -> 'request' ->> '_aj_globalid' = :gid) + SQL + relation = relation.where( + ActiveRecord::Base.send( + :sanitize_sql_array, [json_filter_sql, { gid: }] + ) + ) + + relation.update_all(read_at: Time.current) + end + end +end diff --git a/app/helpers/better_together/joatu/exchange_helper.rb b/app/helpers/better_together/joatu/exchange_helper.rb new file mode 100644 index 000000000..a74070762 --- /dev/null +++ b/app/helpers/better_together/joatu/exchange_helper.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module BetterTogether + module Joatu + module ExchangeHelper # rubocop:todo Style/Documentation + # Should the "Respond with Request" button be visible for the current person? + # - hide when there's no current_person + # - hide when the current_person is the creator of the offer + # - hide when the current_person has already responded to the offer + # - hide when the current_person already has an agreement involving the offer + # - hide when the offer itself is a response to one of the current_person's Requests + # rubocop:todo Metrics/PerceivedComplexity + # rubocop:todo Metrics/MethodLength + # rubocop:todo Metrics/AbcSize + def respond_with_request_visible?(offer) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + return false unless defined?(current_person) && current_person + return false if offer.creator == current_person + + # has user already responded with a Request to this Offer? + user_has_responded = offer.response_links_as_source.any? do |rl| + rl.response.is_a?(BetterTogether::Joatu::Request) && rl.response.creator == current_person + end + return false if user_has_responded + + # any agreement involving this offer where the other party is the current person + user_agreement = offer.agreements.detect do |agr| + (agr.request && agr.request.creator == current_person) || (agr.offer && agr.offer.creator == current_person) + end + return false if user_agreement.present? + + # if this offer is itself a response to one of the current_person's Requests, hide the button + return false if is_response_to_my_request?(offer) + + true + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity + + # Should the "Respond with Offer" button be visible for the current person? + # Symmetric to respond_with_request_visible? + # - hide when the request itself is a response to one of the current_person's Offers + # rubocop:todo Metrics/PerceivedComplexity + # rubocop:todo Metrics/MethodLength + # rubocop:todo Metrics/AbcSize + def respond_with_offer_visible?(request) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + return false unless defined?(current_person) && current_person + return false if request.creator == current_person + + # has user already responded with an Offer to this Request? + user_has_responded = request.response_links_as_source.any? do |rl| + rl.response.is_a?(BetterTogether::Joatu::Offer) && rl.response.creator == current_person + end + return false if user_has_responded + + # any agreement involving this request where the other party is the current person + user_agreement = request.agreements.detect do |agr| + (agr.request && agr.request.creator == current_person) || (agr.offer && agr.offer.creator == current_person) + end + return false if user_agreement.present? + + # if this request is itself a response to one of the current_person's Offers, hide the button + return false if is_response_to_my_offer?(request) + + true + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity + + # Is the given Offer itself a response to a Request owned by current_person? + def is_response_to_my_request?(offer) # rubocop:todo Naming/PredicatePrefix + return false unless defined?(current_person) && current_person + + offer.response_links_as_response.any? do |rl| + rl.source.is_a?(BetterTogether::Joatu::Request) && rl.source.creator == current_person + end + end + + # Is the given Request itself a response to an Offer owned by current_person? + def is_response_to_my_offer?(request) # rubocop:todo Naming/PredicatePrefix + return false unless defined?(current_person) && current_person + + request.response_links_as_response.any? do |rl| + rl.source.is_a?(BetterTogether::Joatu::Offer) && rl.source.creator == current_person + end + end + + # Find an Agreement involving the given resource where the other party is the current person. + # Returns the Agreement or nil. + def agreement_for_current_person(resource) + return nil unless defined?(current_person) && current_person + + resource.agreements.detect do |agr| + (agr.request && agr.request.creator == current_person) || (agr.offer && agr.offer.creator == current_person) + end + end + end + end +end diff --git a/app/javascript/controllers/better_together/location_selector_controller.js b/app/javascript/controllers/better_together/location_selector_controller.js new file mode 100644 index 000000000..7b68632d1 --- /dev/null +++ b/app/javascript/controllers/better_together/location_selector_controller.js @@ -0,0 +1,114 @@ +// Stimulus controller for dynamic location selection in event forms +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "typeSelector", + "simpleLocation", + "addressLocation", + "buildingLocation", + "addressTypeField", + "buildingTypeField" + ] + + connect() { + // Initialize form state based on existing data + this.updateVisibility() + } + + toggleLocationType(event) { + const selectedType = event.target.value + this.hideAllLocationTypes() + + switch(selectedType) { + case 'simple': + this.showSimpleLocation() + break + case 'address': + this.showAddressLocation() + break + case 'building': + this.showBuildingLocation() + break + } + } + + hideAllLocationTypes() { + if (this.hasSimpleLocationTarget) { + this.simpleLocationTarget.style.display = 'none' + } + if (this.hasAddressLocationTarget) { + this.addressLocationTarget.style.display = 'none' + } + if (this.hasBuildingLocationTarget) { + this.buildingLocationTarget.style.display = 'none' + } + } + + showSimpleLocation() { + if (this.hasSimpleLocationTarget) { + this.simpleLocationTarget.style.display = 'block' + } + // Clear structured location fields + this.clearStructuredLocationFields() + } + + showAddressLocation() { + if (this.hasAddressLocationTarget) { + this.addressLocationTarget.style.display = 'block' + } + // Clear simple name field + this.clearSimpleLocationFields() + } + + showBuildingLocation() { + if (this.hasBuildingLocationTarget) { + this.buildingLocationTarget.style.display = 'block' + } + // Clear simple name field + this.clearSimpleLocationFields() + } + + updateAddressType(event) { + if (event.target.value && this.hasAddressTypeFieldTarget) { + // Type field should already be set in the hidden field + } + } + + updateBuildingType(event) { + if (event.target.value && this.hasBuildingTypeFieldTarget) { + // Type field should already be set in the hidden field + } + } + + updateVisibility() { + // Show the appropriate section based on current data + const checkedRadio = this.element.querySelector('input[name="location_type_selector"]:checked') + if (checkedRadio) { + this.toggleLocationType({ target: { value: checkedRadio.value } }) + } else { + // Default to simple location if nothing is selected + this.hideAllLocationTypes() + this.showSimpleLocation() + const simpleRadio = this.element.querySelector('#simple_location') + if (simpleRadio) { + simpleRadio.checked = true + } + } + } + + clearSimpleLocationFields() { + const nameField = this.element.querySelector('input[name*="[name]"]') + if (nameField) { + nameField.value = '' + } + } + + clearStructuredLocationFields() { + // Clear location_id and location_type for structured locations + const locationIdFields = this.element.querySelectorAll('select[name*="[location_id]"]') + locationIdFields.forEach(field => { + field.selectedIndex = 0 + }) + } +} diff --git a/app/jobs/better_together/event_reminder_job.rb b/app/jobs/better_together/event_reminder_job.rb new file mode 100644 index 000000000..1f026e3cf --- /dev/null +++ b/app/jobs/better_together/event_reminder_job.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module BetterTogether + # Job to send event reminders to attendees + class EventReminderJob < ApplicationJob + queue_as :notifications + + retry_on StandardError, wait: :polynomially_longer, attempts: 5 + discard_on ActiveRecord::RecordNotFound + + def perform(event_or_id, reminder_type = '24_hours') + event = find_event(event_or_id) + return unless event_valid?(event) + + attendees = going_attendees(event) + send_reminders_to_attendees(event, attendees, reminder_type) + log_completion(event, attendees, reminder_type) + end + + private + + def find_event(event_or_id) + return event_or_id if event_or_id.is_a?(BetterTogether::Event) + + BetterTogether::Event.find(event_or_id) if event_or_id.present? + rescue ActiveRecord::RecordNotFound + nil + end + + def event_valid?(event) + event.present? && event.starts_at.present? + end + + def going_attendees(event) + # Get people who have 'going' status for this event + person_ids = event.event_attendances.where(status: 'going').pluck(:person_id) + BetterTogether::Person.where(id: person_ids) + end + + def send_reminders_to_attendees(event, attendees, reminder_type) + attendees.find_each do |attendee| + send_reminder_to_attendee(event, attendee, reminder_type) + end + end + + def send_reminder_to_attendee(event, attendee, reminder_type) + BetterTogether::EventReminderNotifier.with( + record: event, + reminder_type: reminder_type + ).deliver(attendee) + rescue StandardError => e + Rails.logger.error "Failed to send event reminder to #{attendee.identifier}: #{e.message}" + end + + def log_completion(event, attendees, reminder_type) + Rails.logger.info "Sent #{reminder_type} reminders for event #{event.identifier} to #{attendees.count} attendees" + end + end +end diff --git a/app/jobs/better_together/event_reminder_scheduler_job.rb b/app/jobs/better_together/event_reminder_scheduler_job.rb new file mode 100644 index 000000000..1fd8be065 --- /dev/null +++ b/app/jobs/better_together/event_reminder_scheduler_job.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module BetterTogether + # Job to schedule event reminders when events are created or updated + class EventReminderSchedulerJob < ApplicationJob + queue_as :notifications + + retry_on StandardError, wait: :polynomially_longer, attempts: 5 + discard_on ActiveRecord::RecordNotFound + + def perform(event_or_id) + event = find_event(event_or_id) + return unless event_valid?(event) + return if event_in_past?(event) + return unless event_has_attendees?(event) + + cancel_existing_reminders(event) + schedule_reminders(event) + log_completion(event) + end + + private + + def find_event(event_or_id) + return event_or_id if event_or_id.is_a?(BetterTogether::Event) + + BetterTogether::Event.find(event_or_id) if event_or_id.present? + rescue ActiveRecord::RecordNotFound + nil + end + + def event_valid?(event) + event.present? && event.starts_at.present? + end + + def event_in_past?(event) + event.starts_at <= Time.current + end + + def event_has_attendees?(event) + event.event_attendances.any? + end + + def schedule_reminders(event) + schedule_24_hour_reminder(event) if should_schedule_24_hour_reminder?(event) + schedule_1_hour_reminder(event) if should_schedule_1_hour_reminder?(event) + schedule_start_time_reminder(event) if should_schedule_start_time_reminder?(event) + end + + def should_schedule_24_hour_reminder?(event) + event.starts_at > 24.hours.from_now + end + + def should_schedule_1_hour_reminder?(event) + event.starts_at > 1.hour.from_now + end + + def should_schedule_start_time_reminder?(event) + event.starts_at > Time.current + end + + def schedule_24_hour_reminder(event) + EventReminderJob.set(wait_until: event.starts_at - 24.hours) + .perform_later(event.id) + end + + def schedule_1_hour_reminder(event) + EventReminderJob.set(wait_until: event.starts_at - 1.hour) + .perform_later(event.id) + end + + def schedule_start_time_reminder(event) + EventReminderJob.set(wait_until: event.starts_at) + .perform_later(event.id) + end + + def log_completion(event) + Rails.logger.info "Scheduled reminders for event #{event.identifier}" + end + + def reminder_intervals + [24.hours, 1.hour, 0.seconds] + end + + def schedule_future_reminder?(event_id, reminder_time) + return false if reminder_time <= Time.current + + EventReminderJob.set(wait_until: reminder_time) + .perform_later(event_id) + true + end + + def cancel_existing_reminders(event) + # Find and cancel existing reminder jobs for this event + # This is a simplified approach - in production you might want to use + # a more sophisticated job management system like sidekiq-cron + Rails.logger.info "Rescheduling reminders for event #{event.identifier}" + end + end +end diff --git a/app/mailers/better_together/event_mailer.rb b/app/mailers/better_together/event_mailer.rb new file mode 100644 index 000000000..dc6b1a0d6 --- /dev/null +++ b/app/mailers/better_together/event_mailer.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module BetterTogether + # Mailer for event-related notifications + class EventMailer < ApplicationMailer + # Sends event reminder emails + def event_reminder + @event = params[:event] + @reminder_type = params[:reminder_type] || '24_hours' + @recipient = params[:person] + @platform = BetterTogether::Platform.find_by(host: true) + + mail( + to: @recipient.email, + subject: reminder_subject(@event) + ) + end + + # Sends event update emails + def event_update + @event = params[:event] + @changed_attributes = params[:changed_attributes] + @recipient = params[:person] + @platform = BetterTogether::Platform.find_by(host: true) + + mail( + to: @recipient.email, + subject: update_subject(@event) + ) + end + + private + + def reminder_subject(event) + I18n.t( + 'better_together.event_mailer.event_reminder.subject', + event_name: event.name, + default: 'Reminder: %s' + ) + end + + def update_subject(event) + I18n.t( + 'better_together.event_mailer.event_update.subject', + event_name: event.name, + default: 'Event updated: %s' + ) + end + end +end diff --git a/app/models/better_together/calendar.rb b/app/models/better_together/calendar.rb index becd4ce33..ea3b8bdd4 100644 --- a/app/models/better_together/calendar.rb +++ b/app/models/better_together/calendar.rb @@ -12,6 +12,9 @@ class Calendar < ApplicationRecord belongs_to :community, class_name: '::BetterTogether::Community' + has_many :calendar_entries, class_name: 'BetterTogether::CalendarEntry', dependent: :destroy + has_many :events, through: :calendar_entries + slugged :name translates :name diff --git a/app/models/better_together/calendar_entry.rb b/app/models/better_together/calendar_entry.rb index 188fbfcd5..e0ee9ffe2 100644 --- a/app/models/better_together/calendar_entry.rb +++ b/app/models/better_together/calendar_entry.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true module BetterTogether + # Join model between Calendar and Event for future calendar organization class CalendarEntry < ApplicationRecord + belongs_to :calendar, class_name: 'BetterTogether::Calendar' + belongs_to :event, class_name: 'BetterTogether::Event' + + validates :event_id, uniqueness: { scope: :calendar_id } end end diff --git a/app/models/better_together/community.rb b/app/models/better_together/community.rb index 2b58d43bd..612b85258 100644 --- a/app/models/better_together/community.rb +++ b/app/models/better_together/community.rb @@ -18,6 +18,9 @@ class Community < ApplicationRecord class_name: '::BetterTogether::Person', optional: true + has_many :calendars, class_name: 'BetterTogether::Calendar', dependent: :destroy + has_one :default_calendar, -> { where(name: 'Default') }, class_name: 'BetterTogether::Calendar' + joinable joinable_type: 'community', member_type: 'person' @@ -61,6 +64,7 @@ class Community < ApplicationRecord before_save :purge_profile_image, if: -> { remove_profile_image == '1' } before_save :purge_cover_image, if: -> { remove_cover_image == '1' } before_save :purge_logo, if: -> { remove_logo == '1' } + after_create :create_default_calendar validates :name, presence: true @@ -107,6 +111,17 @@ def to_s name end + private + + def create_default_calendar + calendars.create!( + name: 'Default', + description: I18n.t('better_together.calendars.default_description', + community_name: name, + default: 'Default calendar for %s') + ) + end + include ::BetterTogether::RemoveableAttachment end end diff --git a/app/models/better_together/event.rb b/app/models/better_together/event.rb index 83bd0f23b..b7ce739b1 100644 --- a/app/models/better_together/event.rb +++ b/app/models/better_together/event.rb @@ -2,6 +2,7 @@ module BetterTogether # A Schedulable Event + # rubocop:disable Metrics/ClassLength class Event < ApplicationRecord include Attachments::Images include Categorizable @@ -16,6 +17,12 @@ class Event < ApplicationRecord attachable_cover_image + has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy + has_many :attendees, through: :event_attendances, source: :person + + has_many :calendar_entries, class_name: 'BetterTogether::CalendarEntry', dependent: :destroy + has_many :calendars, through: :calendar_entries + categorizable(class_name: 'BetterTogether::EventCategory') has_many :event_hosts @@ -42,6 +49,11 @@ class Event < ApplicationRecord where(start_query) } + scope :scheduled, lambda { + start_query = arel_table[:starts_at].not_eq(nil) + where(start_query) + } + scope :upcoming, lambda { start_query = arel_table[:starts_at].gteq(Time.current) where(start_query) @@ -87,15 +99,170 @@ def to_s name end + # Minimal iCalendar representation for export + def to_ics + lines = ics_header_lines + ics_event_lines + ics_footer_lines + "#{lines.join("\r\n")}\r\n" + end + configure_attachment_cleanup + # Callbacks for notifications and reminders + after_update :send_update_notifications + after_update :schedule_reminder_notifications, if: :requires_reminder_scheduling? + + # Get the host community for calendar functionality + def host_community + @host_community ||= BetterTogether::Community.host.first + end + + # Check if event requires reminder scheduling + def requires_reminder_scheduling? + starts_at.present? && attendees.reload.any? + end + + # Get significant changes for notifications + def significant_changes_for_notifications + changes_to_check = saved_changes.presence || previous_changes + return [] unless changes_to_check.present? + + significant_attrs = %w[name name_en name_es name_fr starts_at ends_at location_id description description_en + description_es description_fr] + changes_to_check.keys & significant_attrs + end + + # Check if event has location + def location? + location.present? + end + + # State methods + def draft? + starts_at.blank? + end + + def scheduled? + starts_at.present? + end + + def upcoming? + starts_at.present? && starts_at > Time.current + end + + def past? + starts_at.present? && starts_at < Time.current + end + + # Duration calculation + def duration_in_hours + return nil unless starts_at.present? && ends_at.present? + + (ends_at - starts_at) / 1.hour + end + + # Delegate location methods + delegate :display_name, to: :location, prefix: true, allow_nil: true + delegate :geocoding_string, to: :location, prefix: true, allow_nil: true + private + # Send update notifications + def send_update_notifications + changes = significant_changes_for_notifications + return unless changes.any? && attendees.reload.any? + + BetterTogether::EventUpdateNotifier.with(event: self, changed_attributes: changes).deliver_later + end + + # Schedule reminder notifications + def schedule_reminder_notifications + return unless requires_reminder_scheduling? + + BetterTogether::EventReminderSchedulerJob.perform_later(id) + end + + # Check if we should schedule reminders after save (for updates) + def should_schedule_reminders_after_save? + !new_record? && requires_reminder_scheduling? + end + + # Check if we should schedule reminders after commit (for creates with attendees) + def should_schedule_reminders_after_commit? + starts_at.present? && attendees.reload.any? + end + + def ics_header_lines + [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Better Together Community Engine//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT' + ] + end + + def ics_event_lines + lines = [] + lines.concat(ics_basic_event_info) + lines << ics_description_line if ics_description_present? + lines.concat(ics_timing_info) + lines << "URL:#{url}" + lines + end + + def ics_basic_event_info + [ + "DTSTAMP:#{ics_timestamp}", + "UID:event-#{id}@better-together", + "SUMMARY:#{name}" + ] + end + + def ics_timing_info + lines = [] + lines << "DTSTART:#{ics_start_time}" if starts_at + lines << "DTEND:#{ics_end_time}" if ends_at + lines + end + + def ics_footer_lines + ['END:VEVENT', 'END:VCALENDAR'] + end + + def ics_timestamp + Time.current.utc.strftime('%Y%m%dT%H%M%SZ') + end + + def ics_start_time + starts_at&.utc&.strftime('%Y%m%dT%H%M%SZ') + end + + def ics_end_time + ends_at&.utc&.strftime('%Y%m%dT%H%M%SZ') + end + + def ics_description_present? + respond_to?(:description) && description + end + + def ics_description_line + desc_text = ActionView::Base.full_sanitizer.sanitize(description.to_plain_text) + desc_text += "\n\n#{I18n.t('better_together.events.ics.view_details_url', url: url)}" + "DESCRIPTION:#{desc_text}" + end + def ends_at_after_starts_at return if ends_at.blank? || starts_at.blank? return if ends_at > starts_at errors.add(:ends_at, I18n.t('errors.models.ends_at_before_starts_at')) end + + # Public URL to this event for use in ICS export + def url + BetterTogether::Engine.routes.url_helpers.event_url(self, locale: I18n.locale) + end end + # rubocop:enable Metrics/ClassLength end diff --git a/app/models/better_together/event_attendance.rb b/app/models/better_together/event_attendance.rb new file mode 100644 index 000000000..4f2f65600 --- /dev/null +++ b/app/models/better_together/event_attendance.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module BetterTogether + # Tracks a person's RSVP to an event + class EventAttendance < ApplicationRecord + STATUS = { + interested: 'interested', + going: 'going' + }.freeze + + belongs_to :event, class_name: 'BetterTogether::Event' + belongs_to :person, class_name: 'BetterTogether::Person' + + validates :status, inclusion: { in: STATUS.values } + validates :event_id, uniqueness: { scope: :person_id } + end +end diff --git a/app/models/better_together/geography/locatable_location.rb b/app/models/better_together/geography/locatable_location.rb index 1cd072989..d3f528c7b 100644 --- a/app/models/better_together/geography/locatable_location.rb +++ b/app/models/better_together/geography/locatable_location.rb @@ -9,12 +9,83 @@ class LocatableLocation < ApplicationRecord belongs_to :locatable, polymorphic: true belongs_to :location, polymorphic: true, optional: true + validates :name, presence: true, if: :simple_location? + validate :at_least_one_location_source + def self.permitted_attributes(id: false, destroy: false) - super + %i[name locatable_id locatable_type location_id location_type] + super + %i[ + name locatable_id locatable_type location_id location_type + ] end def to_s - name || id + display_name + end + + # Primary display name for the location + def display_name + return name if name.present? + return location.to_s if location.present? + + 'Unnamed Location' + end + + # Full address string for geocoding + def geocoding_string + return location.geocoding_string if location.respond_to?(:geocoding_string) + + name # fallback to string location + end + + # Check if this is a simple string-based location + def simple_location? + location.blank? + end + + # Check if this has structured location data + def structured_location? + !simple_location? + end + + # Convenience methods for specific location types + def address + location if location_type == 'BetterTogether::Address' + end + + def building + location if location_type == 'BetterTogether::Infrastructure::Building' + end + + # Check if location is of a specific type + def address? + location_type == 'BetterTogether::Address' + end + + def building? + location_type == 'BetterTogether::Infrastructure::Building' + end + + # Helper method for forms - get available addresses for the user/context + def self.available_addresses_for(_context = nil) + # This would be customized based on your business logic + # For example, user's addresses, community addresses, etc. + BetterTogether::Address.includes(:string_translations) + end + + # Helper method for forms - get available buildings for the user/context + def self.available_buildings_for(_context = nil) + # This would be customized based on your business logic + BetterTogether::Infrastructure::Building.includes(:string_translations) + end + + private + + def at_least_one_location_source + sources = [name.present?, location.present?] + return if sources.any? + + errors.add(:base, I18n.t('better_together.geography.locatable_location.errors.no_location_source', + default: 'Must specify either a name or location')) end end end diff --git a/app/models/better_together/joatu/agreement.rb b/app/models/better_together/joatu/agreement.rb index 4ea37f756..da391b273 100644 --- a/app/models/better_together/joatu/agreement.rb +++ b/app/models/better_together/joatu/agreement.rb @@ -3,7 +3,7 @@ module BetterTogether module Joatu # Agreement connects an offer and request and tracks value exchange - class Agreement < ApplicationRecord + class Agreement < ApplicationRecord # rubocop:todo Metrics/ClassLength include FriendlySlug include Metrics::Viewable @@ -28,13 +28,32 @@ class Agreement < ApplicationRecord after_create_commit :notify_creators + # When an agreement is created, mark the paired offer/request as matched + after_create :mark_associated_matched + after_update_commit :notify_status_change, if: -> { saved_change_to_status? } + # Prevent illegal status transitions regardless of entry point + validate :validate_status_transition + + # Only one accepted agreement per offer/request (enforced at DB too) + validates :offer_id, uniqueness: { + conditions: -> { where(status: STATUS_VALUES[:accepted]) }, + message: 'already has an accepted agreement' + }, if: :status_accepted? + + validates :request_id, uniqueness: { + conditions: -> { where(status: STATUS_VALUES[:accepted]) }, + message: 'already has an accepted agreement' + }, if: :status_accepted? + def self.permitted_attributes(id: false, destroy: false) super + %i[offer_id request_id terms value status] end def accept! + ensure_accept_allowed! + transaction do update!(status: :accepted) offer.status_closed! @@ -43,6 +62,7 @@ def accept! end def reject! + ensure_reject_allowed! update!(status: :rejected) end @@ -52,6 +72,82 @@ def to_s private + def ensure_accept_allowed! # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + if status_accepted? + errors.add(:base, 'Agreement already accepted') + raise ActiveRecord::RecordInvalid, self + end + + if status_rejected? + errors.add(:base, 'Agreement already rejected') + raise ActiveRecord::RecordInvalid, self + end + + if offer.respond_to?(:status_closed?) && offer.status_closed? + errors.add(:offer, 'is already closed') + raise ActiveRecord::RecordInvalid, self + end + + return unless request.respond_to?(:status_closed?) && request.status_closed? + + errors.add(:request, 'is already closed') + raise ActiveRecord::RecordInvalid, self + end + + def ensure_reject_allowed! # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + if status_accepted? + errors.add(:base, 'Agreement already accepted') + raise ActiveRecord::RecordInvalid, self + end + + if status_rejected? + errors.add(:base, 'Agreement already rejected') + raise ActiveRecord::RecordInvalid, self + end + + if offer.respond_to?(:status_closed?) && offer.status_closed? + errors.add(:offer, 'is already closed') + raise ActiveRecord::RecordInvalid, self + end + + return unless request.respond_to?(:status_closed?) && request.status_closed? + + errors.add(:request, 'is already closed') + raise ActiveRecord::RecordInvalid, self + end + + def validate_status_transition # rubocop:todo Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength + return unless will_save_change_to_status? + + from, to = status_change_to_be_saved + + # On create (from nil), only allow pending + if from.nil? + errors.add(:status, 'must start as pending') unless to == STATUS_VALUES[:pending] + return + end + + # No-op changes are fine + return if from == to + + case from + when STATUS_VALUES[:pending] + # Allow only transitions to accepted or rejected from pending + unless [STATUS_VALUES[:accepted], STATUS_VALUES[:rejected]].include?(to) + errors.add(:status, 'can only move from pending to accepted or rejected') + end + # If moving to accepted via direct update, block when sides are already closed + if to == STATUS_VALUES[:accepted] + errors.add(:offer, 'is already closed') if offer.respond_to?(:status_closed?) && offer.status_closed? + errors.add(:request, 'is already closed') if request.respond_to?(:status_closed?) && request.status_closed? + end + when STATUS_VALUES[:accepted], STATUS_VALUES[:rejected] + errors.add(:status, 'cannot change once accepted or rejected') + else + errors.add(:status, 'has an invalid transition') + end + end + # Ensures the offer targets the same record as the request def offer_matches_request_target return unless targets_present? @@ -69,6 +165,17 @@ def notify_creators AgreementNotifier.with(record: self).deliver_later([offer.creator, request.creator]) end + def mark_associated_matched # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize + return unless offer && request + + begin + offer.status_matched! if offer.respond_to?(:status) && offer.status == 'open' + request.status_matched! if request.respond_to?(:status) && request.status == 'open' + rescue StandardError => e + Rails.logger.error("Failed to mark associated records matched for Agreement #{id}: #{e.message}") + end + end + def notify_status_change notifier = BetterTogether::Joatu::AgreementStatusNotifier.with(record: self) notifier.deliver_later(offer.creator) if offer&.creator diff --git a/app/models/better_together/joatu/offer.rb b/app/models/better_together/joatu/offer.rb index 64ae08487..17b2b5f3d 100644 --- a/app/models/better_together/joatu/offer.rb +++ b/app/models/better_together/joatu/offer.rb @@ -7,10 +7,18 @@ class Offer < ApplicationRecord include Creatable include Exchange include Metrics::Viewable + include ResponseLinkable has_many :requests, class_name: 'BetterTogether::Joatu::Request', through: :agreements categorizable class_name: '::BetterTogether::Joatu::Category' + + # Response link associations and nested attributes + response_linkable + + def self.permitted_attributes(id: true, destroy: false) + super + response_link_permitted_attributes + end end end end diff --git a/app/models/better_together/joatu/request.rb b/app/models/better_together/joatu/request.rb index f767570f8..c292d3d1c 100644 --- a/app/models/better_together/joatu/request.rb +++ b/app/models/better_together/joatu/request.rb @@ -7,10 +7,18 @@ class Request < ApplicationRecord include Creatable include Exchange include Metrics::Viewable + include ResponseLinkable has_many :offers, class_name: 'BetterTogether::Joatu::Offer', through: :agreements categorizable class_name: '::BetterTogether::Joatu::Category' + + # Response link associations and nested attributes + response_linkable + + def self.permitted_attributes(id: true, destroy: false) + super + response_link_permitted_attributes + end end end end diff --git a/app/models/better_together/joatu/response_link.rb b/app/models/better_together/joatu/response_link.rb new file mode 100644 index 000000000..9eb8020b8 --- /dev/null +++ b/app/models/better_together/joatu/response_link.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module BetterTogether + module Joatu + # ResponseLink represents an explicit user-created link between a source + # Offer/Request and the Offer/Request created in response. + class ResponseLink < ApplicationRecord + include Creatable + + belongs_to :source, polymorphic: true + belongs_to :response, polymorphic: true + + validates :source, :response, presence: true + + validate :disallow_same_type_link + + after_commit :notify_match, on: :create + + # Ensure source is in a state that can be responded to + validate :source_must_be_respondable + + after_commit :mark_source_matched, on: :create + + def self.permitted_attributes(id: true, destroy: false) + super + %i[ + source_type source_id response_type response_id + ] + end + + private + + # We only support Offer -> Request or Request -> Offer links + def disallow_same_type_link + return unless source && response + return if source.class != response.class + + errors.add(:base, 'Response must be of the opposite type to the source') + end + + # When a direct response link is created from an Offer -> Request, + # notify the offer creator about the match. + def notify_match # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + # Symmetric notifications for direct response links + if source.is_a?(BetterTogether::Joatu::Offer) && response.is_a?(BetterTogether::Joatu::Request) + return unless source.creator + + notifier = BetterTogether::Joatu::MatchNotifier.with(offer: source, request: response) + notifier.deliver_later([source.creator]) + elsif source.is_a?(BetterTogether::Joatu::Request) && response.is_a?(BetterTogether::Joatu::Offer) + return unless source.creator + + notifier = BetterTogether::Joatu::MatchNotifier.with(offer: response, request: source) + notifier.deliver_later([source.creator]) + end + rescue StandardError + # Do not raise — notifications should not break the main flow + Rails.logger.error("Failed to deliver match notification for ResponseLink #{id}") + end + + def source_must_be_respondable + return unless source.respond_to?(:status) + + allowed = %w[open matched] + return if allowed.include?(source.status) + + errors.add(:source, 'must be open or matched to create a response') + end + + def mark_source_matched + return unless source.respond_to?(:status) + + # Only transition an open source to matched; leave other states alone + source.status_matched! if source.status == 'open' + rescue StandardError => e + Rails.logger.error("Failed to mark source ##{source&.id} as matched: #{e.message}") + end + end + end +end diff --git a/app/models/better_together/navigation_item.rb b/app/models/better_together/navigation_item.rb index fa2de7175..49a6136db 100644 --- a/app/models/better_together/navigation_item.rb +++ b/app/models/better_together/navigation_item.rb @@ -29,6 +29,7 @@ class NavigationItem < ApplicationRecord # rubocop:todo Metrics/ClassLength navigation_areas: 'navigation_areas_url', pages: 'pages_url', people: 'people_url', + posts: 'posts_url', platforms: 'platforms_url', resource_permissions: 'resource_permissions_url', roles: 'roles_url', diff --git a/app/models/better_together/person.rb b/app/models/better_together/person.rb index 4ed82f2cc..4812b46f7 100644 --- a/app/models/better_together/person.rb +++ b/app/models/better_together/person.rb @@ -83,7 +83,13 @@ def self.primary_community_delegation_attrs translates :description_html, backend: :action_text - delegate :email, to: :user, allow_nil: true + # Return email from user if available, otherwise from contact details + def email + return user.email if user&.email.present? + + # Fallback to primary email address from contact details + email_addresses.find(&:primary_flag)&.email + end has_one_attached :profile_image has_one_attached :cover_image diff --git a/app/models/concerns/better_together/joatu/exchange.rb b/app/models/concerns/better_together/joatu/exchange.rb index 4985d7c21..b1fd5c838 100644 --- a/app/models/concerns/better_together/joatu/exchange.rb +++ b/app/models/concerns/better_together/joatu/exchange.rb @@ -55,11 +55,17 @@ module Exchange class_methods do def permitted_attributes(id: false, destroy: false) super + - %i[target_type target_id address_id status] + + %i[target_type target_id address_id status urgency] + [address_attributes: BetterTogether::Address.permitted_attributes(id: true, destroy: true)] end end + def self.included_in_models + included_module = self + Rails.application.eager_load! if Rails.env.development? # Ensure all models are loaded + ActiveRecord::Base.descendants.select { |model| model.included_modules.include?(included_module) } + end + # Return matching counterpart records (requests for offers, offers for requests) def find_matches BetterTogether::Joatu::Matchmaker.match(self) diff --git a/app/models/concerns/better_together/joatu/response_linkable.rb b/app/models/concerns/better_together/joatu/response_linkable.rb new file mode 100644 index 000000000..fc2a71cba --- /dev/null +++ b/app/models/concerns/better_together/joatu/response_linkable.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module BetterTogether + module Joatu + module ResponseLinkable # rubocop:todo Style/Documentation + extend ActiveSupport::Concern + + class_methods do + # Call this in a model to add the standardized response_link associations + # and nested attributes. Example: call `response_linkable` in Offer and Request models. + def response_linkable + has_many :response_links_as_source, class_name: 'BetterTogether::Joatu::ResponseLink', as: :source, + dependent: :nullify + has_many :response_links_as_response, class_name: 'BetterTogether::Joatu::ResponseLink', as: :response, + dependent: :nullify + + accepts_nested_attributes_for :response_links_as_source, :response_links_as_response, allow_destroy: true + end + + # Helper to provide the nested attributes for strong params + def response_link_permitted_attributes + [ + { response_links_as_response_attributes: BetterTogether::Joatu::ResponseLink.permitted_attributes }, + { response_links_as_source_attributes: BetterTogether::Joatu::ResponseLink.permitted_attributes } + ] + end + end + end + end +end diff --git a/app/notifiers/better_together/event_reminder_notifier.rb b/app/notifiers/better_together/event_reminder_notifier.rb new file mode 100644 index 000000000..e504e3d6b --- /dev/null +++ b/app/notifiers/better_together/event_reminder_notifier.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module BetterTogether + # Notifies attendees when an event is approaching + class EventReminderNotifier < ApplicationNotifier + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message do |config| + config.if = -> { should_notify? } + end + deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_reminder, params: :email_params do |config| + config.wait = 15.minutes + config.if = -> { send_email_notification? } + end + + validates :record, presence: true + required_param :reminder_type + + def event + record + end + + def reminder_type + params[:reminder_type] || '24_hours' + end + + def identifier + event.id + end + + def url + ::BetterTogether::Engine.routes.url_helpers.event_url(event, locale: I18n.locale) + end + + def title + I18n.t('better_together.notifications.event_reminder.title', + event_name: event.name, + default: 'Reminder: %s') + end + + def body + case reminder_type + when '24_hours' + body_24_hours + when '1_hour' + body_1_hour + else + body_generic + end + end + + def build_message(notification) + { + title:, + body:, + identifier:, + url:, + unread_count: notification.recipient.notifications.unread.count + } + end + + def email_params(_notification) + { + event: event, + person: recipient, + reminder_type: reminder_type + } + end + + private + + def body_24_hours + I18n.t('better_together.notifications.event_reminder.body_24h', + event_name: event.name, + starts_at: formatted_start_time, + default: '%s starts tomorrow at %s') + end + + def body_1_hour + I18n.t('better_together.notifications.event_reminder.body_1h', + event_name: event.name, + starts_at: formatted_start_time, + default: '%s starts in 1 hour at %s') + end + + def body_generic + I18n.t('better_together.notifications.event_reminder.body_generic', + event_name: event.name, + starts_at: formatted_start_time, + default: 'Reminder: %s starts at %s') + end + + def formatted_start_time + I18n.l(event.starts_at, format: :long) + end + + notification_methods do + delegate :event, to: :event + delegate :url, to: :event + delegate :identifier, to: :event + delegate :reminder_type, to: :event + + def send_email_notification? + recipient.email.present? && recipient.notify_by_email && should_send_email? + end + + def should_notify? + event.present? && event.starts_at.present? && + (!recipient.respond_to?(:notification_preferences) || + recipient.notification_preferences.fetch('event_reminders', true)) + end + + def should_send_email? + # Check for unread notifications for the recipient for the record's event + unread_notifications = recipient.notifications.where( + event_id: BetterTogether::EventReminderNotifier.where(params: { event_id: event.id }).select(:id), + read_at: nil + ).order(created_at: :desc) + + if unread_notifications.none? + false + else + # Only send one email per unread notifications per event + event.id == unread_notifications.last.event.record_id + end + end + end + end +end diff --git a/app/notifiers/better_together/event_update_notifier.rb b/app/notifiers/better_together/event_update_notifier.rb new file mode 100644 index 000000000..ebf2d479a --- /dev/null +++ b/app/notifiers/better_together/event_update_notifier.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module BetterTogether + # Notifies attendees when an event is updated + class EventUpdateNotifier < ApplicationNotifier + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message do |config| + config.if = -> { should_notify? } + end + deliver_by :email, mailer: 'BetterTogether::EventMailer', method: :event_update, params: :email_params do |config| + config.if = -> { recipient_has_email? && should_notify? } + end + + param :event, :changed_attributes + + notification_methods do + delegate :event, :changed_attributes, to: :params + end + + def event = params[:event] + def changed_attributes = params[:changed_attributes] || [] + + def title + I18n.t('better_together.notifications.event_update.title', + event_name: event.name, + default: 'Event updated: %s') + end + + def body + change_list = changed_attributes.map do |attr| + I18n.t("better_together.notifications.event_update.changes.#{attr}", default: attr.humanize) + end.join(', ') + + I18n.t('better_together.notifications.event_update.body', + event_name: event.name, + changes: change_list, + default: '%s has been updated: %s') + end + + def build_message(_notification) + { title:, body: } + end + + def email_params(_notification) + { event:, changed_attributes: } + end + + notification_methods do + def recipient_has_email? + recipient.respond_to?(:email) && recipient.email.present? && + (!recipient.respond_to?(:notification_preferences) || + recipient.notification_preferences.fetch('notify_by_email', true)) + end + + def should_notify? + event.present? && changed_attributes.present? && + (!recipient.respond_to?(:notification_preferences) || + recipient.notification_preferences.fetch('event_updates', true)) + end + end + end +end diff --git a/app/notifiers/better_together/joatu/match_notifier.rb b/app/notifiers/better_together/joatu/match_notifier.rb index 2812ca269..d8aabd603 100644 --- a/app/notifiers/better_together/joatu/match_notifier.rb +++ b/app/notifiers/better_together/joatu/match_notifier.rb @@ -4,8 +4,12 @@ module BetterTogether module Joatu # Notifies creators when a new offer or request matches class MatchNotifier < ApplicationNotifier - deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message - deliver_by :email, mailer: 'BetterTogether::JoatuMailer', method: :new_match, params: :email_params + deliver_by :action_cable, channel: 'BetterTogether::NotificationsChannel', message: :build_message do |config| + config.if = -> { should_notify? } + end + deliver_by :email, mailer: 'BetterTogether::JoatuMailer', method: :new_match, params: :email_params do |config| + config.if = -> { recipient_has_email? && should_notify? } + end param :offer, :request @@ -32,6 +36,58 @@ def email_params(_notification) { offer:, request: } end + notification_methods do + def current_offer_gid + offer.respond_to?(:to_global_id) ? offer.to_global_id.to_s : offer.to_s + end + + def current_request_gid + request.respond_to?(:to_global_id) ? request.to_global_id.to_s : request.to_s + end + + def recipient_has_email? + recipient.respond_to?(:email) && recipient.email.present? && + (!recipient.respond_to?(:notification_preferences) || recipient.notification_preferences['notify_by_email']) + end + + # Avoid duplicate unread notifications for the same offer/request pair per recipient + def should_notify? # rubocop:todo Metrics/AbcSize + unread = recipient.notifications.unread.includes(:event) + o_gid = current_offer_gid + r_gid = current_request_gid + unread.none? do |notification| + ev = notification.event + next false unless ev.is_a?(BetterTogether::Joatu::MatchNotifier) + + params = ev.respond_to?(:params) ? ev.params : {} + params['offer'].to_s == o_gid && params['request'].to_s == r_gid + end + end + end + + # Prevent creating a new notification record if an unread one exists for this pair + def deliver(recipient) + return if duplicate_for?(recipient) + + super + end + + private + + def duplicate_for?(recipient) + unread = recipient.notifications.unread.includes(:event) + unread.any? do |notification| + ev = notification.event + next false unless ev.is_a?(BetterTogether::Joatu::MatchNotifier) + + begin + ev.offer&.id == offer.id && ev.request&.id == request.id + rescue StandardError + false + end + end + end + # Ensure immediate delivery in tests and synchronous contexts # without relying on the ActiveJob test adapter. def deliver_now(recipient) diff --git a/app/policies/better_together/event_attendance_policy.rb b/app/policies/better_together/event_attendance_policy.rb new file mode 100644 index 000000000..24d7e4a89 --- /dev/null +++ b/app/policies/better_together/event_attendance_policy.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module BetterTogether + # Access control for event attendance (RSVPs) + class EventAttendancePolicy < ApplicationPolicy + def create? + user.present? + end + + def update? + user.present? && record.person_id == agent&.id + end + + alias rsvp_interested? update? + alias rsvp_going? update? + + def destroy? + update? + end + end +end diff --git a/app/policies/better_together/event_policy.rb b/app/policies/better_together/event_policy.rb index b4e3ae738..174862c91 100644 --- a/app/policies/better_together/event_policy.rb +++ b/app/policies/better_together/event_policy.rb @@ -11,6 +11,8 @@ def show? (record.privacy_public? && record.starts_at.present?) || creator_or_manager || event_host_member? end + alias ics? show? + def update? creator_or_manager || event_host_member? end diff --git a/app/policies/better_together/joatu/offer_policy.rb b/app/policies/better_together/joatu/offer_policy.rb index c37df8ba9..7c75f9410 100644 --- a/app/policies/better_together/joatu/offer_policy.rb +++ b/app/policies/better_together/joatu/offer_policy.rb @@ -9,6 +9,9 @@ def show? = user.present? def create? = user.present? alias new? create? + # Permission helper for the "respond with request" flow (creating an Offer from a Request) + def respond_with_request? = create? + def update? return false unless user.present? @@ -19,16 +22,54 @@ def update? def destroy? return false unless user.present? + # Prevent destroy if there are any agreements for this offer — applies to everyone + return false if record.respond_to?(:agreements) && record.agreements.exists? + + # Platform managers or the creator may destroy when there are no agreements permitted_to?('manage_platform') || record.creator_id == agent&.id end class Scope < ApplicationPolicy::Scope # rubocop:todo Style/Documentation - def resolve + # rubocop:todo Metrics/MethodLength + def resolve # rubocop:todo Metrics/AbcSize, Metrics/MethodLength # For now, allow authenticated users to see all offers. return scope.none unless user.present? - scope.all + # Platform managers see everything + return scope.all if permitted_to?('manage_platform') + + agent_id = agent&.id + + # Offers that are not responses to another resource (no response_link where response is this offer) + # rubocop:todo Layout/LineLength + not_responses = scope.left_joins(:response_links_as_response).where(better_together_joatu_response_links: { id: nil }) + # rubocop:enable Layout/LineLength + + # Offers owned by the agent + owned = scope.where(creator_id: agent_id) + + # Offers that are responses to a Request where that Request's creator is the agent + rl = BetterTogether::Joatu::ResponseLink.arel_table + offers = BetterTogether::Joatu::Offer.arel_table + requests = BetterTogether::Joatu::Request.arel_table + + # rubocop:todo Layout/LineLength + # build: JOIN response_links rl ON rl.response_type = 'BetterTogether::Joatu::Offer' AND rl.response_id = offers.id + # rubocop:enable Layout/LineLength + # JOIN requests r ON rl.source_type = 'BetterTogether::Joatu::Request' AND rl.source_id = requests.id + join_on_rl = rl[:response_type].eq(BetterTogether::Joatu::Offer.name).and(rl[:response_id].eq(offers[:id])) + join_on_requests = rl[:source_type].eq(BetterTogether::Joatu::Request.name).and(rl[:source_id].eq(requests[:id])) + + join_sources = offers.join(rl, Arel::Nodes::InnerJoin).on(join_on_rl).join(requests, Arel::Nodes::InnerJoin).on(join_on_requests).join_sources + + response_to_my_request = scope.joins(join_sources).where(requests[:creator_id].eq(agent_id)) + + # Combine the allowed sets: not_responses (public) OR owned OR response_to_my_request + # rubocop:todo Layout/LineLength + scope.where(id: not_responses.select(:id)).or(scope.where(id: owned.select(:id))).or(scope.where(id: response_to_my_request.select(:id))) + # rubocop:enable Layout/LineLength end + # rubocop:enable Metrics/MethodLength end end end diff --git a/app/policies/better_together/joatu/request_policy.rb b/app/policies/better_together/joatu/request_policy.rb index d87d229e8..f9dbcade1 100644 --- a/app/policies/better_together/joatu/request_policy.rb +++ b/app/policies/better_together/joatu/request_policy.rb @@ -9,6 +9,9 @@ def show? = user.present? def create? = user.present? alias new? create? + # Permission helper for the "respond with offer" flow (creating an Offer from a Request) + def respond_with_offer? = create? + def update? return false unless user.present? @@ -20,15 +23,52 @@ def update? def destroy? return false unless user.present? + # Prevent destroy if there are any agreements for this request — applies to everyone + return false if record.respond_to?(:agreements) && record.agreements.exists? + + # Platform managers or the creator may destroy when there are no agreements permitted_to?('manage_platform') || record.creator_id == agent&.id end class Scope < ApplicationPolicy::Scope # rubocop:todo Style/Documentation - def resolve + # rubocop:todo Metrics/MethodLength + def resolve # rubocop:todo Metrics/AbcSize, Metrics/MethodLength return scope.none unless user.present? - scope.all + # Platform managers see everything + return scope.all if permitted_to?('manage_platform') + + agent_id = agent&.id + + # Requests that are not responses to another resource (no response_link where response is this request) + # rubocop:todo Layout/LineLength + not_responses = scope.left_joins(:response_links_as_response).where(better_together_joatu_response_links: { id: nil }) + # rubocop:enable Layout/LineLength + + # Requests owned by the agent + owned = scope.where(creator_id: agent_id) + + # Requests that are responses to an Offer owned by the agent + rl = BetterTogether::Joatu::ResponseLink.arel_table + offers = BetterTogether::Joatu::Offer.arel_table + requests = BetterTogether::Joatu::Request.arel_table + + # rubocop:todo Layout/LineLength + # build: JOIN response_links rl ON rl.response_type = 'BetterTogether::Joatu::Request' AND rl.response_id = requests.id + # rubocop:enable Layout/LineLength + # JOIN offers o ON rl.source_type = 'BetterTogether::Joatu::Offer' AND rl.source_id = offers.id + join_on_rl = rl[:response_type].eq(BetterTogether::Joatu::Request.name).and(rl[:response_id].eq(requests[:id])) + join_on_offers = rl[:source_type].eq(BetterTogether::Joatu::Offer.name).and(rl[:source_id].eq(offers[:id])) + + join_sources = requests.join(rl, Arel::Nodes::InnerJoin).on(join_on_rl).join(offers, Arel::Nodes::InnerJoin).on(join_on_offers).join_sources + + response_to_my_offer = scope.joins(join_sources).where(offers[:creator_id].eq(agent_id)) + + # rubocop:todo Layout/LineLength + scope.where(id: not_responses.select(:id)).or(scope.where(id: owned.select(:id))).or(scope.where(id: response_to_my_offer.select(:id))) + # rubocop:enable Layout/LineLength end + # rubocop:enable Metrics/MethodLength end end end diff --git a/app/services/better_together/joatu/matchmaker.rb b/app/services/better_together/joatu/matchmaker.rb index d1d743f98..e26fa0d68 100644 --- a/app/services/better_together/joatu/matchmaker.rb +++ b/app/services/better_together/joatu/matchmaker.rb @@ -8,38 +8,79 @@ class Matchmaker # - If given a Request -> returns matching Offers # - If given an Offer -> returns matching Requests # rubocop:todo Metrics/MethodLength - # rubocop:todo Metrics/CyclomaticComplexity - def self.match(record) # rubocop:todo Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + def self.match(record) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + offer_klass = BetterTogether::Joatu::Offer + request_klass = BetterTogether::Joatu::Request + rl_klass = BetterTogether::Joatu::ResponseLink + case record - when BetterTogether::Joatu::Request - offers = BetterTogether::Joatu::Offer.status_open # rubocop:todo Layout/IndentationWidth - if record.category_ids.any? - offers = offers.joins(:categories) - .where(BetterTogether::Joatu::Category.table_name => { id: record.category_ids }) - end + when request_klass + candidates = offer_klass.status_open + # Category overlap if any + if record.category_ids.any? + candidates = candidates.joins(:categories) + .where(BetterTogether::Joatu::Category.table_name => { id: record.category_ids }) + end - offers = offers.where(target_type: record.target_type) - offers = offers.where(target_id: record.target_id) if record.target_id.present? - offers = offers.where(target_id: nil) if record.target_id.blank? + # Target type must align; target_id supports wildcard semantics + candidates = candidates.where(target_type: record.target_type) + if record.target_id.present? + candidates = candidates.where( + "#{offer_klass.table_name}.target_id = ? OR #{offer_klass.table_name}.target_id IS NULL", + record.target_id + ) + end - offers.where.not(creator_id: record.creator_id).distinct - when BetterTogether::Joatu::Offer - requests = BetterTogether::Joatu::Request.status_open # rubocop:todo Layout/IndentationWidth - if record.category_ids.any? - requests = requests.joins(:categories) - .where(BetterTogether::Joatu::Category.table_name => { id: record.category_ids }) - end + # Exclude same creator + candidates = candidates.where.not(creator_id: record.creator_id) + + # Pair-specific ResponseLink exclusion (exclude if Request -> Offer link already exists for this pair) + join_sql = ActiveRecord::Base.send( + :sanitize_sql_array, + [ + # rubocop:todo Layout/LineLength + "LEFT JOIN #{rl_klass.table_name} AS rl ON rl.source_type = ? AND rl.source_id = ? AND rl.response_type = ? AND rl.response_id = #{offer_klass.table_name}.id", + # rubocop:enable Layout/LineLength + request_klass.name, record.id, offer_klass.name + ] + ) + candidates = candidates.joins(join_sql).where('rl.id IS NULL') + + candidates.distinct + when offer_klass + candidates = request_klass.status_open + if record.category_ids.any? + candidates = candidates.joins(:categories) + .where(BetterTogether::Joatu::Category.table_name => { id: record.category_ids }) + end + + candidates = candidates.where(target_type: record.target_type) + if record.target_id.present? + candidates = candidates.where( + "#{request_klass.table_name}.target_id = ? OR #{request_klass.table_name}.target_id IS NULL", + record.target_id + ) + end + + candidates = candidates.where.not(creator_id: record.creator_id) - requests = requests.where(target_type: record.target_type) - requests = requests.where(target_id: record.target_id) if record.target_id.present? - requests = requests.where(target_id: nil) if record.target_id.blank? + # Pair-specific ResponseLink exclusion (exclude if Offer -> Request link already exists for this pair) + join_sql = ActiveRecord::Base.send( + :sanitize_sql_array, + [ + # rubocop:todo Layout/LineLength + "LEFT JOIN #{rl_klass.table_name} AS rl ON rl.source_type = ? AND rl.source_id = ? AND rl.response_type = ? AND rl.response_id = #{request_klass.table_name}.id", + # rubocop:enable Layout/LineLength + offer_klass.name, record.id, request_klass.name + ] + ) + candidates = candidates.joins(join_sql).where('rl.id IS NULL') - requests.where.not(creator_id: record.creator_id).distinct + candidates.distinct else - raise ArgumentError, "Unsupported record type: #{record.class.name}" # rubocop:todo Layout/IndentationWidth + raise ArgumentError, "Unsupported record type: #{record.class.name}" end end - # rubocop:enable Metrics/CyclomaticComplexity # rubocop:enable Metrics/MethodLength end end diff --git a/app/views/better_together/event_mailer/event_reminder.html.erb b/app/views/better_together/event_mailer/event_reminder.html.erb new file mode 100644 index 000000000..a261ef693 --- /dev/null +++ b/app/views/better_together/event_mailer/event_reminder.html.erb @@ -0,0 +1,39 @@ + + +

<%= t('.greeting', recipient_name: @recipient.name) %>

+ +

<%= t('.reminder_message', event_name: @event.name) %>

+ +

<%= @event.name %>

+ +<% if @event.description.present? %> +

<%= t('.description') %>:

+

<%= @event.description %>

+<% end %> + +

<%= t('.when') %>:

+<% if @event.starts_at %> +

<%= l(@event.starts_at, format: :long) %>

+ <% if @event.ends_at %> +

<%= t('.ends_at') %>: <%= l(@event.ends_at, format: :long) %>

+ <% end %> +<% else %> +

<%= t('.time_tbd') %>

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

<%= t('.duration') %>: <%= t('.hours', count: @event.duration_in_hours) %>

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

<%= t('.location') %>:

+

<%= @event.location_display_name %>

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

<%= link_to t('.register_link'), @event.registration_url, class: 'text-decoration-none' %>

+<% end %> + +

<%= t('.view_event_link_html', link: link_to(@event.name, event_url(@event))) %>

+ +

<%= t('.signature_html', platform: @platform&.name || 'Better Together') %>

diff --git a/app/views/better_together/event_mailer/event_update.html.erb b/app/views/better_together/event_mailer/event_update.html.erb new file mode 100644 index 000000000..141a514df --- /dev/null +++ b/app/views/better_together/event_mailer/event_update.html.erb @@ -0,0 +1,52 @@ + + +

<%= t('.greeting', recipient_name: @recipient.name) %>

+ +<% if @changed_attributes.length > 1 %> +

<%= t('.update_message_plural', event_name: @event.name) %>

+<% else %> +

<%= t('.update_message_singular', event_name: @event.name) %>

+<% end %> + +

<%= @event.name %>

+ +

<%= t('.changes_made') %>:

+
    + <% @changed_attributes.each do |attribute| %> +
  • <%= t(".changed_attributes.#{attribute}", default: attribute.humanize) %>
  • + <% end %> +
+ +

<%= t('.current_details') %>:

+ +<% if @event.description.present? %> +

<%= t('.description') %>:

+

<%= @event.description %>

+<% end %> + +

<%= t('.when') %>:

+<% if @event.starts_at %> +

<%= l(@event.starts_at, format: :long) %>

+ <% if @event.ends_at %> +

<%= t('.ends_at') %>: <%= l(@event.ends_at, format: :long) %>

+ <% end %> +<% else %> +

<%= t('.time_tbd') %>

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

<%= t('.duration') %>: <%= t('.hours', count: @event.duration_in_hours) %>

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

<%= t('.location') %>:

+

<%= @event.location_display_name %>

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

<%= link_to t('.register_link'), @event.registration_url, class: 'text-decoration-none' %>

+<% end %> + +

<%= t('.view_event_link_html', link: link_to(@event.name, event_url(@event))) %>

+ +

<%= t('.signature_html', platform: @platform&.name || 'Better Together') %>

diff --git a/app/views/better_together/events/_form.html.erb b/app/views/better_together/events/_form.html.erb index 8f82bd943..c3924f182 100644 --- a/app/views/better_together/events/_form.html.erb +++ b/app/views/better_together/events/_form.html.erb @@ -123,16 +123,76 @@
<%= form.label :location, t('better_together.events.labels.location') %> -
+
<%= form.fields_for :location, (event.location || event.build_location) do |location_form| %> <%= location_form.hidden_field :locatable_id %> <%= location_form.hidden_field :locatable_type %> - <%= location_form.text_field :name, class: 'form-control mb-4' %> - - - <%# location_form.text_field :location_type, class: 'form-control', placeholder: 'location type' %> - <%# location_form.text_field :location_id, class: 'form-control', placeholder: 'location id' %> + +
+ +
+ + data-action="change->better_together--location-selector#toggleLocationType"> + + + + data-action="change->better_together--location-selector#toggleLocationType"> + + + + data-action="change->better_together--location-selector#toggleLocationType"> + +
+
+ + +
+ <%= location_form.label :name, t('better_together.events.labels.location_name'), class: 'form-label' %> + <%= location_form.text_field :name, class: 'form-control', + placeholder: t('better_together.events.placeholders.location_name') %> + <%= t('better_together.events.hints.location_name') %> +
+ + +
+ <%= location_form.label :location_id, t('better_together.events.labels.select_address'), class: 'form-label' %> + <%= location_form.collection_select :location_id, + BetterTogether::Geography::LocatableLocation.available_addresses_for(current_person), + :id, :to_formatted_s, + { prompt: t('better_together.events.prompts.select_address') }, + { class: 'form-select', + data: { action: 'change->better_together--location-selector#updateAddressType' } } %> + <%= location_form.hidden_field :location_type, value: 'BetterTogether::Address', + data: { better_together__location_selector_target: 'addressTypeField' } %> + <%= t('better_together.events.hints.select_address') %> +
+ + +
+ <%= location_form.label :location_id, t('better_together.events.labels.select_building'), class: 'form-label' %> + <%= location_form.collection_select :location_id, + BetterTogether::Geography::LocatableLocation.available_buildings_for(current_person), + :id, :name, + { prompt: t('better_together.events.prompts.select_building') }, + { class: 'form-select', + data: { action: 'change->better_together--location-selector#updateBuildingType' } } %> + <%= location_form.hidden_field :location_type, value: 'BetterTogether::Infrastructure::Building', + data: { better_together__location_selector_target: 'buildingTypeField' } %> + <%= t('better_together.events.hints.select_building') %> +
<% end %>
<% if event.errors[:location].any? %> diff --git a/app/views/better_together/events/show.html.erb b/app/views/better_together/events/show.html.erb index 137ad5d48..a8544947d 100644 --- a/app/views/better_together/events/show.html.erb +++ b/app/views/better_together/events/show.html.erb @@ -35,6 +35,9 @@ destroy_path: policy(@resource).destroy? ? event_path(@resource) : nil, destroy_confirm: t('globals.confirm_delete'), destroy_aria_label: 'Delete Record' %> +
+ <%= 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' %> +
@@ -43,6 +46,27 @@ + + <% if current_person %> + <% attendance = BetterTogether::EventAttendance.find_by(event: @event, person: current_person) %> +
+
+ <%= button_to t('better_together.events.rsvp_interested', default: 'Interested'), rsvp_interested_event_path(@event), method: :post, class: "btn btn-outline-primary #{'active' if attendance&.status == 'interested'}" %> + <%= button_to t('better_together.events.rsvp_going', default: 'Going'), rsvp_going_event_path(@event), method: :post, class: "btn btn-primary #{'active' if attendance&.status == 'going'}" %> + <% if attendance %> + <%= button_to t('better_together.events.rsvp_cancel', default: 'Cancel RSVP'), rsvp_cancel_event_path(@event), method: :delete, class: 'btn btn-outline-danger' %> + <% end %> +
+
+ <% going_count = BetterTogether::EventAttendance.where(event: @event, status: 'going').count %> + <% interested_count = BetterTogether::EventAttendance.where(event: @event, status: 'interested').count %> + + <%= t('better_together.events.rsvp_counts', default: 'Going: %{going} · Interested: %{interested}', going: going_count, interested: interested_count) %> + +
+
+ <% end %> +
@@ -96,4 +120,4 @@
<%= share_buttons(shareable: @event) if @event.privacy_public? %> - \ No newline at end of file + diff --git a/app/views/better_together/joatu/offers/_form.html.erb b/app/views/better_together/joatu/offers/_form.html.erb index f17b164a9..9536b5dcc 100644 --- a/app/views/better_together/joatu/offers/_form.html.erb +++ b/app/views/better_together/joatu/offers/_form.html.erb @@ -1,4 +1,3 @@ - <%= form_with(model: joatu_offer, class: 'form', id: dom_id(joatu_offer, 'form'), data: { controller: "better_together--form-validation" }) do |form| %> <%= render 'better_together/shared/help_banner', id: 'joatu-offers-form', @@ -22,6 +21,19 @@ <%= yield :resource_toolbar %> + <%# If this offer is being created in response to another resource, show that resource so the user knows what they're responding to. The controller sets @source and authorizes access. %> + <% if defined?(@source) && @source.present? %> +
+
+
<%= t('better_together.joatu.responses.in_response_to', default: 'In response to') %>
+

<%= link_to(@source.name, @source, class: 'text-decoration-none') if @source.respond_to?(:name) %>

+ <% if @source.respond_to?(:description) && @source.description.present? %> +
<%= sanitize(@source.description.to_s) %>
+ <% end %> +
+
+ <% end %> + <% if joatu_offer.errors.any? %>

<%= t('helpers.errors.heading') %>

@@ -41,6 +53,17 @@ <%= render partial: 'better_together/shared/translated_rich_text_field', locals: { model: joatu_offer, form: form, attribute: 'description' } %>
+
+
+ <%= required_label form, :status %> + <%= form.select :status, BetterTogether::Joatu::Exchange::STATUS_VALUES.values.map { |v| [v.humanize, v] }, {}, class: 'form-select', data: { controller: 'better_together--slim_select' } %> +
+
+ <%= required_label form, :urgency %> + <%= form.select :urgency, BetterTogether::Joatu::Exchange::URGENCY_VALUES.values.map { |v| [v.humanize, v] }, {}, class: 'form-select', data: { controller: 'better_together--slim_select' } %> +
+
+
<%= required_label form, :categories %> <%= form.select :category_ids, options_from_collection_for_select(BetterTogether::Joatu::Category.positioned.all.includes(:string_translations), :id, :name, joatu_offer.category_ids), { include_blank: true, multiple: true }, class: 'form-select', data: { controller: 'better_together--slim_select' } %> @@ -58,5 +81,16 @@
<% end %> + <%# Prefill marker: add nested response_links attributes when creating in response to a source %> + <% if params[:source_type].present? && params[:source_id].present? %> + <%= form.fields_for :response_links_as_response do |rl| %> + <%= rl.hidden_field :source_type, value: params[:source_type] %> + <%= rl.hidden_field :source_id, value: params[:source_id] %> + <%= rl.hidden_field :response_type, value: joatu_offer.class.to_s %> + <%# response_id will be set by Rails after creation; include creator_id so ResponseLink can be created if needed in controller fallback %> + <%= rl.hidden_field :creator_id, value: current_person&.id %> + <% end %> + <% end %> + <%= yield :resource_toolbar %> <% end %> diff --git a/app/views/better_together/joatu/offers/_offer_list_item.html.erb b/app/views/better_together/joatu/offers/_offer_list_item.html.erb index 80bb53c58..b1b5e3603 100644 --- a/app/views/better_together/joatu/offers/_offer_list_item.html.erb +++ b/app/views/better_together/joatu/offers/_offer_list_item.html.erb @@ -1,11 +1,33 @@ <% joatu_offer ||= offer_list_item || offer %> -

<%= joatu_offer.name %>

+

+ <%= joatu_offer.name %> + + +

<%= t('better_together.joatu.type.offer', default: 'Offer') %> | <%= t('better_together.joatu.by', default: 'by') %> <%= joatu_offer.creator&.name || joatu_offer.creator&.to_s %> | <%= l(joatu_offer.created_at, format: :long) if joatu_offer.created_at %>
+
+ <%= joatu_offer.status %> + <%= joatu_offer.urgency %> +

<%= truncate(joatu_offer.description.to_plain_text, length: 100) %>

<% joatu_offer.categories.first(3).each do |cat| %> diff --git a/app/views/better_together/joatu/offers/index.html.erb b/app/views/better_together/joatu/offers/index.html.erb index 29c4cb08c..9e85f9b5d 100644 --- a/app/views/better_together/joatu/offers/index.html.erb +++ b/app/views/better_together/joatu/offers/index.html.erb @@ -10,17 +10,17 @@ i18n_key: 'better_together.joatu.help.offers.index' %>
+ <% if policy(resource_class).create? %> + <%= link_to new_joatu_offer_path, class: 'btn btn-success float-end', 'aria-label' => 'Add Offer' do %> + <%= t('better_together.joatu.offers.new_offer', default: 'New Offer') %> + <% end %> + <% end %> <%= render 'better_together/joatu/shared/offers_and_requests/list_form', search_path: joatu_offers_path, order_options: [[t('better_together.joatu.search.newest', default: 'Newest'), 'newest'], [t('better_together.joatu.search.oldest', default: 'Oldest'), 'oldest']], type_options: @category_options %>
- <% if policy(resource_class).create? %> - <%= link_to new_joatu_offer_path, class: 'btn btn-success float-end', 'aria-label' => 'Add Offer' do %> - <%= t('better_together.joatu.offers.new_offer', default: 'New Offer') %> - <% end %> - <% end %>
diff --git a/app/views/better_together/joatu/offers/show.html.erb b/app/views/better_together/joatu/offers/show.html.erb index a888f9e3e..35cb00a24 100644 --- a/app/views/better_together/joatu/offers/show.html.erb +++ b/app/views/better_together/joatu/offers/show.html.erb @@ -8,13 +8,23 @@ <%= render 'better_together/shared/help_banner', id: 'joatu-offers-show', i18n_key: 'better_together.joatu.help.offers.show' %> -

<%= @joatu_offer.name %>

+

<%= @joatu_offer.name %>

+ +
+
+ <%= render 'better_together/people/mention', person: @joatu_offer.creator, flex_layout: 'flex-row', flex_align_items: 'center' %> +
+ | <%= l(@joatu_offer.created_at, format: :long) if @joatu_offer.created_at %> | <%= @joatu_offer.status %> | <%= @joatu_offer.urgency %> +
<%= @joatu_offer.description %>
+ <%# Determine whether the current_person has an agreement involving this offer (used elsewhere) %> + <% user_agreement = agreement_for_current_person(@joatu_offer) if defined?(current_person) && current_person %> + <% if policy(resource_class).index? %> <%= link_to t('better_together.joatu.offers.back_to_offers', default: 'Back to Offers'), joatu_offers_path, class: 'btn btn-sm btn-outline-secondary' %> <% end %> @@ -28,11 +38,117 @@ <%= t('globals.delete') %> <% end %> <% end %> + + <%# Button to create a Request prefilled from this Offer (hidden by helper predicate) %> + <% if respond_with_request_visible?(@joatu_offer) %> + <%= link_to new_joatu_request_path(source_type: 'BetterTogether::Joatu::Offer', source_id: @joatu_offer.id), class: 'btn btn-primary btn-sm ms-2', 'aria-label' => 'Respond with Request' do %> + <%= t('better_together.joatu.respond_with_request', default: 'Respond with Request') %> + <% end %> + <% end %>
-<%# Render potential matches for the creator %> -<% if defined?(current_person) && current_person == @joatu_offer.creator %> - <%= render 'better_together/joatu/shared/match_list', resource: @joatu_offer %> - +<%# If this offer was created in response to a Request, show that Request and skip potential matches %> +<% source_link = @joatu_offer.response_links_as_response.find_by(source_type: 'BetterTogether::Joatu::Request') %> +<% if source_link %> + <% source_request = source_link.source %> +
+
+
+
<%= t('better_together.joatu.offers.source_request_heading', default: 'Responding to Request') %>
+

<%= link_to source_request.name, joatu_request_path(source_request), class: 'text-decoration-none' %>

+
+
+ <%= render 'better_together/people/mention', person: source_request.creator, flex_layout: 'flex-row', flex_align_items: 'center' %> +
+ | <%= l(source_request.created_at, format: :long) if source_request.created_at %> +
+
<%= simple_format(source_request.description.to_plain_text) %>
+
+
+
+<% else %> + <%# Show response/agreement for a viewer who already responded to this offer, otherwise render potential matches for the offer creator %> + <% user_response_link = nil %> + <% linked_response_for_creator = nil %> + <%# user_agreement is already computed above when current_person is present %> + <% if defined?(current_person) && current_person %> + <%# Find a ResponseLink where this offer is the source and the linked response is a Request created by the current person %> + <% user_response_link = @joatu_offer.response_links_as_source.detect do |rl| + rl.response.is_a?(BetterTogether::Joatu::Request) && rl.response.creator == current_person + end %> + + <%# If the current_person is the offer creator, check whether any direct linked Request exists for this offer (show to creator) + # We intentionally pick the first linked response; if there are multiple, consider listing them or linking to an index in future. + %> + <% if current_person == @joatu_offer.creator %> + <% linked_response_for_creator = @joatu_offer.response_links_as_source.find_by(response_type: 'BetterTogether::Joatu::Request') %> + <% end %> + + <%# user_agreement is already assigned above %> + <% end %> + + <% if user_response_link %> + <% resp = user_response_link.response %> +
+
+
+
<%= t('better_together.joatu.offers.your_response_heading', default: 'Your Response') %>
+

<%= link_to resp.name, joatu_request_path(resp), class: 'text-decoration-none' %>

+
+
+ <%= render 'better_together/people/mention', person: resp.creator, flex_layout: 'flex-row', flex_align_items: 'center' %> +
+ | <%= l(resp.created_at, format: :long) if resp.created_at %> +
+
<%= simple_format(resp.description.to_plain_text) %>
+
+
+
+ <% elsif linked_response_for_creator %> + <% resp = linked_response_for_creator.response %> +
+
+
+
<%= t('better_together.joatu.offers.response_to_your_offer_heading', default: 'Response to your Offer') %>
+

<%= link_to resp.name, joatu_request_path(resp), class: 'text-decoration-none' %>

+
+
+ <%= render 'better_together/people/mention', person: resp.creator, flex_layout: 'flex-row', flex_align_items: 'center' %> +
+ | <%= l(resp.created_at, format: :long) if resp.created_at %> +
+
<%= simple_format(resp.description.to_plain_text) %>
+
+
+
+ <% elsif user_agreement %> + <% agr = user_agreement %> + <% linked_request = agr.request %> +
+
+
+
<%= t('better_together.joatu.offers.agreement_heading', default: 'Agreement') %>
+

<%= t('better_together.joatu.offers.agreement_with', default: 'Agreement with') %> <%= (linked_request&.creator&.name || linked_request&.creator&.to_s) if linked_request %>

+ <% if linked_request %> +

<%= link_to linked_request.name, joatu_request_path(linked_request), class: 'text-decoration-none' %>

+

<%= l(agr.created_at, format: :long) if agr.created_at %>

+
<%= truncate(strip_tags(linked_request.description.to_plain_text), length: 250) %>
+
+ <%= link_to joatu_agreement_path(agr), class: 'btn btn-sm btn-outline-success' do %> + <%= t('better_together.joatu.offers.view_agreement', default: 'View Agreement') %> + <% end %> +
+ <% end %> +
+
+
+ <% else %> + <%# Render potential matches for the creator (unchanged behavior) - but skip if this offer has a linked response to avoid duplicate/irrelevant matches + # Also skip matches if this offer itself is a response to a Request (source_link presence) + %> + <% if defined?(current_person) && current_person == @joatu_offer.creator && @joatu_offer.response_links_as_source.find_by(response_type: 'BetterTogether::Joatu::Request').blank? && @joatu_offer.response_links_as_response.find_by(source_type: 'BetterTogether::Joatu::Request').blank? %> + <%= render 'better_together/joatu/shared/match_list', resource: @joatu_offer %> + <% end %> + <% end %> <% end %> diff --git a/app/views/better_together/joatu/requests/_form.html.erb b/app/views/better_together/joatu/requests/_form.html.erb index ec84f6ec3..fd8fc4505 100644 --- a/app/views/better_together/joatu/requests/_form.html.erb +++ b/app/views/better_together/joatu/requests/_form.html.erb @@ -1,4 +1,3 @@ - <%# locals(joatu_request:) %> <%= form_with(model: joatu_request, class: 'form', id: dom_id(joatu_request, 'form'), data: { controller: "better_together--form-validation" }) do |form| %> @@ -6,6 +5,10 @@ id: 'joatu-requests-form', i18n_key: 'better_together.joatu.help.requests.form' %> <%= form.hidden_field :creator_id, value: current_person&.id unless form.object.creator_id %> + + <%= form.hidden_field :source_type, value: params[:source_type] if params[:source_type].present? %> + <%= form.hidden_field :source_id, value: params[:source_id] if params[:source_id].present? %> + <% content_for :resource_toolbar do %>