diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 9a3b692a..ce231431 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -45,6 +45,7 @@ //= require selectize //= require bootstrap-select //= require osem-survey +//= require osem-bulk-email $(document).ready(function() { $('a[disabled=disabled]').click(function(event){ diff --git a/app/assets/javascripts/osem-bulk-email.js b/app/assets/javascripts/osem-bulk-email.js new file mode 100644 index 00000000..ba79f381 --- /dev/null +++ b/app/assets/javascripts/osem-bulk-email.js @@ -0,0 +1,143 @@ +$(document).ready(function() { + // Bulk Email Recipient Management + + // Select All button + $('#select-all-btn').click(function() { + $('.recipient-checkbox').prop('checked', true); + updateRecipientCount(); + }); + + // Deselect All button + $('#deselect-all-btn').click(function() { + $('.recipient-checkbox').prop('checked', false); + updateRecipientCount(); + }); + + // Remove Selected button + $('#remove-selected-btn').click(function() { + $('.recipient-checkbox:checked').each(function() { + $(this).closest('.recipient-item').slideUp(300, function() { + $(this).remove(); + updateRecipientCount(); + }); + }); + }); + + // Update count when individual checkboxes are changed + $(document).on('change', '.recipient-checkbox', function() { + updateRecipientCount(); + }); + + // Update recipient count display + function updateRecipientCount() { + var checkedCount = $('.recipient-checkbox:checked').length; + var totalCount = $('.recipient-checkbox').length; + $('#recipient-count').text(totalCount); + + // Update the next button state + if (checkedCount === 0) { + $('#next-compose-btn').prop('disabled', true).text('Select Recipients First'); + } else { + $('#next-compose-btn').prop('disabled', false).text('Next: Compose Email (' + checkedCount + ' selected)'); + } + } + + // Initialize count on page load + if ($('.recipient-checkbox').length > 0) { + updateRecipientCount(); + } + + // Form validation for recipients step + $('#recipients-form').submit(function(e) { + var checkedCount = $('.recipient-checkbox:checked').length; + if (checkedCount === 0) { + e.preventDefault(); + alert('Please select at least one recipient before proceeding.'); + return false; + } + }); + + // Dynamic recipient filtering (if needed for future enhancement) + $('#filter-recipients-input').on('keyup', function() { + var value = $(this).val().toLowerCase(); + $('.recipient-item').filter(function() { + $(this).toggle($(this).text().toLowerCase().indexOf(value) > -1); + }); + }); + + // Preview email functionality + $('#preview-email-btn').click(function() { + var subject = $('#subject').val(); + var body = $('#body').val(); + + if (subject.trim() === '' || body.trim() === '') { + alert('Please enter both subject and body before previewing.'); + return; + } + + // Create preview modal (assuming Bootstrap modal) + var modalHtml = ''; + + $('body').append(modalHtml); + $('#email-preview-modal').modal('show').on('hidden.bs.modal', function() { + $(this).remove(); + }); + }); + + // Character counter for email body + $('#body').on('input', function() { + var length = $(this).val().length; + var counter = $('#char-counter'); + if (counter.length === 0) { + $(this).after('Characters: 0'); + counter = $('#char-counter'); + } + counter.text('Characters: ' + length); + }); + + // Auto-save draft functionality (localStorage) + var draftKey = 'bulk-email-draft-' + window.location.pathname; + + // Load draft on page load + if (localStorage.getItem(draftKey)) { + var draft = JSON.parse(localStorage.getItem(draftKey)); + if (draft.subject) $('#subject').val(draft.subject); + if (draft.body) $('#body').val(draft.body); + + if (draft.subject || draft.body) { + $('
').text('Draft restored from previous session.').insertBefore('form'); + } + } + + // Save draft as user types + $('#subject, #body').on('input', function() { + var draft = { + subject: $('#subject').val(), + body: $('#body').val(), + timestamp: new Date().toISOString() + }; + localStorage.setItem(draftKey, JSON.stringify(draft)); + }); + + // Clear draft on successful send + $('form[action*="send_bulk"]').submit(function() { + localStorage.removeItem(draftKey); + }); +}); \ No newline at end of file diff --git a/app/controllers/admin/emails_controller.rb b/app/controllers/admin/emails_controller.rb index f9aa4200..a1872f2e 100644 --- a/app/controllers/admin/emails_controller.rb +++ b/app/controllers/admin/emails_controller.rb @@ -24,8 +24,38 @@ module Admin def bulk authorize! :index, @conference.email_settings - @registrations = @conference.registrations.includes(:user, :qanswers) - @questions = @conference.questions.includes(:qanswers) + @step = params[:step] || 'filter' + + case @step + when 'filter' + @registrations = @conference.registrations.includes(:user, :qanswers) + @questions = @conference.questions.includes(:qanswers) + when 'recipients' + @selected_recipients = filtered_recipients(params[:filter_type], params[:search_term]) + @filter_type = params[:filter_type] + @search_term = params[:search_term] + when 'compose' + @recipient_emails = params[:recipient_emails] || [] + @filter_type = params[:filter_type] + @search_term = params[:search_term] + end + end + + def recipients + authorize! :index, @conference.email_settings + + recipients = filtered_recipients(params[:filter_type], params[:search_term]) + + render json: { + recipients: recipients.map do |user| + { + id: user.id, + name: user.name, + email: user.email + } + end, + count: recipients.count + } end def send_bulk @@ -33,47 +63,91 @@ module Admin subject = params[:subject] body = params[:body] - filter_type = params[:filter_type] + recipient_emails = params[:recipient_emails] || [] if subject.blank? || body.blank? - redirect_to bulk_admin_conference_emails_path(@conference.short_title), alert: 'Subject and body are required.' + redirect_to bulk_admin_conference_emails_path(@conference.short_title, step: 'compose'), + alert: 'Subject and body are required.' return end - recipients = get_filtered_recipients(filter_type) - - if recipients.empty? - redirect_to bulk_admin_conference_emails_path(@conference.short_title), alert: 'No recipients found with the selected filter.' + if recipient_emails.empty? + redirect_to bulk_admin_conference_emails_path(@conference.short_title, step: 'recipients'), + alert: 'No recipients selected.' return end + recipients = User.where(email: recipient_emails) + recipients.each do |user| Mailbot.bulk_mail(@conference, user, subject, body).deliver_later end - redirect_to bulk_admin_conference_emails_path(@conference.short_title), + redirect_to admin_conference_emails_path(@conference.short_title), notice: "Bulk email sent to #{recipients.count} recipients." end private - def get_filtered_recipients(filter_type) + def filtered_recipients(filter_type, search_term = nil) + base_users = get_base_user_set(filter_type) + apply_search_filter(base_users, search_term) + end + + def get_base_user_set(filter_type) registrations = @conference.registrations.includes(:user, :qanswers) case filter_type when 'no_questions_answered' - registrations.select { |r| r.qanswers.empty? }.map(&:user) + users_with_no_answers(registrations) when 'some_questions_answered' - registrations.select { |r| r.qanswers.any? && r.qanswers.count < @conference.questions.count }.map(&:user) + users_with_some_answers(registrations) when 'all_questions_answered' - registrations.select { |r| r.qanswers.count >= @conference.questions.count }.map(&:user) + users_with_all_answers(registrations) when 'all_registered' registrations.map(&:user) + when 'not_registered' + User.where.not(id: registrations.map(&:user_id)) + when 'all_users' + User.all else [] end end + def users_with_no_answers(registrations) + registrations.select { |r| r.qanswers.empty? }.map(&:user) + end + + def users_with_some_answers(registrations) + total_questions = @conference.questions.count + registrations.select { |r| r.qanswers.any? && r.qanswers.count < total_questions }.map(&:user) + end + + def users_with_all_answers(registrations) + total_questions = @conference.questions.count + registrations.select { |r| r.qanswers.count >= total_questions }.map(&:user) + end + + def apply_search_filter(base_users, search_term) + return base_users if search_term.blank? + + if base_users.respond_to?(:where) + base_users.where('name ILIKE ? OR email ILIKE ?', "%#{search_term}%", "%#{search_term}%") + else + search_in_memory(base_users, search_term) + end + end + + def search_in_memory(users, search_term) + lower_term = search_term.downcase + users.select { |u| user_matches_search?(u, lower_term) } + end + + def user_matches_search?(user, search_term) + user.name&.downcase&.include?(search_term) || user.email&.downcase&.include?(search_term) + end + def bulk_email_params params.permit(:subject, :body, :filter_type) end diff --git a/app/views/admin/emails/_bulk_compose.html.haml b/app/views/admin/emails/_bulk_compose.html.haml new file mode 100644 index 00000000..32f90d89 --- /dev/null +++ b/app/views/admin/emails/_bulk_compose.html.haml @@ -0,0 +1,64 @@ +.row + .col-md-8 + %h3 Step 3: Compose Email + + = form_tag send_bulk_admin_conference_emails_path(@conference.short_title), method: :post, class: 'form-horizontal' do + - @recipient_emails.each do |email| + = hidden_field_tag 'recipient_emails[]', email + + .form-group + = label_tag :subject, 'Subject', class: 'col-sm-2 control-label' + .col-sm-10 + = text_field_tag :subject, params[:subject], class: 'form-control', required: true, placeholder: 'Enter email subject' + + .form-group + = label_tag :body, 'Message Body', class: 'col-sm-2 control-label' + .col-sm-10 + = text_area_tag :body, params[:body], rows: 15, class: 'form-control', required: true, placeholder: 'Enter your email message here...' + %p.help-block + You can use the following variables in your email: + %code {name} + , + %code {conference} + , + %code {registrationlink} + + .form-group + .col-sm-offset-2.col-sm-10 + = submit_tag 'Send Bulk Email', class: 'btn btn-success btn-lg', data: { confirm: "Are you sure you want to send this email to #{@recipient_emails.count} recipients?" } + = link_to 'Back to Recipients', bulk_admin_conference_emails_path(@conference.short_title, step: 'recipients', filter_type: params[:filter_type], search_term: params[:search_term]), class: 'btn btn-default' + = link_to 'Cancel', admin_conference_emails_path(@conference.short_title), class: 'btn btn-default' + + .col-md-4 + .panel.panel-success + .panel-heading + %h4 Email Summary + .panel-body + %p + %strong Recipients: + = @recipient_emails.count + selected + + - if @recipient_emails.any? + %h5 Recipient List: + .recipient-list{ style: 'max-height: 200px; overflow-y: auto;' } + - @recipient_emails.each do |email| + .small.text-muted= email + - else + %p.text-warning No recipients selected. Please go back to select recipients. + + .panel.panel-info + .panel-heading + %h4 Email Variables + .panel-body + %p Available template variables: + %ul.small + %li + %code {name} + - User's full name + %li + %code {conference} + - Conference name + %li + %code {registrationlink} + - Link to conference registration diff --git a/app/views/admin/emails/_bulk_filter.html.haml b/app/views/admin/emails/_bulk_filter.html.haml new file mode 100644 index 00000000..fd5db851 --- /dev/null +++ b/app/views/admin/emails/_bulk_filter.html.haml @@ -0,0 +1,56 @@ +.row + .col-md-8 + %h3 Step 1: Filter Recipients + + = form_tag bulk_admin_conference_emails_path(@conference.short_title), method: :get, class: 'form-horizontal', id: 'filter-form' do + = hidden_field_tag :step, 'recipients' + + .form-group + = label_tag :filter_type, 'Filter Type', class: 'col-sm-3 control-label' + .col-sm-9 + = select_tag :filter_type, options_for_select([ + ['All users in the system', 'all_users'], + ['All registered users', 'all_registered'], + ['Users not registered for this conference', 'not_registered'], + ['Users who haven\'t answered any registration questions', 'no_questions_answered'], + ['Users who answered some but not all questions', 'some_questions_answered'], + ['Users who answered all registration questions', 'all_questions_answered'] + ], params[:filter_type]), { class: 'form-control', id: 'filter-type-select' } + + .form-group + = label_tag :search_term, 'Search in Name/Email', class: 'col-sm-3 control-label' + .col-sm-9 + = text_field_tag :search_term, params[:search_term], class: 'form-control', placeholder: 'Enter keyword to search in names or email addresses' + %p.help-block Leave empty to include all users matching the filter type above. + + .form-group + .col-sm-offset-3.col-sm-9 + = submit_tag 'Next: Select Recipients', class: 'btn btn-primary', id: 'next-recipients-btn' + = link_to 'Cancel', admin_conference_emails_path(@conference.short_title), class: 'btn btn-default' + + .col-md-4 + .panel.panel-info + .panel-heading + %h4 Filter Information + .panel-body + %p + %strong Total registered users: + = @conference.registrations.count + - if @questions.any? + %p + %strong Registration questions configured: + = @questions.count + %hr + %h5 Current distribution: + %p + %strong No questions answered: + = @registrations.select { |r| r.qanswers.empty? }.count + %p + %strong Some questions answered: + = @registrations.select { |r| r.qanswers.any? && r.qanswers.count < @questions.count }.count + %p + %strong All questions answered: + = @registrations.select { |r| r.qanswers.count >= @questions.count }.count + - else + %p + %em No registration questions configured for this conference. diff --git a/app/views/admin/emails/_bulk_recipients.html.haml b/app/views/admin/emails/_bulk_recipients.html.haml new file mode 100644 index 00000000..3c797a14 --- /dev/null +++ b/app/views/admin/emails/_bulk_recipients.html.haml @@ -0,0 +1,44 @@ +.row + .col-md-12 + %h3 Step 2: Select Recipients + %p + Found + %strong= @selected_recipients.count + recipients matching your filter criteria. Select the recipients you want to email: + + = form_tag bulk_admin_conference_emails_path(@conference.short_title), method: :get, id: 'recipients-form' do + = hidden_field_tag :step, 'compose' + = hidden_field_tag :filter_type, @filter_type + = hidden_field_tag :search_term, @search_term + + .row + .col-md-12 + .form-group + .btn-toolbar{ style: 'margin-bottom: 15px;' } + = button_tag 'Select All', type: 'button', class: 'btn btn-sm btn-default', id: 'select-all-btn' + = button_tag 'Deselect All', type: 'button', class: 'btn btn-sm btn-default', id: 'deselect-all-btn' + = button_tag 'Remove Selected', type: 'button', class: 'btn btn-sm btn-danger', id: 'remove-selected-btn' + + .panel.panel-default + .panel-heading + %h4 + Recipients + %span.badge#recipient-count= @selected_recipients.count + .panel-body + - if @selected_recipients.any? + #recipients-list + - @selected_recipients.each do |user| + .recipient-item{ 'data-email' => user.email } + .checkbox + %label + = check_box_tag 'recipient_emails[]', user.email, true, class: 'recipient-checkbox' + %strong= user.name.present? ? user.name : 'No Name' + %br + %small.text-muted= user.email + - else + %p.text-muted No recipients found with the current filter criteria. + + .form-group + = submit_tag 'Next: Compose Email', class: 'btn btn-primary', id: 'next-compose-btn' + = link_to 'Back to Filter', bulk_admin_conference_emails_path(@conference.short_title, step: 'filter', filter_type: @filter_type, search_term: @search_term), class: 'btn btn-default' + = link_to 'Cancel', admin_conference_emails_path(@conference.short_title), class: 'btn btn-default' diff --git a/app/views/admin/emails/bulk.html.haml b/app/views/admin/emails/bulk.html.haml index a3b3b245..ce81c697 100644 --- a/app/views/admin/emails/bulk.html.haml +++ b/app/views/admin/emails/bulk.html.haml @@ -1,61 +1,22 @@ .row - .col-md-8 + .col-md-12 %h2 Bulk Email - %p Send customized emails to registered users based on their registration status. + %p Send customized emails to users with advanced filtering and recipient selection. - = form_tag send_bulk_admin_conference_emails_path(@conference.short_title), method: :post, class: 'form-horizontal' do - .form-group - = label_tag :subject, 'Subject', class: 'col-sm-2 control-label' - .col-sm-10 - = text_field_tag :subject, params[:subject], class: 'form-control', required: true + .nav.nav-pills{ role: 'navigation' } + %li{ class: (@step == 'filter' ? 'active' : ''), id: 'step-filter' } + %a{ href: '#' } 1. Filter Recipients + %li{ class: (@step == 'recipients' ? 'active' : ''), id: 'step-recipients' } + %a{ href: '#' } 2. Select Recipients + %li{ class: (@step == 'compose' ? 'active' : ''), id: 'step-compose' } + %a{ href: '#' } 3. Compose Email - .form-group - = label_tag :body, 'Body', class: 'col-sm-2 control-label' - .col-sm-10 - = text_area_tag :body, params[:body], rows: 10, class: 'form-control', required: true - %p.help-block You can use the following variables in your email: {name}, {conference}, {registrationlink} + %hr - .form-group - = label_tag :filter_type, 'Send to', class: 'col-sm-2 control-label' - .col-sm-10 - = select_tag :filter_type, options_for_select([ - ['All registered users', 'all_registered'], - ['Users who haven\'t answered any questions', 'no_questions_answered'], - ['Users who answered some but not all questions', 'some_questions_answered'], - ['Users who answered all questions', 'all_questions_answered'] - ], params[:filter_type]), { class: 'form-control', required: true } - - - if @questions.any? - .form-group - .col-sm-offset-2.col-sm-10 - %h4 Registration Questions - %ul - - @questions.each do |question| - %li= question.title - - .form-group - .col-sm-offset-2.col-sm-10 - = submit_tag 'Send Bulk Email', class: 'btn btn-primary', data: { confirm: 'Are you sure you want to send this email to all selected recipients?' } - = link_to 'Cancel', admin_conference_emails_path(@conference.short_title), class: 'btn btn-default' - - .col-md-4 - .panel.panel-default - .panel-heading - %h4 Recipient Preview - .panel-body - %p - %strong All registered users: - = @conference.registrations.count - - if @questions.any? - %p - %strong No questions answered: - = @registrations.select { |r| r.qanswers.empty? }.count - %p - %strong Some questions answered: - = @registrations.select { |r| r.qanswers.any? && r.qanswers.count < @questions.count }.count - %p - %strong All questions answered: - = @registrations.select { |r| r.qanswers.count >= @questions.count }.count - - else - %p - %em No registration questions configured for this conference. + - case @step + - when 'filter' + = render 'bulk_filter' + - when 'recipients' + = render 'bulk_recipients' + - when 'compose' + = render 'bulk_compose' diff --git a/config/routes.rb b/config/routes.rb index 04dba8cd..ce151371 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -118,6 +118,7 @@ Osem::Application.routes.draw do resources :emails, only: [:show, :update, :index] do collection do get :bulk + get :recipients post :send_bulk end end