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 = '
' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '
Subject: ' + subject + '
' +
+ '
' +
+ '
' + body + '
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
';
+
+ $('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