From 2dd3ddf574676d0bce634ea5d7210b8e0e422932 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 28 Jan 2021 09:20:12 -0500 Subject: [PATCH 1/8] + admin: surveys --- app/commands/send_grid_email.rb | 10 +- app/commands/survey_response_register.rb | 42 +++++++ app/controllers/admin/surveys_controller.rb | 51 ++++++++ app/controllers/admin/targets_controller.rb | 20 ++++ app/controllers/api_controller.rb | 1 + app/controllers/sms_controller.rb | 26 ++++ app/mailboxes/application_mailbox.rb | 4 + app/mailboxes/survey_responses_mailbox.rb | 41 +++++++ app/models/location.rb | 1 + app/models/survey.rb | 113 ++++++++++++++++++ app/models/survey_response.rb | 43 +++++++ app/models/user.rb | 3 + app/views/admin/surveys/_form.html.erb | 16 +++ app/views/admin/surveys/_result.html.erb | 72 +++++++++++ app/views/admin/surveys/edit.html.erb | 3 + app/views/admin/surveys/index.html.erb | 26 ++++ app/views/admin/surveys/location.html.erb | 11 ++ app/views/admin/surveys/new.html.erb | 3 + app/views/admin/surveys/show.html.erb | 54 +++++++++ .../admin/targets/_sent_locations.html.erb | 6 + app/views/admin/targets/index.html.erb | 55 +++++++++ app/views/admin/targets/update.js.erb | 2 + app/views/layouts/admin.html.erb | 9 +- app/workers/send_survey_worker.rb | 58 +++++++++ config/application.rb | 3 +- config/environments/development.rb | 4 +- config/environments/production.rb | 3 + .../initializers/001_environment_variables.rb | 3 + config/locales/en.yml | 5 + config/locales/simple_form.en.yml | 5 + config/routes.rb | 6 + ...te_action_mailbox_tables.action_mailbox.rb | 14 +++ db/migrate/20210126015743_create_surveys.rb | 15 +++ .../20210126015755_create_survey_responses.rb | 16 +++ ...20210128103252_add_permalink_to_surveys.rb | 5 + db/schema.rb | 37 +++++- lib/survey_parser.rb | 20 ++++ lib/survey_result.rb | 45 +++++++ .../survey_responses_mailbox_spec.rb | 5 + 39 files changed, 849 insertions(+), 7 deletions(-) create mode 100644 app/commands/survey_response_register.rb create mode 100644 app/controllers/admin/surveys_controller.rb create mode 100644 app/controllers/admin/targets_controller.rb create mode 100644 app/controllers/sms_controller.rb create mode 100644 app/mailboxes/application_mailbox.rb create mode 100644 app/mailboxes/survey_responses_mailbox.rb create mode 100644 app/models/survey.rb create mode 100644 app/models/survey_response.rb create mode 100644 app/views/admin/surveys/_form.html.erb create mode 100644 app/views/admin/surveys/_result.html.erb create mode 100644 app/views/admin/surveys/edit.html.erb create mode 100644 app/views/admin/surveys/index.html.erb create mode 100644 app/views/admin/surveys/location.html.erb create mode 100644 app/views/admin/surveys/new.html.erb create mode 100644 app/views/admin/surveys/show.html.erb create mode 100644 app/views/admin/targets/_sent_locations.html.erb create mode 100644 app/views/admin/targets/index.html.erb create mode 100644 app/views/admin/targets/update.js.erb create mode 100644 app/workers/send_survey_worker.rb create mode 100644 db/migrate/20210125140759_create_action_mailbox_tables.action_mailbox.rb create mode 100644 db/migrate/20210126015743_create_surveys.rb create mode 100644 db/migrate/20210126015755_create_survey_responses.rb create mode 100644 db/migrate/20210128103252_add_permalink_to_surveys.rb create mode 100644 lib/survey_parser.rb create mode 100644 lib/survey_result.rb create mode 100644 spec/mailboxes/survey_responses_mailbox_spec.rb diff --git a/app/commands/send_grid_email.rb b/app/commands/send_grid_email.rb index 65d50f1..c7d49d6 100644 --- a/app/commands/send_grid_email.rb +++ b/app/commands/send_grid_email.rb @@ -12,6 +12,10 @@ class SendGridEmail < ApplicationCommand validates :to, presence: true validates :subject, presence: true + def logger + @logger ||= Logger.new(Rails.root.join('log', 'email.log')) + end + def pony_payload return @pony_payload if defined?(@pony_payload) @@ -28,6 +32,10 @@ def pony_payload end def work - Pony.mail(pony_payload) + if Rails.env.production? + Pony.mail(pony_payload) + else + logger.info(pony_payload.inspect) + end end end diff --git a/app/commands/survey_response_register.rb b/app/commands/survey_response_register.rb new file mode 100644 index 0000000..fd4d638 --- /dev/null +++ b/app/commands/survey_response_register.rb @@ -0,0 +1,42 @@ +# frozen_literal_string: true + +class SurveyResponseRegister < ApplicationCommand + argument :medium + argument :response + argument :from + argument :permalink + + def work + return false if respondant.nil? + return false if survey.nil? + return false unless survey.valid_response? response + + survey_response = respondant.survey_responses. + not_answered.find_by(survey: survey) + return false if survey_response.nil? + + survey_response.update(response: response, medium: medium, responded_at: DateTime.now) + end + + private + + def respondant + @respondant ||= User.find_by_email_or_mobile(from) + end + + def survey + return @survey if @survey.present? + + @survey = medium == :email ? + Survey.find_by_permalink(permalink) : + last_sent_survey(respondant) + end + + def last_sent_survey(respondant) + last_survey = respondant.survey_responses. + not_answered.order(created_at: :desc).first + return nil if last_survey.nil? + + last_survey.survey + end +end diff --git a/app/controllers/admin/surveys_controller.rb b/app/controllers/admin/surveys_controller.rb new file mode 100644 index 0000000..115ccc6 --- /dev/null +++ b/app/controllers/admin/surveys_controller.rb @@ -0,0 +1,51 @@ +module Admin + class SurveysController < ApplicationController + before_action :set_survey, except: [:index, :new, :create] + + def index + @pagy, @surveys = pagy(Survey.all.order(:id)) + end + + def show + @result = SurveyResult.new(@survey).result_presentable + end + + def location + @location = Location.find(params[:location_id]) + @result = SurveyResult.new( + @survey, + @survey.survey_responses.at_locations(@location.id) + ).result_presentable + end + + def new + @survey = Survey.new + end + + def create + @survey = Survey.new(params.require(:survey).permit!) + if @survey.save + redirect_to admin_survey_path(@survey), notice: "#{@survey.question} created!" + else + render 'new' + end + end + + def edit; end + + def update + @survey.assign_attributes(params.require(:survey).permit!) + if @survey.save + redirect_to admin_survey_path(@survey), notice: "#{@survey.question} updated!" + else + render 'edit' + end + end + + private + + def set_survey + @survey = Survey.find(params[:id]) + end + end +end diff --git a/app/controllers/admin/targets_controller.rb b/app/controllers/admin/targets_controller.rb new file mode 100644 index 0000000..1783bae --- /dev/null +++ b/app/controllers/admin/targets_controller.rb @@ -0,0 +1,20 @@ +# frozen_literal_string: true +module Admin + class TargetsController < ApplicationController + before_action :set_survey + + def index + @pagy, @locations = pagy(Location.q(params[:query])) + end + + def update + @survey.send_to_locations([params[:id]]) + end + + private + + def set_survey + @survey = Survey.find(params[:survey_id]) + end + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index c1de2e9..276576d 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -17,6 +17,7 @@ class APIController < ActionController::API include UsersController include CurrentUserController include MailController + include SMSController include SmokeTestsController include UtilController end diff --git a/app/controllers/sms_controller.rb b/app/controllers/sms_controller.rb new file mode 100644 index 0000000..286fcec --- /dev/null +++ b/app/controllers/sms_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SMSController + extend ActiveSupport::Concern + + # + # {"From"=>"19199993085", "MessageIntent"=>"", "MessageUUID"=>"18db7417-5f38-11eb-8b58-0242ac110008", "PowerpackUUID"=>"", "Text"=>"Survey-5", "To"=>"19197285377", "TotalAmount"=>"0", "TotalRate"=>"0", "Type"=>"sms", "Units"=>"1", "auth"=>false} + # + + included do + post '/v1/sms', auth: false do + text = params['Text'] + from = params['From'] + + if SurveyParser.content_valid?(text) + SurveyResponseRegister.new( + medium: :phone, + response: SurveyParser.clean_content(text), + from: from, + ).run + end + + success_response + end + end +end diff --git a/app/mailboxes/application_mailbox.rb b/app/mailboxes/application_mailbox.rb new file mode 100644 index 0000000..e36edd4 --- /dev/null +++ b/app/mailboxes/application_mailbox.rb @@ -0,0 +1,4 @@ +class ApplicationMailbox < ActionMailbox::Base + # routing /something/i => :somewhere + routing :all => :survey_responses +end diff --git a/app/mailboxes/survey_responses_mailbox.rb b/app/mailboxes/survey_responses_mailbox.rb new file mode 100644 index 0000000..a3ace51 --- /dev/null +++ b/app/mailboxes/survey_responses_mailbox.rb @@ -0,0 +1,41 @@ +class SurveyResponsesMailbox < ApplicationMailbox + # survey sender email format: greenlight+survey+@greenlight.com + RECIPIENT_FORMAT = /lucy\+(.+)@greenlight.com/i + + def process + return unless permalink.present? + + if SurveyParser.content_valid?(content) + SurveyResponseRegister.new( + medium: :email, + response: SurveyParser.clean_content(content), + from: from_mail, + permalink: permalink, + ).run + end + end + + def from_mail + mail.from.first + end + + def permalink + # There can be multiple recipients, + # so finding the one which matches the RECEIPIENT_FORMAT + + recipient = mail.recipients.find { |r| RECIPIENT_FORMAT.match?(r) } + + # Returns the first_match and that is product_id + # For Ex: recipient = "admin+survey+would-you-vaccinate@greenlight.com" + # Then it'll return "would-you-vaccinate" + recipient[RECIPIENT_FORMAT, 1] + end + + def content + if mail.parts.present? + mail.parts[0].body.decoded + else + mail.decoded + end + end +end diff --git a/app/models/location.rb b/app/models/location.rb index ff47daf..8a4a02f 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -42,6 +42,7 @@ class Location < ApplicationRecord has_many :location_accounts has_many :cohorts has_many :users, -> { distinct }, through: :location_accounts + has_many :surveys LocationAccount::ROLES.each do |role| has_many "#{role}_accounts".to_sym, -> { where(role: role) }, class_name: 'LocationAccount' diff --git a/app/models/survey.rb b/app/models/survey.rb new file mode 100644 index 0000000..0db23f1 --- /dev/null +++ b/app/models/survey.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +class Survey < ApplicationRecord + extend Enumerize + + QUESTION_TYPES = [ + CHOICES = 'choices', + PLAIN = 'plain', + ].freeze + + before_save :make_it_permalink, if: :new_record? + + enumerize :question_type, in: QUESTION_TYPES, default: CHOICES + + has_many :survey_responses + has_many :users, -> { distinct }, through: :survey_responses + + validates :question, presence: true + + # locations that the survey is sent to + def locations + Location.where(id: location_ids) + end + + def send_to_locations(location_ids) + target_users = User.joined(location_ids).with_contact_method + .where.not(id: self.users.pluck(:id)) + send_to_users(target_users.pluck(:id)) + + update( + location_ids: (self.location_ids + location_ids.map(&:to_i)).uniq, + last_sent_at: DateTime.now, + ) + end + + def send_to_users(user_ids) + user_ids.each do |user_id| + self.survey_responses.build(user_id: user_id) + # trigger sender job + SendSurveyWorker.perform_async(id, user_id) + end + + save + end + + def valid_response?(response) + choices.keys.include? response + end + + def choices_str + choices.values.join(', ') + end + + def choices_str=(str) + self.choices = choices_str_to_hash(str) + end + + def choices_es_str + choices_es.values.join(', ') + end + + def choices_es_str=(str) + self.choices_es = choices_str_to_hash(str) + end + + def locale_question(locale) + if locale == 'es' + return question_es || question + end + + question + end + + def locale_choices(locale) + if locale == 'es' + return choices_es.keys.length == choices.keys.length ? choices_es : choices + end + + choices + end + + private + + def choices_str_to_hash(str) + labels = str.split(',').map(&:strip) + labels.each.with_index.reduce({}) do |hash, (label, index)| + hash.merge({ + "#{index+1}" => label, + }) + end + end + + def make_it_permalink + self.permalink = SecureRandom.urlsafe_base64(10) + end +end + +# == Schema Information +# +# Table name: surveys +# +# id :bigint not null, primary key +# question :string not null +# question_es :string +# question_type :string default("choices"), not null +# choices :jsonb +# choices_es :jsonb +# location_ids :jsonb +# last_sent_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# permalink :string +# diff --git a/app/models/survey_response.rb b/app/models/survey_response.rb new file mode 100644 index 0000000..1fe7e64 --- /dev/null +++ b/app/models/survey_response.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class SurveyResponse < ApplicationRecord + extend Enumerize + + MEDIUMS = [ + EMAIL = 'email', + PHONE = 'phone', + ].freeze + + enumerize :medium, in: MEDIUMS + + belongs_to :survey + belongs_to :user + + validates :user_id, uniqueness: { scope: :survey_id } + + scope :not_answered, -> { where(responded_at: nil) } + scope :answered, -> { where.not(responded_at: nil) } + scope :at_locations, -> (locations) { + joins(user: :locations).where(locations: { id: locations }) + } +end + +# == Schema Information +# +# Table name: survey_responses +# +# id :bigint not null, primary key +# user_id :bigint not null +# survey_id :bigint not null +# response :string +# medium :string +# responded_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_survey_responses_on_survey_id (survey_id) +# index_survey_responses_on_survey_id_and_user_id (survey_id,user_id) UNIQUE +# index_survey_responses_on_user_id (user_id) +# diff --git a/app/models/user.rb b/app/models/user.rb index 7f612f5..5cdaf9b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -36,6 +36,8 @@ class User < ApplicationRecord joins('INNER JOIN location_accounts ON location_accounts.user_id = users.id') .where.not(location_accounts: { role: LocationAccount::STUDENT }) } + scope :joined, ->(location_ids) { joins(:locations).where(locations: { id: location_ids }) } + scope :with_contact_method, -> { where('users.email IS NOT NULL OR users.mobile_number IS NOT NULL') } # TODO: Fix casing so that its case insensitive scope :order_by_name, -> { @@ -56,6 +58,7 @@ class User < ApplicationRecord has_many :locations, through: :location_accounts has_many :cohort_users has_many :cohorts, through: :cohort_users + has_many :survey_responses has_one :password_reset, inverse_of: :user has_one :settings, class_name: 'UserSettings', inverse_of: :user diff --git a/app/views/admin/surveys/_form.html.erb b/app/views/admin/surveys/_form.html.erb new file mode 100644 index 0000000..45716b6 --- /dev/null +++ b/app/views/admin/surveys/_form.html.erb @@ -0,0 +1,16 @@ +<%= simple_form_for [:admin, @survey] do |f| %> + <%= f.input :question %> + <%= f.input :question_es %> + <%= f.input :choices_str %> + <%= f.input :choices_es_str %> + Separate choices by comma +
+   Yes, Not Sure, No
+  
+
+ <%= f.button :submit %> +<% end %> + +<% if @survey.errors.any? %> + <%= @survey.errors.to_json %> +<% end %> diff --git a/app/views/admin/surveys/_result.html.erb b/app/views/admin/surveys/_result.html.erb new file mode 100644 index 0000000..68600a1 --- /dev/null +++ b/app/views/admin/surveys/_result.html.erb @@ -0,0 +1,72 @@ +
+

