diff --git a/Gemfile b/Gemfile index 8be04620..9545eff3 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,7 @@ gem 'appmap', '0.102.1', :groups => [:development, :test] # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem 'rails', '~> 7.0.5' +gem 'rails', '~> 7.0.7' # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] gem 'sprockets-rails' @@ -36,7 +36,7 @@ gem 'cssbundling-rails' gem 'jbuilder' # Faster JSON processing [https://github.com/ohler55/oj/blob/develop/pages/Rails.md] -gem 'oj', '~> 3.15.0' +gem 'oj', '~> 3.16.0' # Use Redis adapter to run Action Cable in production gem 'redis', '~> 5.0.5' @@ -95,6 +95,11 @@ gem 'bootsnap', require: false # Used for badges, reputation, etc. gem 'merit' +# Add Meta Tags [https://github.com/kpumuk/meta-tags] +# the main reason is to prevent stimulus controllers from being double loaded +# Read [https://blog.corsego.com/double-loading-stimulus-controllers] for more details +gem 'meta-tags', '~> 2.18.0' + # Mailer - Sendgrid [https://github.com/sendgrid/sendgrid-ruby] gem 'sendgrid-actionmailer', '~> 3.2.0' @@ -111,7 +116,7 @@ gem 'nokogiri', '~> 1.15.0' gem 'down', '~> 5.0' # Tracking changes using PaperTrail [https://github.com/paper-trail-gem/paper_trail] -gem 'paper_trail' +gem 'paper_trail', '~> 15.0.0' # Active storage validations [https://github.com/igorkasyanchuk/active_storage_validations] gem 'active_storage_validations', '~> 1.0.0' @@ -145,13 +150,13 @@ gem 'groupdate' gem 'chartkick' # openAI [https://github.com/alexrudall/ruby-openai] -gem 'ruby-openai', '~> 4.2.0' +gem 'ruby-openai', '~> 5.0.0' # Adding pgsearch gem 'pg_search', '~> 2.3.6' # Stripe (payment, subscription processing) [https://github.com/stripe/stripe-ruby] -gem 'stripe', '~> 8.6.0' +gem 'stripe', '~> 9.0.0' # To enable retry in Faraday v2.0+ gem 'faraday-retry', '~> 2.2.0' diff --git a/Gemfile.lock b/Gemfile.lock index 1bf50c16..35650c95 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,54 +1,54 @@ GIT remote: https://github.com/faker-ruby/faker.git - revision: 82a1dbe2ee6b50240f8d61d800c1af271f745d2c + revision: 1d5ccfd01dff170ae589523f14544897be054dab specs: - faker (3.2.0) + faker (3.2.1) i18n (>= 1.8.11, < 2) GEM remote: https://rubygems.org/ specs: - actioncable (7.0.6) - actionpack (= 7.0.6) - activesupport (= 7.0.6) + actioncable (7.0.7) + actionpack (= 7.0.7) + activesupport (= 7.0.7) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.6) - actionpack (= 7.0.6) - activejob (= 7.0.6) - activerecord (= 7.0.6) - activestorage (= 7.0.6) - activesupport (= 7.0.6) + actionmailbox (7.0.7) + actionpack (= 7.0.7) + activejob (= 7.0.7) + activerecord (= 7.0.7) + activestorage (= 7.0.7) + activesupport (= 7.0.7) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.6) - actionpack (= 7.0.6) - actionview (= 7.0.6) - activejob (= 7.0.6) - activesupport (= 7.0.6) + actionmailer (7.0.7) + actionpack (= 7.0.7) + actionview (= 7.0.7) + activejob (= 7.0.7) + activesupport (= 7.0.7) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.6) - actionview (= 7.0.6) - activesupport (= 7.0.6) + actionpack (7.0.7) + actionview (= 7.0.7) + activesupport (= 7.0.7) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.6) - actionpack (= 7.0.6) - activerecord (= 7.0.6) - activestorage (= 7.0.6) - activesupport (= 7.0.6) + actiontext (7.0.7) + actionpack (= 7.0.7) + activerecord (= 7.0.7) + activestorage (= 7.0.7) + activesupport (= 7.0.7) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.6) - activesupport (= 7.0.6) + actionview (7.0.7) + activesupport (= 7.0.7) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -58,33 +58,33 @@ GEM activemodel (>= 5.2.0) activestorage (>= 5.2.0) activesupport (>= 5.2.0) - activejob (7.0.6) - activesupport (= 7.0.6) + activejob (7.0.7) + activesupport (= 7.0.7) globalid (>= 0.3.6) - activemodel (7.0.6) - activesupport (= 7.0.6) - activerecord (7.0.6) - activemodel (= 7.0.6) - activesupport (= 7.0.6) - activestorage (7.0.6) - actionpack (= 7.0.6) - activejob (= 7.0.6) - activerecord (= 7.0.6) - activesupport (= 7.0.6) + activemodel (7.0.7) + activesupport (= 7.0.7) + activerecord (7.0.7) + activemodel (= 7.0.7) + activesupport (= 7.0.7) + activestorage (7.0.7) + actionpack (= 7.0.7) + activejob (= 7.0.7) + activerecord (= 7.0.7) + activesupport (= 7.0.7) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.6) + activesupport (7.0.7) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - addressable (2.8.4) + addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) ahoy_matey (4.2.1) activesupport (>= 5.2) device_detector safely_block (>= 0.2.1) - airbrussh (1.4.1) + airbrussh (1.4.2) sshkit (>= 1.6.1, != 1.7.0) ambry (1.0.0) annotate (3.2.0) @@ -100,8 +100,8 @@ GEM ffi-compiler (~> 1.0) ast (2.4.2) aws-eventstream (1.2.0) - aws-partitions (1.795.0) - aws-sdk-core (3.180.1) + aws-partitions (1.806.0) + aws-sdk-core (3.180.3) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) @@ -109,12 +109,13 @@ GEM aws-sdk-kms (1.71.0) aws-sdk-core (~> 3, >= 3.177.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.132.0) + aws-sdk-s3 (1.132.1) aws-sdk-core (~> 3, >= 3.179.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) aws-sigv4 (1.6.0) aws-eventstream (~> 1, >= 1.0.2) + base64 (0.1.1) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) @@ -174,7 +175,7 @@ GEM debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) - device_detector (1.1.0) + device_detector (1.1.1) docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) @@ -244,9 +245,11 @@ GEM merit (4.0.3) ambry (~> 1.0.0) zeitwerk + meta-tags (2.18.0) + actionpack (>= 3.2.0, < 7.1) method_source (1.0.0) mini_magick (4.12.0) - mini_mime (1.1.2) + mini_mime (1.1.5) minitest (5.19.0) msgpack (1.7.2) multi_xml (0.6.0) @@ -264,9 +267,9 @@ GEM net-protocol net-ssh (7.2.0) nio4r (2.5.9) - nokogiri (1.15.3-arm64-darwin) + nokogiri (1.15.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.15.3-x86_64-linux) + nokogiri (1.15.4-x86_64-linux) racc (~> 1.4) noticed (1.6.3) http (>= 4.0.0) @@ -281,7 +284,7 @@ GEM octokit (7.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) - oj (3.15.1) + oj (3.16.0) omniauth (2.1.1) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -298,8 +301,8 @@ GEM actionpack (>= 4.2) omniauth (~> 2.0) pagy (6.0.4) - paper_trail (14.0.0) - activerecord (>= 6.0) + paper_trail (15.0.0) + activerecord (>= 6.1) request_store (~> 1.4) parallel (1.23.0) parser (3.2.2.3) @@ -310,49 +313,49 @@ GEM activerecord (>= 5.2) activesupport (>= 5.2) public_suffix (5.0.3) - puma (6.3.0) + puma (6.3.1) nio4r (~> 2.0) pundit (2.3.1) activesupport (>= 3.0.0) racc (1.7.1) rack (2.2.8) - rack-protection (3.0.6) - rack + rack-protection (3.1.0) + rack (~> 2.2, >= 2.2.4) rack-test (2.1.0) rack (>= 1.3) - rails (7.0.6) - actioncable (= 7.0.6) - actionmailbox (= 7.0.6) - actionmailer (= 7.0.6) - actionpack (= 7.0.6) - actiontext (= 7.0.6) - actionview (= 7.0.6) - activejob (= 7.0.6) - activemodel (= 7.0.6) - activerecord (= 7.0.6) - activestorage (= 7.0.6) - activesupport (= 7.0.6) + rails (7.0.7) + actioncable (= 7.0.7) + actionmailbox (= 7.0.7) + actionmailer (= 7.0.7) + actionpack (= 7.0.7) + actiontext (= 7.0.7) + actionview (= 7.0.7) + activejob (= 7.0.7) + activemodel (= 7.0.7) + activerecord (= 7.0.7) + activestorage (= 7.0.7) + activesupport (= 7.0.7) bundler (>= 1.15.0) - railties (= 7.0.6) - rails-dom-testing (2.1.1) + railties (= 7.0.7) + rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.0.6) - actionpack (= 7.0.6) - activesupport (= 7.0.6) + railties (7.0.7) + actionpack (= 7.0.7) + activesupport (= 7.0.7) method_source rake (>= 12.2) thor (~> 1.0) zeitwerk (~> 2.5) rainbow (3.1.1) rake (13.0.6) - redis (5.0.6) + redis (5.0.7) redis-client (>= 0.9.0) - redis-client (0.15.0) + redis-client (0.16.0) connection_pool regexp_parser (2.8.1) reline (0.3.7) @@ -363,7 +366,8 @@ GEM nokogiri rexml (3.2.6) rolify (6.0.1) - rubocop (1.55.1) + rubocop (1.56.0) + base64 (~> 0.1.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -380,7 +384,7 @@ GEM activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) - ruby-openai (4.2.0) + ruby-openai (5.0.0) faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) @@ -426,9 +430,9 @@ GEM sshkit (1.21.5) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) - stimulus-rails (1.2.1) + stimulus-rails (1.2.2) railties (>= 6.0.0) - stripe (8.6.0) + stripe (9.0.0) thor (1.2.2) timeout (0.4.0) turbo-rails (1.4.0) @@ -470,7 +474,7 @@ GEM nokogiri (~> 1.8) xsv (1.2.1) rubyzip (>= 1.3, < 3) - zeitwerk (2.6.10) + zeitwerk (2.6.11) PLATFORMS arm64-darwin-22 @@ -510,25 +514,26 @@ DEPENDENCIES jbuilder jsbundling-rails merit + meta-tags (~> 2.18.0) nokogiri (~> 1.15.0) noticed (~> 1.5) octokit (~> 7.0.0) - oj (~> 3.15.0) + oj (~> 3.16.0) omniauth (~> 2.1.0) omniauth-google-oauth2 (~> 1.1.1) omniauth-rails_csrf_protection pagy (~> 6.0.0) - paper_trail + paper_trail (~> 15.0.0) pg (~> 1.4) pg_search (~> 2.3.6) puma (~> 6.3.0) pundit - rails (~> 7.0.5) + rails (~> 7.0.7) redis (~> 5.0.5) rolify (~> 6.0.0) rubocop (~> 1.50) rubocop-rails - ruby-openai (~> 4.2.0) + ruby-openai (~> 5.0.0) ruby-vips (>= 2.1.0) selenium-webdriver sendgrid-actionmailer (~> 3.2.0) @@ -536,7 +541,7 @@ DEPENDENCIES simplecov sprockets-rails stimulus-rails - stripe (~> 8.6.0) + stripe (~> 9.0.0) turbo-rails tzinfo-data validate_url @@ -550,4 +555,4 @@ RUBY VERSION ruby 3.2.2p53 BUNDLED WITH - 2.4.17 + 2.4.19 diff --git a/README.md b/README.md index 8fa828b1..967aed80 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Shout out to the following projects: ### Javascript: - [chart.js](https://www.chartjs.org) - [flowbite](https://flowbite.com) +- [Merakiui - Email Templates](https://merakiui.com/components/email-templates) - [QR Code Styling](https://github.com/kozakdenys/qr-code-styling) - [stimulus-autocomplete](https://github.com/afcapel/stimulus-autocomplete) - [taggle](https://github.com/okcoker/taggle.js) diff --git a/app/assets/images/thepew-logo.png b/app/assets/images/thepew-logo.png new file mode 100644 index 00000000..aa87e461 Binary files /dev/null and b/app/assets/images/thepew-logo.png differ diff --git a/app/controllers/concerns/invitable.rb b/app/controllers/concerns/invitable.rb new file mode 100644 index 00000000..1bcaebd1 --- /dev/null +++ b/app/controllers/concerns/invitable.rb @@ -0,0 +1,44 @@ +module Invitable + extend ActiveSupport::Concern + + def fetch_invited_users(resource) + # Return the invited users if needed + # See Notion for the correct format. + invited_users = [] + if !resource.universal? + # Remove fron invites the users from a group (that means group_id AND email not null) + # to only keep the group_id + invites = resource.resource_invites.where.not('group_id IS NOT NULL AND email IS NOT NULL') + + # Format the @invited_user entries as per the requirements + # Only one catch: we do not check for type invite as this would require a few more + # queries on the database. As this information is not that important at this stage we + # merge types user and invite together. + invites.each do |invite| + # Testing if this is a group (aka group_id is not null) + if invite.group_id.present? + invited_users << { id: invite.group_id, type: "group", label: invite.group.name } + next # Move to next invite record + end + + # Testing if this is a new user (email is present but there is no recipient_id) + if invite.email.present? && invite.recipient_id.nil? + invited_users << { id: "", type: "new_user", label: invite.email } + next # Move to next invite record + end + + # Testing if this is a user (email and recipient_id not null) + # or a user extracted from ResourceInvite (in that case the recipient_id will contain an invite id) + # For now we will fallback to the type user + if invite.email.present? && invite.recipient_id.present? + invited_users << { id: invite.recipient_id, type: "user", label: invite.email } + end + end + + end + + # Return a formated or empty array of invited users + invited_users + end + +end \ No newline at end of file diff --git a/app/controllers/concerns/user_searchable.rb b/app/controllers/concerns/user_searchable.rb index 450f4522..4941fd8f 100644 --- a/app/controllers/concerns/user_searchable.rb +++ b/app/controllers/concerns/user_searchable.rb @@ -1,6 +1,21 @@ module UserSearchable extend ActiveSupport::Concern + # Searches for users in the current user's organization based on a search query. + # + # The `:q` param is used to determine the search query. This method utilizes a custom search + # method on the User model to find users based on this query. It only considers users who + # belong to the same organization as the currently logged-in user. + # + # In order to prevent unnecessary database queries, it also eager loads the `:profile` + # association for the searched users. It also limits the search results to the top 5 matches. + # + # If no search query is provided, it will return an empty ActiveRecord::Relation. + # + # This method is intended to be used for an autocomplete feature, and it renders the associated + # view without a layout. + # + # @return [void] def search_users if params[:q].present? query = params[:q] @@ -19,4 +34,46 @@ def search_users end render layout: false end + + + # search_users_and_groups_for_invites searches for users, groups, and previously sent resource invites based on the provided query. + # + # This action looks up: + # - Users within the current organization excluding those who have already received invites from the current user. + # - Groups based on their name. + # - Previous resource invites sent by other users within the same organization. + # + # The results are then collated and rendered as a JSON response comprising an array of objects. + # Each object represents either a user, group, or a previous resource invite. They are characterized by + # the attributes `type`, `name`, `email`, and `id`. + # + # If no results are found or if no query is provided, an empty array is returned. + def search_users_and_groups_for_invites + + if params[:q].present? + query = params[:q] + + # Search users within the organization + @searched_users = User.joins(:member) + .where(members: { organization_id: current_user.organization.id }) + .search(query) + .includes(:profile) + .limit(5) + + # Search groups within the organization or created by the user + @searched_groups = Group + .where("user_id = ? OR (group_type = ? AND organization_id = ?)", current_user.id, Group.group_types[:organization], current_user.organization.id) + .search(query) + .limit(5) + + # Search resource invites (aka user's previously invited by the current user) + @searched_invites = ResourceInvite.search(query) + .where(sender_id: current_user.id) + .limit(5) + + end + + render layout: false + end + end \ No newline at end of file diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index faccb9c1..c0571d56 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,4 +1,6 @@ class EventsController < ApplicationController + include Invitable + before_action :authenticate_user!, only: %i[index edit destroy update new] before_action :redirect_if_unauthenticated, only: %i[index edit destroy update new] before_action :set_event, except: %i[index new create event validate_pin] @@ -56,6 +58,7 @@ def show def edit @event.start_date = @event.start_date.strftime("%m/%d/%Y") + @invited_users = fetch_invited_users(@event) end # GET /event/:id/stats @@ -189,11 +192,11 @@ def authorize_event end def create_event_params - params.require(:event).permit(:allow_anonymous, :always_on, :description, :event_type, :name, :start_date, :status, :stop_date) + params.require(:event).permit(:allow_anonymous, :always_on, :description, :event_type, :name, :start_date, :status, :end_date) end def update_event_params - params.require(:event).permit(:allow_anonymous, :always_on, :description, :event_type, :name, :short_code, :start_date, :status, :stop_date) + params.require(:event).permit(:allow_anonymous, :always_on, :description, :event_type, :name, :short_code, :start_date, :status, :end_date) end # Called to make sure a user's account is confirmed before they can create or edit an event. diff --git a/app/controllers/polls_controller.rb b/app/controllers/polls_controller.rb index c9e61abe..987f63bd 100644 --- a/app/controllers/polls_controller.rb +++ b/app/controllers/polls_controller.rb @@ -1,4 +1,6 @@ class PollsController < ApplicationController + include Invitable + before_action :authenticate_user! before_action :redirect_if_unauthenticated @@ -16,6 +18,11 @@ def create @poll.user_id = current_user.id if @poll.save + # Trigger the invitation if the poll is not universal. Poll type is tested in + # ResourceInviteService + # params[:invited_users] is already a JSON so we pass it as it is to the next + # steps as Sidekiq is expecting this format. + ResourceInviteService.new(params[:invited_users], current_user.id, @poll).create redirect_to polls_url, notice: "The poll was succesfully saved." else flash[:alert] = "An error prevented the poll from being created" @@ -27,6 +34,11 @@ def update @poll = Poll.find(params[:id]) if @poll.update(poll_params) + # Update the invitation if the poll is not universal. Poll type is tested in + # ResourceInviteService + # params[:invited_users] is already a JSON so we pass it as it is to the next + # steps as Sidekiq is expecting this format. + ResourceInviteService.new(params[:invited_users], current_user.id, @poll).update redirect_to polls_url, notice: "The poll was succesfully updated." else flash[:alert] = "An error prevented the poll from being updated" @@ -37,6 +49,8 @@ def update def edit @poll = Poll.find(params[:id]) authorize @poll + + @invited_users = fetch_invited_users(@poll) end def show diff --git a/app/controllers/resource_invites_controller.rb b/app/controllers/resource_invites_controller.rb index 45159d89..3ae78823 100644 --- a/app/controllers/resource_invites_controller.rb +++ b/app/controllers/resource_invites_controller.rb @@ -11,7 +11,9 @@ def show end def new - + @resource_invite = ResourceInvites.new + @resource_invite.user_id = current_user.id + @resource_invite.organization_id = current_user.organization.id end def create @@ -25,4 +27,8 @@ def edit def update end + + def destroy + + end end diff --git a/app/javascript/controllers/filter_users_controller.js b/app/javascript/controllers/filter_users_controller.js index d134398e..8016242a 100644 --- a/app/javascript/controllers/filter_users_controller.js +++ b/app/javascript/controllers/filter_users_controller.js @@ -20,7 +20,6 @@ export default class extends Controller { } clear() { - console.debug("Calling Clear"); if (this.selected_user_id) { // Loop through the table rows and display the non selected users this.toggleUsers(); diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index df0052ae..a3fe5f20 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -2,73 +2,79 @@ // Run that command whenever you add a new controller or create them with // ./bin/rails generate stimulus controllerName -import { application } from "./application" +import { application } from "./application"; -import AddPollOptionController from "./add_poll_option_controller" -application.register("add-poll-option", AddPollOptionController) +import AddPollOptionController from "./add_poll_option_controller"; +application.register("add-poll-option", AddPollOptionController); -import AlertController from "./alert_controller" -application.register("alert", AlertController) +import AlertController from "./alert_controller"; +application.register("alert", AlertController); -import AutoSubmitController from "./auto_submit_controller" -application.register("auto-submit", AutoSubmitController) +import AutoSubmitController from "./auto_submit_controller"; +application.register("auto-submit", AutoSubmitController); -import ClipboardController from "./clipboard_controller" -application.register("clipboard", ClipboardController) +import ClipboardController from "./clipboard_controller"; +application.register("clipboard", ClipboardController); -import CloseDropdownController from "./close_dropdown_controller" -application.register("close-dropdown", CloseDropdownController) +import CloseDropdownController from "./close_dropdown_controller"; +application.register("close-dropdown", CloseDropdownController); -import ConfettiController from "./confetti_controller" -application.register("confetti", ConfettiController) +import ConfettiController from "./confetti_controller"; +application.register("confetti", ConfettiController); -import EmptyStateController from "./empty_state_controller" -application.register("empty-state", EmptyStateController) +import EmptyStateController from "./empty_state_controller"; +application.register("empty-state", EmptyStateController); -import FilterUsersController from "./filter_users_controller" -application.register("filter-users", FilterUsersController) +import FilterUsersController from "./filter_users_controller"; +application.register("filter-users", FilterUsersController); -import HamburgerController from "./hamburger_controller" -application.register("hamburger", HamburgerController) +import HamburgerController from "./hamburger_controller"; +application.register("hamburger", HamburgerController); -import MaxcharController from "./maxchar_controller" -application.register("maxchar", MaxcharController) +import InviteToggleController from "./invite_toggle_controller"; +application.register("invite-toggle", InviteToggleController); -import ModeController from "./mode_controller" -application.register("mode", ModeController) +import MaxcharController from "./maxchar_controller"; +application.register("maxchar", MaxcharController); -import OrderQuestionsController from "./order_questions_controller" -application.register("order-questions", OrderQuestionsController) +import ModeController from "./mode_controller"; +application.register("mode", ModeController); -import PollController from "./poll_controller" -application.register("poll", PollController) +import OrderQuestionsController from "./order_questions_controller"; +application.register("order-questions", OrderQuestionsController); -import PollOptionsController from "./poll_options_controller" -application.register("poll-options", PollOptionsController) +import PollController from "./poll_controller"; +application.register("poll", PollController); -import QrCodeGeneratorController from "./qr_code_generator_controller" -application.register("qr-code-generator", QrCodeGeneratorController) +import PollOptionsController from "./poll_options_controller"; +application.register("poll-options", PollOptionsController); -import ResetFormController from "./reset_form_controller" -application.register("reset-form", ResetFormController) +import QrCodeGeneratorController from "./qr_code_generator_controller"; +application.register("qr-code-generator", QrCodeGeneratorController); -import SubmitQuestionController from "./submit_question_controller" -application.register("submit-question", SubmitQuestionController) +import ResetFormController from "./reset_form_controller"; +application.register("reset-form", ResetFormController); -import SubscriptionFormController from "./subscription_form_controller" -application.register("subscription-form", SubscriptionFormController) +import SubmitQuestionController from "./submit_question_controller"; +application.register("submit-question", SubmitQuestionController); -import TimeZoneController from "./time_zone_controller" -application.register("time-zone", TimeZoneController) +import SubscriptionFormController from "./subscription_form_controller"; +application.register("subscription-form", SubscriptionFormController); -import ToggleController from "./toggle_controller" -application.register("toggle", ToggleController) +import TimeZoneController from "./time_zone_controller"; +application.register("time-zone", TimeZoneController); -import TurboModalController from "./turbo_modal_controller" -application.register("turbo-modal", TurboModalController) +import ToggleController from "./toggle_controller"; +application.register("toggle", ToggleController); -import UserVoteController from "./user_vote_controller" -application.register("user-vote", UserVoteController) +import TurboModalController from "./turbo_modal_controller"; +application.register("turbo-modal", TurboModalController); -import ValidatePinController from "./validate_pin_controller" -application.register("validate-pin", ValidatePinController) +import UserInvitesController from "./user_invites_controller"; +application.register("user-invites", UserInvitesController); + +import UserVoteController from "./user_vote_controller"; +application.register("user-vote", UserVoteController); + +import ValidatePinController from "./validate_pin_controller"; +application.register("validate-pin", ValidatePinController); diff --git a/app/javascript/controllers/invite_toggle_controller.js b/app/javascript/controllers/invite_toggle_controller.js new file mode 100644 index 00000000..d6da148b --- /dev/null +++ b/app/javascript/controllers/invite_toggle_controller.js @@ -0,0 +1,25 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="invite-toggle" +// Description: used to toggle a section in a form +// Primary user: invite from Poll, Event, Survey management _form. +export default class extends Controller { + static targets = ["form"]; + static values = { openForm: { type: Boolean, default: false } }; + + connect() { + if (this.openFormValue) { + this.open(); + } else { + this.close(); + } + } + + open() { + this.formTarget.classList.remove("hidden"); + } + + close() { + this.formTarget.classList.add("hidden"); + } +} diff --git a/app/javascript/controllers/user_invites_controller.js b/app/javascript/controllers/user_invites_controller.js new file mode 100644 index 00000000..f42f013d --- /dev/null +++ b/app/javascript/controllers/user_invites_controller.js @@ -0,0 +1,155 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="user-invites" +export default class extends Controller { + static targets = ["searchInput", "badgeContainer", "invitedUsers"]; + static values = { + invitedUsers: { type: Array, default: [] }, + initInvitedUsers: { type: Array, default: [] }, + }; + + connect() { + // If invitedUser is not an empty array, display the list of invited users + // Otherwise just make sure the invitedUser is fully empty in the JS code too + if (this.initInvitedUsersValue.length > 0) { + this.initInvitedUsersValue.forEach((invite) => { + // Check if the user is already in the invitedUsersValue array based on the label + if ( + !this.invitedUsersValue.some((user) => user.label === invite.label) + ) { + // Display the badge for the user or group + this.displayBadge(invite); + + // Update invitedUsers array + this.invitedUsersValue.push(invite); + } + }); + + // Serialize the invitedUsersValue as JSON and store it in the hidden input + this.invitedUsersTarget.value = JSON.stringify( + this.initInvitedUsersValue + ); + } else { + // this.invitedUsersValue = []; + this.invitedUsersValue.length = 0; + } + + // Get the autocomplete object from the page + this.autocomplete = document.getElementById("autocomplete"); + + autocomplete.addEventListener("autocomplete.change", (event) => { + // Get the email address or group id + var invited = this.extractTypeAndId(event.detail.value); + invited.label = event.detail.textValue; + + this.addBadge(invited); + }); + } + + clear(event) { + // Prevent form submission + event.preventDefault(); + + // Clear the search input field + this.searchInputTarget.value = ""; + } + + disconnect() { + // Remove the autocomplete listener + if (this.autocomplete) { + this.autocomplete.removeEventListener( + "autocomplete.change", + this.autocompleteListener + ); + } + this.invitedUsersValue = []; + this.invitedUsersTarget.value = JSON.stringify(this.invitedUsersValue); + } + + addBadge(invited) { + // Append the email to the invitedUser Array + // ✅ only appends if value not in array + if (!this.invitedUsersValue.some((user) => user.label === invited.label)) { + this.invitedUsersValue.push(invited); + } else { + alert(`${this.searchInputTarget.value} is already in the list`); + this.searchInputTarget.value = ""; + return; + } + + // Create a new badge and add it to the badge container + this.displayBadge(invited); + + // Serialize the invitedUsersValue as JSON and store it in the hidden input + this.invitedUsersTarget.value = JSON.stringify(this.invitedUsersValue); + + // Clear the search input field + this.searchInputTarget.value = ""; + } + + addEmail(event) { + // Prevent form submission + event.preventDefault(); + + // 1. Extract the value of searchInputTarget + const email = this.searchInputTarget.value.trim(); + + // 2. Ensure that the searchInputTarget value is a valid email address + if (this.isValidEmail(email)) { + this.addBadge({ id: "", type: "new_email", label: email }); + } else { + alert("Please enter a valid email address."); + } + } + + displayBadge(invited) { + this.badgeContainerTarget.innerHTML += ` + + ${invited.label} + + + `; + } + + extractTypeAndId(str) { + const match = str.match(/^(\w+)_([\w-]+)$/); + + if (match) { + return { + type: match[1], + id: match[2], + }; + } else { + return null; + } + } + + // Utility function to validate email address using a simple regex + isValidEmail(email) { + const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // simple email validation regex + return regex.test(email); + } + + removeBadge(event) { + const badgeElement = event.target.closest("[data-id]"); // Find the parent badge of the clicked button + const id = badgeElement.getAttribute("data-id"); + const type = badgeElement.getAttribute("data-type"); + const label = badgeElement.getAttribute("data-label"); + + // Remove the badge from the UI + badgeElement.remove(); + + // Remove the corresponding object from the invitedUsers array + this.invitedUsersValue = this.invitedUsersValue.filter((user) => { + return !(user.id === id && user.type === type && user.label === label); + }); + + // Update the invitedUsersField hidden input to reflect the changes + this.invitedUsersTarget.value = JSON.stringify(this.invitedUsersValue); + } +} diff --git a/app/mailers/resource_invite_mailer.rb b/app/mailers/resource_invite_mailer.rb new file mode 100644 index 00000000..b36aaea2 --- /dev/null +++ b/app/mailers/resource_invite_mailer.rb @@ -0,0 +1,16 @@ +class ResourceInviteMailer < ApplicationMailer + default from: User::MAILER_FROM_EMAIL + + def invite(resource) + @resource = JSON.parse(resource) + + @resource_url = root_url + if @resource['invitable_type'].downcase == 'event' + @resource_url += "rooms/#{@resource['invitable_id']}/questions" + else + @resource_url += @resource['invitable_type'].downcase.pluralize + "/" + @resource['invitable_id'] + end + + mail(to: @resource['email'], subject: 'You are invited!') + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 02919c55..eccae2a2 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -47,6 +47,9 @@ class Event < ApplicationRecord has_many :attendances, dependent: :destroy has_many :rooms, dependent: :destroy + # Link to resource invitation(s) + has_many :resource_invites, as: :invitable, dependent: :destroy + # Description (optional) / Used to extract topics and intends from questions by # offering a better context to openAI has_rich_text :description @@ -69,7 +72,7 @@ class Event < ApplicationRecord # make sure to implement a corresponding test to check if the validation # for end_date being before start_date is working correctly. def set_values - self.end_date = start_date + self.end_date = start_date if self.end_date.nil? self.short_code = generate_pin if self.short_code.nil? self.organization_id = Member.where(user_id: self.user_id).first.organization_id if self.organization_id.nil? @@ -89,10 +92,6 @@ def end_date_is_after_start_date # Generate a unique pin for the event def generate_pin - # loop do - # pin = 6.times.map{rand(10)}.join - # break pin unless Event.exists?(short_code: pin) - # end 6.times.map { rand(10) }.join end end diff --git a/app/models/group.rb b/app/models/group.rb new file mode 100644 index 00000000..26dcd048 --- /dev/null +++ b/app/models/group.rb @@ -0,0 +1,43 @@ +# == Schema Information +# +# Table name: groups +# +# id :uuid not null, primary key +# description :text +# group_type :integer default("restricted"), not null +# icon :string +# name :string +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# user_id :uuid not null +# +# Indexes +# +# index_groups_on_group_type (group_type) +# index_groups_on_organization_id (organization_id) +# index_groups_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (user_id => users.id) +# +class Group < ApplicationRecord + include PgSearch::Model + + # enable rolify on the Event class + resourcify + + has_many :group_memberships + has_many :users, through: :group_memberships + has_many :resource_invites + + # Defines if the group only be accessed by the user who created it or if it + # can be access by anyone from the organization + enum group_type: { restricted: 0, organization: 10 }, _default: 0 + + # PG_SEARCH + pg_search_scope :search, against: [:name] + +end diff --git a/app/models/group_membership.rb b/app/models/group_membership.rb new file mode 100644 index 00000000..d29af5be --- /dev/null +++ b/app/models/group_membership.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: group_memberships +# +# id :uuid not null, primary key +# role :integer +# status :integer +# created_at :datetime not null +# updated_at :datetime not null +# group_id :uuid not null +# user_id :uuid not null +# +# Indexes +# +# index_group_memberships_on_group_id (group_id) +# index_group_memberships_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (group_id => groups.id) +# fk_rails_... (user_id => users.id) +# +class GroupMembership < ApplicationRecord + belongs_to :user + belongs_to :group +end diff --git a/app/models/poll.rb b/app/models/poll.rb index 59c24b7c..2facb97e 100644 --- a/app/models/poll.rb +++ b/app/models/poll.rb @@ -56,6 +56,9 @@ class Poll < ApplicationRecord has_many :poll_participations, dependent: :destroy has_many :participants, through: :poll_participations, source: :user + # Link to resource invitation(s) + has_many :resource_invites, as: :invitable, dependent: :destroy + validates :title, presence: true, length: { minimum: 3, maximum: 250 } validates :duration, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true } validate :validate_poll_options diff --git a/app/models/poll_option.rb b/app/models/poll_option.rb index ea7ee93c..b7bffbc1 100644 --- a/app/models/poll_option.rb +++ b/app/models/poll_option.rb @@ -34,7 +34,7 @@ class PollOption < ApplicationRecord has_many :poll_answers, dependent: :destroy has_many :votes, as: :votable, dependent: :destroy - validates :title, presence: true, length: { minimum: 3, maximum: 250 } + validates :title, presence: true, length: { maximum: 250 } enum status: { in_review: 0, diff --git a/app/models/resource_invite.rb b/app/models/resource_invite.rb index 5d32c415..d2b272dc 100644 --- a/app/models/resource_invite.rb +++ b/app/models/resource_invite.rb @@ -3,14 +3,17 @@ # Table name: resource_invites # # id :uuid not null, primary key -# email :string not null +# email :string +# error_msg :text # expires_at :datetime # invitable_type :string not null +# sent_on :datetime # status :integer # template :string # token :string not null # created_at :datetime not null # updated_at :datetime not null +# group_id :uuid # invitable_id :uuid not null # organization_id :uuid not null # recipient_id :uuid @@ -19,6 +22,7 @@ # Indexes # # index_resource_invites_on_email (email) +# index_resource_invites_on_group_id (group_id) # index_resource_invites_on_invitable (invitable_type,invitable_id) # index_resource_invites_on_invitable_type_and_invitable_id (invitable_type,invitable_id) # index_resource_invites_on_organization_id (organization_id) @@ -34,9 +38,12 @@ # fk_rails_... (sender_id => users.id) # class ResourceInvite < ApplicationRecord + include PgSearch::Model + belongs_to :sender, class_name: 'User' belongs_to :recipient, class_name: 'User', optional: true belongs_to :invitable, polymorphic: true + belongs_to :group, optional: true before_validation :generate_token, on: :create @@ -45,6 +52,11 @@ class ResourceInvite < ApplicationRecord enum status: { pending: 0, accepted: 1, declined: 2, expired: 3 } + # PG_SEARCH + pg_search_scope :search, against: [:email] + + # FUNCTIONS: + def token_valid? self.expires_at > Time.now end diff --git a/app/models/user.rb b/app/models/user.rb index 197fda55..738bee88 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -73,6 +73,10 @@ class User < ApplicationRecord has_one :member has_one :organization, through: :member, required: false + # Groups + has_many :group_memberships + has_many :groups, through: :group_memberships + # To enable bullk upload via an excel spreadsheet has_one_attached :import_file has_many :import_results, dependent: :destroy diff --git a/app/services/resource_invite_service.rb b/app/services/resource_invite_service.rb new file mode 100644 index 00000000..0cfb0ded --- /dev/null +++ b/app/services/resource_invite_service.rb @@ -0,0 +1,58 @@ +# app/services/resource_invite_service.rb +class ResourceInviteService + attr_reader :invited_users, :resource, :sender + + def initialize(invited_users, sender_id, resource) + @invited_users = invited_users # Serialized JSON + @resource = resource # this could be a Poll, Survey, Event, etc. + @sender_id = sender_id # User sending the invitation (should be current_user) + generateResourceJSON() + end + + # Used in the create method of a resource controller + def create + if !@resource.universal? + # Sending the task to a Sidekiq job [Model must be formatted as JSON] + # @invited_users is already a JSON at this stage. + ResourceInviteJob.perform_async(@invited_users, @sender_id, @resource_json) + end + end + + # Used in the update method of a resource controller + def update + if JSON.parse(@invited_users).count > 0 + # Sending the task to a Sidekiq job [Model must be formatted as JSON] + # What the job does: + # * Check for new user(s) and send proper invitation(s) + # * Check for removed user(s) and remove them from the ResourceInvite + if !@resource.universal? + ResourceInviteUpdateJob.perform_async(@invited_users, @sender_id, @resource_json) + end + else + # The user removed all invitations ;-) + ResourceInvite.destroy_all(invitable: @resource) + end + end + + private + + def getResourceTitle + case @resource.class.name + when 'Event' + @resource.name + when 'Poll', 'Survey' + @resource.title + else + Rails.logger.error "Cannot extract name or title from: #{@resource.class.name} / Unsupported resource type" + end + end + + def generateResourceJSON + @resource_json = { + "title": getResourceTitle(), + "invitable_id": @resource.id, + "invitable_type": @resource.class.name + }.to_json + end + +end diff --git a/app/services/vote_counter_service.rb b/app/services/vote_counter_service.rb index 1cc8e050..07379336 100644 --- a/app/services/vote_counter_service.rb +++ b/app/services/vote_counter_service.rb @@ -15,7 +15,30 @@ def self.count_by_poll_option_and_choice(poll) grouped_votes = votes.group_by { |k, _| k[0] } # Group by the poll option title grouped_votes.each do |option, counts| - total = counts.map { |_, v| v || 0 }.reduce(0, :+) + # Total participants per option + total_participants = counts.map { |_, v| v || 0 }.reduce(0, :+) + votes[[option, 'participants']] = total_participants + + # Total vote based on the vote options + # upvote accounts for +1, downvote for -1 and cancel/neutral for 0 + # so if we have 3 users voting for the same option the following way: + # user 1: upvote, user 2 downvote and user 3 cancel we should have a + # total of 0 (sum of +1 -1 0 is equal to 0) + total = counts.reduce(0) do |sum, (keys, count)| + choice = keys[1] + # Adjust value depending on vote type + value = case choice + when 'up_vote' + count + when 'down_vote' + -count + when 'neutral', 'cancel' + 0 + else + 0 + end + sum + value + end votes[[option, 'total']] = total end diff --git a/app/sidekiq/resource_invite_job.rb b/app/sidekiq/resource_invite_job.rb new file mode 100644 index 00000000..9c7ce19b --- /dev/null +++ b/app/sidekiq/resource_invite_job.rb @@ -0,0 +1,127 @@ +class ResourceInviteJob + include Sidekiq::Job + + # ResourceInviteJob's perform method. + # + # This method is responsible for processing invitations based on the provided + # parameters. It parses the JSON representations of invited users and the resource, + # then processes each invited user based on their type (group, new_email, user, invite). + # For each valid invitation, it creates a ResourceInvite record and sends an email invitation. + # + # @param invited_users_json [String] A JSON string representation of the users to be invited. + # Expected format: + # [ + # {"type": "user", "id": "some-uuid", "label": "some-label"}, + # ... + # ] + # + # @param sender_id [String] The UUID of the user who initiated the invitation. + # + # @param resource_json [String] A JSON string representation of the resource to which users are invited. + # Expected format: + # { + # "title": "some title", + # "invitable_id": "some-uuid", + # "invitable_type": "Poll" // or "Event", "Survey", etc. + # } + # + # @return [void] + # + # @raise [JSON::ParserError] If there's an issue parsing the JSON strings. + # @raise [ActiveRecord::RecordNotFound] If a user or group is not found in the database. + def perform(invited_users, sender_id, resource) + invited_users = JSON.parse(invited_users) + resource = JSON.parse(resource) + + # Remove duplicates based on the label attribute + invited_users.uniq! { |user| user["label"] } + + # Fetching the user who initiated the invitation: aka sender + @sender = User.find(sender_id) + + invited_users.each do |invited| + case invited['type'] + when 'group' + group_invite(invited['id'], resource) + when 'new_email' + email = invited['label'] + if valid_email?(email) + invitation = ResourceInvite.create(email: email, + group_id: nil, + invitable_id: resource['invitable_id'], + invitable_type: resource['invitable_type'], + organization_id: @sender.organization.id, + recipient_id: nil, + sender_id: @sender.id) + send_invite(resource, invitation) + end + when 'user' + user = User.find(invited['id']) + if user + invitation = ResourceInvite.create(email: user.email, + group_id: nil, + invitable_id: resource['invitable_id'], + invitable_type: resource['invitable_type'], + organization_id: @sender.organization.id, + recipient_id: user.id, + sender_id: @sender.id) + send_invite(resource, invitation) + end + when 'invite' + email = invited['label'] + if valid_email?(email) + invitation = ResourceInvite.create(email: email, + group_id: nil, + invitable_id: resource['invitable_id'], + invitable_type: resource['invitable_type'], + organization_id: @sender.organization.id, + recipient_id: nil, + sender_id: @sender.id) + send_invite(resource, invitation) + end + else + Rails.logger.error "Unsupported invite type #{invited['type']}" + end + end + end + + private + + def send_invite(resource, invitation) + resource['email'] = invitation['email'] # Email receiving the invitation + resource['nickname'] = @sender.profile.nickname # Name of the person inviting (sender) + + # Serializing the JSON & + # Sending to the mailer for async processing + ResourceInviteMailer.invite(resource.to_json).deliver_later + end + + def group_invite(group_id, resource) + group = Group.find(group_id) + + if group + group_memberships = group.group_memberships + + group_memberships.each do |membership| + user = membership.user + # Create ResourceInvite for this user with polymorphic association + invitation = ResourceInvite.create(email: user.email, + group_id: group_id, + invitable_id: resource['invitable_id'], + invitable_type: resource['invitable_type'], + organization_id: @sender.organization.id, + recipient_id: user.id, + sender_id: @sender.id) + # Send the invitation email to the invited user + send_invite(resource, invitation) + end + end + end + + # @param email [String] Email address to validate. + # @return [Boolean] true if the email is valid, false otherwise. + def valid_email?(email) + # A basic email regex to check if email format is valid + email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i + end +end diff --git a/app/sidekiq/resource_invite_update_job.rb b/app/sidekiq/resource_invite_update_job.rb new file mode 100644 index 00000000..2db4bda4 --- /dev/null +++ b/app/sidekiq/resource_invite_update_job.rb @@ -0,0 +1,98 @@ +class ResourceInviteUpdateJob + include Sidekiq::Job + + # @param invited_users [Array] The list of users/groups invited to the resource. + # @param sender [User] The user sending the invite. + # @param resource [ActiveRecord::Base] The resource (like Poll, Survey, Event) for which users/groups are invited. + def perform(invited_users, sender, resource) + # Invited users (received from the UI) + invited_users = JSON.parse(invited_users) + # Remove duplicates based on the label attribute + invited_users.uniq! { |user| user["label"] } + + resource = JSON.parse(resource) + + # Fetch from the database a list of the already invited users and groups + invites = ResourceInvite.where(invitable_id: resource['invitable_id'], invitable_type: resource['invitable_type']) + filtered_invites = invites.to_a # Convert to array to work with + + new_invites_users = [] + + # Scan all the invited_users + # If a user (email) or group (group_id) is already in the invites collection we simply + # remove it from the invites collection. + # If a user (email) or group (group_id) is not in the invites collection we place it into + # new_invites_users array which contains object of type {id:, type:, label: } + # the new_invites_users array is then passed to the ResourceInviteJob to invite the new users. + invited_users.each do |invited| + case invited['type'] + when 'group' + group_id = invited['id'] + # Check if group_id is already in invites + # If so remove all entries from invites which have this group_id (group and users) + if invites.exists?(group_id: group_id) + # invites.where(group_id: group_id).destroy_all + filtered_invites.reject! { |invite| invite.group_id == group_id } + else + new_invites_users << invited + end + when 'new_email', 'invite' + email = invited['label'] + if valid_email?(email) + # Check if email is already in invites + # If so remove the entry(ies) with this email from invites + if valid_email?(email) && invites.exists?(email: email) + # invites.where(email: email).destroy_all + filtered_invites.reject! { |invite| invite.email == email } + else + new_invites_users << invited + end + end + when 'user' + user = User.find(invited['id']) + # Check if user is already in invites + # If so remove the entry(ies) with this user from invites + if user && invites.exists?(recipient_id: user.id) + # invites.where(recipient_id: user.id).destroy_all + filtered_invites.reject! { |invite| invite.recipient_id == user.id } + elsif user + new_invites_users << invited + end + else + # In case we received an incorrect type, report it as an error + Rails.logger.error "Unsupported invite type #{invited['type']}" + end + end + + # Call ResourceInviteJob to invite the new users + # but first make sure that the new_invites_users can be passed to Sidekiq + ResourceInviteJob.perform_async(new_invites_users.to_json, sender, resource.to_json) if new_invites_users.length > 0 + + # invites should now only contains the groups and users which have + # been removed from the resource by the organizer. + # We can now remove them from ResourceInvite to match the organizer list. + if filtered_invites.length > 0 + # Extract group_ids from filtered_invites + group_ids_to_remove = filtered_invites.select { |invite| invite.group_id }.map(&:group_id) + + # Extract recipient_ids (user ids) from filtered_invites + user_ids_to_remove = filtered_invites.select { |invite| invite.recipient_id }.map(&:recipient_id) + + # Remove ResourceInvites by group_ids + ResourceInvite.where(group_id: group_ids_to_remove, invitable_id: resource['invitable_id'], invitable_type: resource['invitable_type']).destroy_all if group_ids_to_remove.any? + + # Remove ResourceInvites by user (recipient) ids + ResourceInvite.where(recipient_id: user_ids_to_remove, invitable_id: resource['invitable_id'], invitable_type: resource['invitable_type']).destroy_all if user_ids_to_remove.any? + + end + end + + private + + # @param email [String] Email address to validate. + # @return [Boolean] true if the email is valid, false otherwise. + def valid_email?(email) + # A basic email regex to check if email format is valid + email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i + end +end diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index e0aa5c69..4ce7d67c 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -18,16 +18,41 @@ class:"h-24 md:h-32 block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500", placeholder: "Add a short text describing the event. This can also be the agenda of a meeting, etc." %> + <%# Accordion %>
-
-
- <%= form.label :start_date, class: "block text-sm font-medium text-gray-900 dark:text-gray-300" %> -

