diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..37c2961 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.7.2 diff --git a/app/commands/send_grid_email.rb b/app/commands/send_grid_email.rb index 65d50f1..27814f3 100644 --- a/app/commands/send_grid_email.rb +++ b/app/commands/send_grid_email.rb @@ -2,6 +2,7 @@ class SendGridEmail < ApplicationCommand argument :from, default: "Greenlight " argument :to + argument :reply_to argument :cc argument :bcc argument :subject @@ -12,12 +13,17 @@ 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) @pony_payload = {} @pony_payload[:from] = self.from @pony_payload[:to] = self.to + @pony_payload[:reply_to] = self.reply_to if self.reply_to @pony_payload[:cc] = self.cc if self.cc @pony_payload[:bcc] = self.bcc if self.bcc @pony_payload[:subject] = self.subject @@ -28,6 +34,10 @@ def pony_payload end def work - Pony.mail(pony_payload) + if Rails.env.development? + logger.info(pony_payload.inspect) + else + Pony.mail(pony_payload) + 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..141f4e6 --- /dev/null +++ b/app/controllers/admin/surveys_controller.rb @@ -0,0 +1,61 @@ +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 + + def destroy + if params[:confirmation] == "DELETE #{@survey.permalink[0..3].upcase}" + @survey.destroy + redirect_to admin_surveys_path, notice: "Congrats! You deleted Survey: #{@survey.question}" + else + flash[:alert] = 'Incorrect confirmation code.' + redirect_to [:admin, @survey] + 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..2744036 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 InboundsController include SmokeTestsController include UtilController end diff --git a/app/controllers/inbounds_controller.rb b/app/controllers/inbounds_controller.rb new file mode 100644 index 0000000..7569367 --- /dev/null +++ b/app/controllers/inbounds_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +module InboundsController + extend ActiveSupport::Concern + + included do + post '/v1/inbounds/email', auth: false do + survey_permalink = SurveyParser.permalink_from_email(params[:to]) + content = params[:text] + from_mail = JSON.parse(params[:envelope])['from'] + + if survey_permalink.present? && SurveyParser.content_valid?(content) + SurveyResponseRegister.new( + medium: :email, + response: SurveyParser.clean_content(content), + from: from_mail, + permalink: survey_permalink, + ).run + end + + success_response + end + + # + # {"From"=>"19199993085", + # "MessageIntent"=>"", + # "MessageUUID"=>"18db7417-5f38-11eb-8b58-0242ac110008", + # "PowerpackUUID"=>"", + # "Text"=>"Survey-5", + # "To"=>"19197285377", + # "TotalAmount"=>"0", + # "TotalRate"=>"0", + # "Type"=>"sms", + # "Units"=>"1"} + # + post '/v1/inbounds/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/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

+ + + <% 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..fda3d9b --- /dev/null +++ b/app/views/admin/surveys/show.html.erb @@ -0,0 +1,64 @@ +
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

+ + +

+ Delete Survey +

+

Enter "DELETE <%= @survey.permalink[0..3].upcase %>" to delete this survey.

+ +<%= form_with url: admin_survey_path(@survey), method: :delete do |form| %> + <%= form.text_field :confirmation, autocomplete: 'off' %> + <%= form.submit "Delete" %> +<% end %> 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 %>