Overview

+
    +
  • Total Sent: <%= result[:total] %>
  • +
  • Responded: <%= result[:responded] %>
  • +
  • Not Responded: <%= result[:not_responded] %>
  • +
+ + <% if result[:responded].zero? %> +

No Responses Yet

+ <% else %> +

Response Demographics

+ + + +

Response Medium Demographics

+ + + <% end %> +
+ + diff --git a/app/views/admin/surveys/edit.html.erb b/app/views/admin/surveys/edit.html.erb new file mode 100644 index 0000000..bf6f568 --- /dev/null +++ b/app/views/admin/surveys/edit.html.erb @@ -0,0 +1,3 @@ +

Edit <%= @survey.question %>

+ +<%= render 'form' %> diff --git a/app/views/admin/surveys/index.html.erb b/app/views/admin/surveys/index.html.erb new file mode 100644 index 0000000..7361407 --- /dev/null +++ b/app/views/admin/surveys/index.html.erb @@ -0,0 +1,26 @@ +

Surveys

+ + + +
+ + + + + + + + + <% @surveys.each do |survey| %> + + + + + + + <% end %> +
QuestionPermalinkLast Sent AtCreated At
<%= link_to survey.question, admin_survey_path(survey) %><%= survey.permalink %><%= survey.last_sent_at %><%= survey.created_at %>
+ +<%== pagy_nav(@pagy) %> diff --git a/app/views/admin/surveys/location.html.erb b/app/views/admin/surveys/location.html.erb new file mode 100644 index 0000000..b855898 --- /dev/null +++ b/app/views/admin/surveys/location.html.erb @@ -0,0 +1,11 @@ +
+<%= link_to "Back to Survey", admin_survey_path(@survey) %> + +

+ Survey: <%= @survey.question %> +

+

+ Result at Location: <%= @location.name %> +

+ +<%= render 'admin/surveys/result', result: @result %> diff --git a/app/views/admin/surveys/new.html.erb b/app/views/admin/surveys/new.html.erb new file mode 100644 index 0000000..2ebde5d --- /dev/null +++ b/app/views/admin/surveys/new.html.erb @@ -0,0 +1,3 @@ +

New Survey

+ +<%= render 'form' %> diff --git a/app/views/admin/surveys/show.html.erb b/app/views/admin/surveys/show.html.erb new file mode 100644 index 0000000..49a2754 --- /dev/null +++ b/app/views/admin/surveys/show.html.erb @@ -0,0 +1,54 @@ +
Surveys: +<%= link_to "All", admin_surveys_path %> | +<%= link_to "New", new_admin_survey_path %> + +

+ Survey: <%= @survey.question %> +

+ + + + + +

Result

+<%= render 'admin/surveys/result', result: @result %> + +

Location Results

+ diff --git a/app/views/admin/targets/_sent_locations.html.erb b/app/views/admin/targets/_sent_locations.html.erb new file mode 100644 index 0000000..70727bb --- /dev/null +++ b/app/views/admin/targets/_sent_locations.html.erb @@ -0,0 +1,6 @@ +<% @survey.locations.each_with_index do |location, index| %> + <% unless index.zero? %> + ,  + <% end %> + <%= link_to location.name, location_admin_survey_path(@survey, location) %> +<% end %> diff --git a/app/views/admin/targets/index.html.erb b/app/views/admin/targets/index.html.erb new file mode 100644 index 0000000..7ca8c9a --- /dev/null +++ b/app/views/admin/targets/index.html.erb @@ -0,0 +1,55 @@ +
+<%= link_to "Back to Survey", admin_survey_path(@survey) %> + +

+ Send Survey: <%= @survey.question %> +

+ +

+ Locations Sent +

+

+ <% if @survey.last_sent_at.nil? %> + You haven't sent the survey yet. + <% else %> + <%= render 'sent_locations' %> + <% end %> +

+ +

+ Locations +

+ +
+ +<%= form_with url: admin_survey_targets_path, method: :get, class: 'inline' do |form| %> + <%= form.text_field :query, value: params[:query] %> + <%= form.submit "Search", data: { disable_with: false } %> +<% end %> + +
+ + + + + + + + + + <% @locations.each do |l|%> + <% is_sent = @survey.location_ids.include? l.id %> + + + + + + + + + <% end %> +
IDNameHandleCategoryEmployee Count
<%= l.id %><%= link_to l.name, admin_location_path(l.id) %><%= l.permalink %><%= l.category %><%= l.employee_count %> + <%= link_to is_sent ? 'Resend' : 'Send', admin_survey_target_path(@survey, l.id), id: "send-#{l.id}", method: :put, remote: true %> +
+ +<%== pagy_nav(@pagy) %> diff --git a/app/views/admin/targets/update.js.erb b/app/views/admin/targets/update.js.erb new file mode 100644 index 0000000..eb874e1 --- /dev/null +++ b/app/views/admin/targets/update.js.erb @@ -0,0 +1,2 @@ +document.getElementById('sent-locations').innerHTML = "<%= j render 'sent_locations' %>"; +document.getElementById('send-<%= params[:id] %>').textContent = 'Resend'; diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index d850be0..6cab9ab 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -5,6 +5,7 @@ + <%= csrf_meta_tags %>