The event start date, format mm/dd/yyyy.

-
-
- -
- <%= form.text_field :start_date, +
+

+ +

+ +

+ +

+ -
-
- - - -
diff --git a/app/views/layouts/invites/_resource_invite.html.erb b/app/views/layouts/invites/_resource_invite.html.erb new file mode 100644 index 00000000..265373ab --- /dev/null +++ b/app/views/layouts/invites/_resource_invite.html.erb @@ -0,0 +1,82 @@ +<%# /app/views/layouts/invites/_resource_invite.html.erb %> +<% attribute_name = "#{resource.class.name.downcase}_type" %> +
> +
+
+
+ + <%= form.label attribute_name.to_sym, "Open to the world", class: "w-full py-4 ml-2 text-sm font-medium text-gray-900 dark:text-gray-300" %> +
+ <%= form.radio_button attribute_name.to_sym, :universal, + data: { action: 'invite-toggle#close' }, + checked: resource.send(attribute_name) == 'universal', + class: "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" %> +
+
+
+ + <%= form.label attribute_name.to_sym, "Only people from your organization", + class: "w-full py-4 ml-2 text-sm font-medium text-gray-900 dark:text-gray-300" %> +
+ <%= form.radio_button attribute_name.to_sym, :restricted, + data: { action: 'invite-toggle#open' }, + checked: resource.send(attribute_name) == 'restricted', + class: "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" %> +
+
+
+ + <%= form.label attribute_name.to_sym, "Invite only", + class: "w-full py-4 ml-2 text-sm font-medium text-gray-900 dark:text-gray-300" %> +
+ <%= form.radio_button attribute_name.to_sym, :invite_only, + data: { action: 'invite-toggle#open' }, + checked: resource.send(attribute_name) == 'invite_only', + class: "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" %> +
+
+
> +

+ Invite users to join: +

+
+ You can search for users within your organization, past invites or simply add a new one by typing their email address. +
+
+ <%# Search / filter users %> + <%# %> +
+
+ +
+ + + +
+
    +
    +
    +
    + +
    +
    +
    +
    diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index dc847b7c..b77e3ce6 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -1,27 +1,55 @@ - - + + - - + - - - New Template + <%= message.subject %> + - - <%= yield %> + +
    + Continue your registration in order to activate your account. +  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏ +
    +
    + <%= yield %> +
    diff --git a/app/views/polls/_form.html.erb b/app/views/polls/_form.html.erb index c3bf6370..d8382e9b 100644 --- a/app/views/polls/_form.html.erb +++ b/app/views/polls/_form.html.erb @@ -48,6 +48,9 @@
    + <%# Render the type and invite section %> +

    Choose who can participate:

    + <%= render "layouts/invites/resource_invite", form: form, resource: poll %>

    Choose what users can do: