diff --git a/.rubocop.yml b/.rubocop.yml index 4c716ddf3..74f39ad5b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,6 @@ AllCops: TargetRubyVersion: 2.3 +Style/AsciiComments: + Enabled: false Security/YAMLLoad: Enabled: false diff --git a/app/assets/stylesheets/forms.sass b/app/assets/stylesheets/forms.sass index 82de42ae4..0e1d264ef 100644 --- a/app/assets/stylesheets/forms.sass +++ b/app/assets/stylesheets/forms.sass @@ -16,7 +16,7 @@ select, input, textarea, button font-family: $sansFont .edit_reader, .new_reader - label + label:not(.notification) font: 600 80% $sansFont text-transform: uppercase letter-spacing: 0.05em @@ -34,7 +34,7 @@ select, input, textarea, button .pt-checkbox margin: 5px 0 0 0 - .pt-control + .pt-control:not(.notification) text-transform: uppercase .actions diff --git a/app/controllers/readers_controller.rb b/app/controllers/readers_controller.rb index bd4ba6c66..386287c20 100644 --- a/app/controllers/readers_controller.rb +++ b/app/controllers/readers_controller.rb @@ -85,7 +85,7 @@ def reader_params @reader_can_set_password = @reader && !@reader.created_password end - permitted = %i[name initials email locale] + permitted = %i[name initials email locale send_reply_notifications] permitted << :password if @reader_can_set_password params.require(:reader).permit(*permitted) diff --git a/app/helpers/comment_threads_helper.rb b/app/helpers/comment_threads_helper.rb new file mode 100644 index 000000000..5c8d53b63 --- /dev/null +++ b/app/helpers/comment_threads_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module CommentThreadsHelper + def comment_thread_url(locale, comment_thread) + "#{case_url(locale, comment_thread.card.case.slug)}" \ + "/#{comment_thread.card.element.case_element.position}" \ + "/cards/#{comment_thread.card_id}/comments" + end +end diff --git a/app/jobs/notification_broadcast_job.rb b/app/jobs/reply_notification_broadcast_job.rb similarity index 57% rename from app/jobs/notification_broadcast_job.rb rename to app/jobs/reply_notification_broadcast_job.rb index f1668ad32..a17f72f1a 100644 --- a/app/jobs/notification_broadcast_job.rb +++ b/app/jobs/reply_notification_broadcast_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class NotificationBroadcastJob < ActiveJob::Base +class ReplyNotificationBroadcastJob < ActiveJob::Base queue_as :default def perform(notification) @@ -12,7 +12,6 @@ def perform(notification) private def render_notification(notification) - ApplicationController.renderer.render partial: 'notifications/notification', - locals: { notification: notification } + ApplicationController.renderer.render notification end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index fc9c124ef..3678c19c3 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -2,7 +2,9 @@ class ApplicationMailer < ActionMailer::Base helper :application - default from: 'Michigan Sustainability Cases ' + + FROM_ADDRESS = 'hello@learnmsc.org' + default from: "Michigan Sustainability Cases <#{FROM_ADDRESS}>" layout 'mailer' end diff --git a/app/mailers/reply_notification_mailer.rb b/app/mailers/reply_notification_mailer.rb new file mode 100644 index 000000000..e611a3bb6 --- /dev/null +++ b/app/mailers/reply_notification_mailer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ReplyNotificationMailer < ApplicationMailer + helper :cases + helper :comment_threads + + def notify(notification) + @notification = notification + reader = @notification.reader + return unless reader.send_reply_notifications + + mail(to: reader.name_and_email, + from: from_header, + subject: subject_header) do |format| + format.text + format.html + end + end + + private + + # Build the from header. We’re setting the from name to the name of the + # reader whose comment triggered the notification, but the from address + # remains our notification address so as not to trip spam filters + def from_header + "#{@notification.notifier.name} <#{ApplicationMailer::FROM_ADDRESS}>" + end + + # Build the email subject as follows + # RE: [National Adaptation] “I understand that Ethiopia would be a...” (# 46) + # + # Every notification of a reply to the same original comment will have the + # same subject so that they can be threaded + def subject_header + comment_thread = @notification.comment_thread + "RE: [#{@notification.case.kicker}] " \ + "“#{comment_thread.comments.first.content.truncate(40)}” " \ + "(##{comment_thread.id})" + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index 2682720aa..ee76a294a 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -6,7 +6,7 @@ class Comment < ApplicationRecord translates :content - acts_as_list scope: :comment_thread + default_scope { order :created_at } validates :content, presence: :true @@ -20,18 +20,17 @@ def timestamp private - def notification_data - { notifier_id: reader.id, - comment_thread_id: comment_thread.id, - card_id: comment_thread.card.id, - case_id: comment_thread.card.case.id, - page_id: comment_thread.card.element.id } - end - def send_notifications_of_reply - (comment_thread.collocutors - [reader]).each do |r| - Notification.create reader: r, category: :reply_to_thread, - data: notification_data + card = comment_thread.card + comment_thread.collocutors.each do |other_reader| + next if other_reader == reader + ReplyNotification.create reader: other_reader, + notifier: reader, + comment: self, + comment_thread: comment_thread, + card: card, + case: card.case, + page: card.element end end end diff --git a/app/models/comment_thread.rb b/app/models/comment_thread.rb index b43872bb8..87ceece93 100644 --- a/app/models/comment_thread.rb +++ b/app/models/comment_thread.rb @@ -6,7 +6,6 @@ class CommentThread < ApplicationRecord belongs_to :reader belongs_to :group belongs_to :card - belongs_to :case has_many :comments, dependent: :restrict_with_error def collocutors diff --git a/app/models/notification.rb b/app/models/notification.rb deleted file mode 100644 index 5e6612935..000000000 --- a/app/models/notification.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -class Notification < ApplicationRecord - belongs_to :reader - - enum category: [ - # When someone else makes a comment on a thread on which the reader has made - # a comment. - # - # data: { :notifier_id, :comment_thread_id, :case_id, :page_id, :card_id } - :reply_to_thread - ] - - serialize :data, Hash - - after_create_commit { NotificationBroadcastJob.perform_now self } - - def message - case category - when 'reply_to_thread' - I18n.t 'notifications.replied_to_your_comment', - notifier: notifier.name, - case_kicker: self.case.kicker - end - end - - def notifier - Reader.find data[:notifier_id] - end - - def comment_thread - CommentThread.find data[:comment_thread_id] - end - - def case - Case.find data[:case_id] - end - - def page - Page.find data[:page_id] - end - - def card - Card.find data[:card_id] - end -end diff --git a/app/models/reply_notification.rb b/app/models/reply_notification.rb new file mode 100644 index 000000000..f5e55bace --- /dev/null +++ b/app/models/reply_notification.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class ReplyNotification < ApplicationRecord + belongs_to :reader + belongs_to :notifier, class_name: 'Reader' + belongs_to :comment + belongs_to :comment_thread + belongs_to :case + belongs_to :page + belongs_to :card + + after_create_commit { ReplyNotificationBroadcastJob.perform_now self } + after_create_commit { ReplyNotificationMailer.notify(self).deliver } + + def message + I18n.t 'notifications.replied_to_your_comment', + notifier: notifier.name, + case_kicker: self.case.kicker + end +end diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index 9057c7e45..9f8173b82 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -1,4 +1,5 @@ -<%= sanitize yield, tags: [] %> +<% if content_for? :headline %># <%= content_for :headline %> +<% end %><%= sanitize yield, tags: [] %> -- <% if content_for? :email_footer %> diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder deleted file mode 100644 index de1f5b660..000000000 --- a/app/views/notifications/_notification.json.jbuilder +++ /dev/null @@ -1,16 +0,0 @@ -json.key_format! camelize: :lower -case notification.category -when "reply_to_thread" - json.notifier do - json.(notification.notifier, :id, :name, :initials) - end - json.case do - json.(notification.case, :slug, :kicker ) - end - json.element do - json.(notification.page.case_element, :position) - end - json.card_id notification.data[:card_id] - json.comment_thread_id notification.data[:comment_thread_id] -end -json.(notification, :id, :message) diff --git a/app/views/readers/_form.html.haml b/app/views/readers/_form.html.haml index 174f1f4a5..e2a4a2ee9 100644 --- a/app/views/readers/_form.html.haml +++ b/app/views/readers/_form.html.haml @@ -18,6 +18,14 @@ = t 'activerecord.attributes.user.email' = f.email_field :email, class: "pt-input pt-fill", tabindex: 3 + %h2= t '.notification_settings' + %div{ style: 'padding-bottom: 1em'} + + = f.label :send_reply_notifications, class: 'notification pt-control pt-checkbox' do + = t 'activerecord.attributes.reader.send_reply_notifications' + = f.check_box :send_reply_notifications + %span.pt-control-indicator + .actions = f.submit t('.save'), class: "o-button pt-button pt-intent-primary" diff --git a/app/views/reply_notification_mailer/notify.markerb b/app/views/reply_notification_mailer/notify.markerb new file mode 100644 index 000000000..0d7587437 --- /dev/null +++ b/app/views/reply_notification_mailer/notify.markerb @@ -0,0 +1,16 @@ +<% headline one_liner "#{@notification.notifier.name} replied to your comment" %> +<% background_image_url ix_cover_image @notification.case, :email %> + +<%= @notification.comment.content %> + +<%= md_button_to "Reply online", + comment_thread_url(I18n.locale, @notification.comment_thread) %> + +<% email_footer <<-FOOTER +You are receiving this email because someone replied to a comment you made on a +Michigan Sustainability Case you’re studying. If you do not want to receive +emails like this, you can [change your notification settings](#{ + edit_reader_url @notification.reader.locale, @notification.reader +}). +FOOTER +%> diff --git a/app/views/reply_notifications/_reply_notification.json.jbuilder b/app/views/reply_notifications/_reply_notification.json.jbuilder new file mode 100644 index 000000000..6d5815854 --- /dev/null +++ b/app/views/reply_notifications/_reply_notification.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +json.key_format! camelize: :lower + +json.extract! reply_notification, :id, :message, :card_id, :comment_thread_id +json.notifier do + json.extract! reply_notification.notifier, :id, :name, :initials +end +json.case do + json.extract! reply_notification.case, :slug, :kicker +end +json.element do + json.extract! reply_notification.page.case_element, :position +end diff --git a/circle.yml b/circle.yml index cc32f8bc2..0d0f8855b 100644 --- a/circle.yml +++ b/circle.yml @@ -5,9 +5,6 @@ dependencies: override: - yarn - bundle install -compile: - override: - - RAILS_ENV=test rails webpacker:compile test: override: - bundle exec rspec -r rspec_junit_formatter --format progress --format RspecJunitFormatter -o $CIRCLE_TEST_REPORTS/rspec/junit.xml: diff --git a/config/locales/translations/en.yml b/config/locales/translations/en.yml index 29a119bd1..d1f4c93b8 100644 --- a/config/locales/translations/en.yml +++ b/config/locales/translations/en.yml @@ -95,6 +95,7 @@ en: reset_password_token: Reset password token #g uid: UID #g initials: Initials + send_reply_notifications: Notify me when someone replies to a comment I wrote question: content: Content diff --git a/db/migrate/20170705153826_add_reply_notification_flag_to_reader.rb b/db/migrate/20170705153826_add_reply_notification_flag_to_reader.rb new file mode 100644 index 000000000..d86931252 --- /dev/null +++ b/db/migrate/20170705153826_add_reply_notification_flag_to_reader.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddReplyNotificationFlagToReader < ActiveRecord::Migration[5.0] + def change + add_column :readers, :send_reply_notifications, :boolean, default: true + end +end diff --git a/db/migrate/20170705164751_create_reply_notifications.rb b/db/migrate/20170705164751_create_reply_notifications.rb new file mode 100644 index 000000000..83c0410ff --- /dev/null +++ b/db/migrate/20170705164751_create_reply_notifications.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateReplyNotifications < ActiveRecord::Migration[5.0] + def change + create_table :reply_notifications do |t| + t.references :reader + + t.integer :notifier_id + t.integer :comment_id + t.integer :comment_thread_id + t.integer :case_id + t.integer :page_id + t.integer :card_id + end + end +end diff --git a/db/migrate/20170705165135_drop_notifications.rb b/db/migrate/20170705165135_drop_notifications.rb new file mode 100644 index 000000000..8ccc1bef6 --- /dev/null +++ b/db/migrate/20170705165135_drop_notifications.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class DropNotifications < ActiveRecord::Migration[5.0] + def change + drop_table :notifications do |t| + t.boolean :email_sent + t.boolean :read + t.references :reader, foreign_key: true + t.integer :category + t.jsonb :data + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0ba469091..010c788b6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. @@ -12,360 +10,361 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_170_511_212_540) do +ActiveRecord::Schema.define(version: 20170705165135) do + # These are extensions that must be enabled in order to support this database - enable_extension 'plpgsql' - enable_extension 'hstore' + enable_extension "plpgsql" + enable_extension "hstore" - create_table 'activities', force: :cascade do |t| - t.hstore 'title_i18n' - t.hstore 'description_i18n' - t.hstore 'pdf_url_i18n' - t.integer 'case_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'position' - t.string 'icon_slug', default: 'activity-text' - t.index ['case_id'], name: 'index_activities_on_case_id', using: :btree + create_table "activities", force: :cascade do |t| + t.hstore "title_i18n" + t.hstore "description_i18n" + t.hstore "pdf_url_i18n" + t.integer "case_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "position" + t.string "icon_slug", default: "activity-text" + t.index ["case_id"], name: "index_activities_on_case_id", using: :btree end - create_table 'ahoy_events', force: :cascade do |t| - t.integer 'visit_id' - t.integer 'user_id' - t.string 'name' - t.jsonb 'properties' - t.datetime 'time' - t.index 'properties jsonb_path_ops', name: 'index_ahoy_events_on_properties_jsonb_path_ops', using: :gin - t.index %w[name time], name: 'index_ahoy_events_on_name_and_time', using: :btree - t.index %w[user_id name], name: 'index_ahoy_events_on_user_id_and_name', using: :btree - t.index %w[visit_id name], name: 'index_ahoy_events_on_visit_id_and_name', using: :btree + create_table "ahoy_events", force: :cascade do |t| + t.integer "visit_id" + t.integer "user_id" + t.string "name" + t.jsonb "properties" + t.datetime "time" + t.index "properties jsonb_path_ops", name: "index_ahoy_events_on_properties_jsonb_path_ops", using: :gin + t.index ["name", "time"], name: "index_ahoy_events_on_name_and_time", using: :btree + t.index ["user_id", "name"], name: "index_ahoy_events_on_user_id_and_name", using: :btree + t.index ["visit_id", "name"], name: "index_ahoy_events_on_visit_id_and_name", using: :btree end - create_table 'answers', force: :cascade do |t| - t.integer 'question_id' - t.integer 'quiz_id' - t.integer 'reader_id' - t.string 'content' - t.boolean 'correct' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.decimal 'case_completion' - t.index ['question_id'], name: 'index_answers_on_question_id', using: :btree - t.index ['quiz_id'], name: 'index_answers_on_quiz_id', using: :btree - t.index ['reader_id'], name: 'index_answers_on_reader_id', using: :btree + create_table "answers", force: :cascade do |t| + t.integer "question_id" + t.integer "quiz_id" + t.integer "reader_id" + t.string "content" + t.boolean "correct" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.decimal "case_completion" + t.index ["question_id"], name: "index_answers_on_question_id", using: :btree + t.index ["quiz_id"], name: "index_answers_on_quiz_id", using: :btree + t.index ["reader_id"], name: "index_answers_on_reader_id", using: :btree end - create_table 'authentication_strategies', force: :cascade do |t| - t.string 'provider' - t.string 'uid' - t.integer 'reader_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.index ['reader_id'], name: 'index_authentication_strategies_on_reader_id', using: :btree - t.index ['uid'], name: 'index_authentication_strategies_on_uid', where: "((provider)::text = 'lti'::text)", using: :btree + create_table "authentication_strategies", force: :cascade do |t| + t.string "provider" + t.string "uid" + t.integer "reader_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["reader_id"], name: "index_authentication_strategies_on_reader_id", using: :btree + t.index ["uid"], name: "index_authentication_strategies_on_uid", where: "((provider)::text = 'lti'::text)", using: :btree end - create_table 'cards', force: :cascade do |t| - t.integer 'position' - t.hstore 'content_i18n' - t.integer 'page_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.boolean 'solid', default: true - t.hstore 'raw_content_i18n' - t.string 'element_type' - t.integer 'element_id' - t.integer 'case_id' - t.index ['case_id'], name: 'index_cards_on_case_id', using: :btree - t.index %w[element_type element_id], name: 'index_cards_on_element_type_and_element_id', using: :btree - t.index ['page_id'], name: 'index_cards_on_page_id', using: :btree + create_table "cards", force: :cascade do |t| + t.integer "position" + t.hstore "content_i18n" + t.integer "page_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "solid", default: true + t.hstore "raw_content_i18n" + t.string "element_type" + t.integer "element_id" + t.integer "case_id" + t.index ["case_id"], name: "index_cards_on_case_id", using: :btree + t.index ["element_type", "element_id"], name: "index_cards_on_element_type_and_element_id", using: :btree + t.index ["page_id"], name: "index_cards_on_page_id", using: :btree end - create_table 'case_elements', force: :cascade do |t| - t.integer 'case_id' - t.string 'element_type' - t.integer 'element_id' - t.integer 'position' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.index ['case_id'], name: 'index_case_elements_on_case_id', using: :btree - t.index %w[element_type element_id], name: 'index_case_elements_on_element_type_and_element_id', using: :btree + create_table "case_elements", force: :cascade do |t| + t.integer "case_id" + t.string "element_type" + t.integer "element_id" + t.integer "position" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["case_id"], name: "index_case_elements_on_case_id", using: :btree + t.index ["element_type", "element_id"], name: "index_case_elements_on_element_type_and_element_id", using: :btree end - create_table 'cases', force: :cascade do |t| - t.boolean 'published', default: false - t.hstore 'title_i18n' - t.text 'slug', null: false - t.string 'authors', default: [], array: true - t.hstore 'summary_i18n' - t.text 'tags', default: [], array: true - t.hstore 'narrative_i18n' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.string 'cover_url' - t.date 'publication_date' - t.integer 'catalog_position', default: 0, null: false - t.text 'short_title' - t.hstore 'translators_i18n', default: { 'en' => '[]' }, null: false - t.hstore 'kicker_i18n' - t.hstore 'dek_i18n' - t.text 'photo_credit' - t.boolean 'commentable' - t.index ['slug'], name: 'index_cases_on_slug', unique: true, using: :btree - t.index ['tags'], name: 'index_cases_on_tags', using: :gin + create_table "cases", force: :cascade do |t| + t.boolean "published", default: false + t.hstore "title_i18n" + t.text "slug", null: false + t.string "authors", default: [], array: true + t.hstore "summary_i18n" + t.text "tags", default: [], array: true + t.hstore "narrative_i18n" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "cover_url" + t.date "publication_date" + t.integer "catalog_position", default: 0, null: false + t.text "short_title" + t.hstore "translators_i18n", default: {"en"=>"[]"}, null: false + t.hstore "kicker_i18n" + t.hstore "dek_i18n" + t.text "photo_credit" + t.boolean "commentable" + t.index ["slug"], name: "index_cases_on_slug", unique: true, using: :btree + t.index ["tags"], name: "index_cases_on_tags", using: :gin end - create_table 'comment_threads', force: :cascade do |t| - t.integer 'case_id' - t.integer 'group_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'start' - t.integer 'length' - t.integer 'block_index' - t.string 'original_highlight_text' - t.string 'locale' - t.integer 'card_id' - t.integer 'reader_id' - t.index ['card_id'], name: 'index_comment_threads_on_card_id', using: :btree - t.index ['case_id'], name: 'index_comment_threads_on_case_id', using: :btree - t.index ['group_id'], name: 'index_comment_threads_on_group_id', using: :btree - t.index ['reader_id'], name: 'index_comment_threads_on_reader_id', using: :btree + create_table "comment_threads", force: :cascade do |t| + t.integer "case_id" + t.integer "group_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "start" + t.integer "length" + t.integer "block_index" + t.string "original_highlight_text" + t.string "locale" + t.integer "card_id" + t.integer "reader_id" + t.index ["card_id"], name: "index_comment_threads_on_card_id", using: :btree + t.index ["case_id"], name: "index_comment_threads_on_case_id", using: :btree + t.index ["group_id"], name: "index_comment_threads_on_group_id", using: :btree + t.index ["reader_id"], name: "index_comment_threads_on_reader_id", using: :btree end - create_table 'comments', force: :cascade do |t| - t.hstore 'content_i18n' - t.integer 'reader_id' - t.integer 'comment_thread_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'position' - t.index ['comment_thread_id'], name: 'index_comments_on_comment_thread_id', using: :btree - t.index ['reader_id'], name: 'index_comments_on_reader_id', using: :btree + create_table "comments", force: :cascade do |t| + t.hstore "content_i18n" + t.integer "reader_id" + t.integer "comment_thread_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "position" + t.index ["comment_thread_id"], name: "index_comments_on_comment_thread_id", using: :btree + t.index ["reader_id"], name: "index_comments_on_reader_id", using: :btree end - create_table 'deployments', force: :cascade do |t| - t.integer 'case_id' - t.integer 'group_id' - t.integer 'quiz_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'answers_needed', default: 1 - t.index ['case_id'], name: 'index_deployments_on_case_id', using: :btree - t.index ['group_id'], name: 'index_deployments_on_group_id', using: :btree - t.index ['quiz_id'], name: 'index_deployments_on_quiz_id', using: :btree + create_table "deployments", force: :cascade do |t| + t.integer "case_id" + t.integer "group_id" + t.integer "quiz_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "answers_needed", default: 1 + t.index ["case_id"], name: "index_deployments_on_case_id", using: :btree + t.index ["group_id"], name: "index_deployments_on_group_id", using: :btree + t.index ["quiz_id"], name: "index_deployments_on_quiz_id", using: :btree end - create_table 'edgenotes', force: :cascade do |t| - t.hstore 'caption_i18n' - t.string 'format' - t.string 'thumbnail_url' - t.hstore 'content_i18n' - t.integer 'case_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.text 'slug', null: false - t.integer 'card_id' - t.hstore 'instructions_i18n' - t.hstore 'image_url_i18n' - t.hstore 'website_url_i18n' - t.hstore 'embed_code_i18n' - t.hstore 'photo_credit_i18n' - t.hstore 'pdf_url_i18n' - t.integer 'style', default: 0 - t.hstore 'pull_quote_i18n' - t.hstore 'attribution_i18n' - t.hstore 'call_to_action_i18n' - t.hstore 'audio_url_i18n' - t.hstore 'youtube_slug_i18n' - t.index ['card_id'], name: 'index_edgenotes_on_card_id', using: :btree - t.index ['case_id'], name: 'index_edgenotes_on_case_id', using: :btree - t.index ['slug'], name: 'index_edgenotes_on_slug', unique: true, using: :btree + create_table "edgenotes", force: :cascade do |t| + t.hstore "caption_i18n" + t.string "format" + t.string "thumbnail_url" + t.hstore "content_i18n" + t.integer "case_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "slug", null: false + t.integer "card_id" + t.hstore "instructions_i18n" + t.hstore "image_url_i18n" + t.hstore "website_url_i18n" + t.hstore "embed_code_i18n" + t.hstore "photo_credit_i18n" + t.hstore "pdf_url_i18n" + t.integer "style", default: 0 + t.hstore "pull_quote_i18n" + t.hstore "attribution_i18n" + t.hstore "call_to_action_i18n" + t.hstore "audio_url_i18n" + t.hstore "youtube_slug_i18n" + t.index ["card_id"], name: "index_edgenotes_on_card_id", using: :btree + t.index ["case_id"], name: "index_edgenotes_on_case_id", using: :btree + t.index ["slug"], name: "index_edgenotes_on_slug", unique: true, using: :btree end - create_table 'enrollments', force: :cascade do |t| - t.integer 'reader_id' - t.integer 'case_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'status', default: 0 - t.index ['case_id'], name: 'index_enrollments_on_case_id', using: :btree - t.index ['reader_id'], name: 'index_enrollments_on_reader_id', using: :btree + create_table "enrollments", force: :cascade do |t| + t.integer "reader_id" + t.integer "case_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "status", default: 0 + t.index ["case_id"], name: "index_enrollments_on_case_id", using: :btree + t.index ["reader_id"], name: "index_enrollments_on_reader_id", using: :btree end - create_table 'group_memberships', force: :cascade do |t| - t.integer 'reader_id' - t.integer 'group_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.index ['group_id'], name: 'index_group_memberships_on_group_id', using: :btree - t.index ['reader_id'], name: 'index_group_memberships_on_reader_id', using: :btree + create_table "group_memberships", force: :cascade do |t| + t.integer "reader_id" + t.integer "group_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["group_id"], name: "index_group_memberships_on_group_id", using: :btree + t.index ["reader_id"], name: "index_group_memberships_on_reader_id", using: :btree end - create_table 'groups', force: :cascade do |t| - t.hstore 'name_i18n' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.string 'context_id' - t.index ['context_id'], name: 'index_groups_on_context_id', using: :btree + create_table "groups", force: :cascade do |t| + t.hstore "name_i18n" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "context_id" + t.index ["context_id"], name: "index_groups_on_context_id", using: :btree end - create_table 'notifications', force: :cascade do |t| - t.boolean 'email_sent' - t.boolean 'read' - t.integer 'reader_id' - t.integer 'category' - t.jsonb 'data' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.index ['reader_id'], name: 'index_notifications_on_reader_id', using: :btree + create_table "pages", force: :cascade do |t| + t.integer "position" + t.hstore "title_i18n" + t.integer "case_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["case_id"], name: "index_pages_on_case_id", using: :btree end - create_table 'pages', force: :cascade do |t| - t.integer 'position' - t.hstore 'title_i18n' - t.integer 'case_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.index ['case_id'], name: 'index_pages_on_case_id', using: :btree + create_table "podcasts", force: :cascade do |t| + t.hstore "title_i18n" + t.hstore "audio_url_i18n" + t.hstore "description_i18n" + t.integer "case_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "position" + t.string "artwork_url" + t.hstore "credits_i18n" + t.text "photo_credit" + t.index ["case_id"], name: "index_podcasts_on_case_id", using: :btree end - create_table 'podcasts', force: :cascade do |t| - t.hstore 'title_i18n' - t.hstore 'audio_url_i18n' - t.hstore 'description_i18n' - t.integer 'case_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'position' - t.string 'artwork_url' - t.hstore 'credits_i18n' - t.text 'photo_credit' - t.index ['case_id'], name: 'index_podcasts_on_case_id', using: :btree + create_table "questions", force: :cascade do |t| + t.integer "quiz_id" + t.hstore "content_i18n" + t.text "correct_answer" + t.string "options", default: [], array: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["quiz_id"], name: "index_questions_on_quiz_id", using: :btree end - create_table 'questions', force: :cascade do |t| - t.integer 'quiz_id' - t.hstore 'content_i18n' - t.text 'correct_answer' - t.string 'options', default: [], array: true - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.index ['quiz_id'], name: 'index_questions_on_quiz_id', using: :btree + create_table "quizzes", force: :cascade do |t| + t.integer "case_id" + t.integer "template_id" + t.boolean "customized" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "author_id" + t.string "lti_uid" + t.index ["author_id"], name: "index_quizzes_on_author_id", using: :btree + t.index ["case_id"], name: "index_quizzes_on_case_id", using: :btree + t.index ["lti_uid"], name: "index_quizzes_on_lti_uid", using: :btree + t.index ["template_id"], name: "index_quizzes_on_template_id", using: :btree end - create_table 'quizzes', force: :cascade do |t| - t.integer 'case_id' - t.integer 'template_id' - t.boolean 'customized' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'author_id' - t.string 'lti_uid' - t.index ['author_id'], name: 'index_quizzes_on_author_id', using: :btree - t.index ['case_id'], name: 'index_quizzes_on_case_id', using: :btree - t.index ['lti_uid'], name: 'index_quizzes_on_lti_uid', using: :btree - t.index ['template_id'], name: 'index_quizzes_on_template_id', using: :btree + create_table "readers", force: :cascade do |t| + t.text "name" + t.text "image_url" + t.string "email", default: "", null: false + t.string "encrypted_password", default: "" + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.datetime "remember_created_at" + t.integer "sign_in_count", default: 0, null: false + t.datetime "current_sign_in_at" + t.datetime "last_sign_in_at" + t.inet "current_sign_in_ip" + t.inet "last_sign_in_ip" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "initials" + t.text "locale" + t.string "confirmation_token" + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "unconfirmed_email" + t.boolean "created_password", default: true + t.boolean "send_reply_notifications", default: true + t.index ["confirmation_token"], name: "index_readers_on_confirmation_token", unique: true, using: :btree + t.index ["email"], name: "index_readers_on_email", unique: true, using: :btree + t.index ["reset_password_token"], name: "index_readers_on_reset_password_token", unique: true, using: :btree end - create_table 'readers', force: :cascade do |t| - t.text 'name' - t.text 'image_url' - t.string 'email', default: '', null: false - t.string 'encrypted_password', default: '' - t.string 'reset_password_token' - t.datetime 'reset_password_sent_at' - t.datetime 'remember_created_at' - t.integer 'sign_in_count', default: 0, null: false - t.datetime 'current_sign_in_at' - t.datetime 'last_sign_in_at' - t.inet 'current_sign_in_ip' - t.inet 'last_sign_in_ip' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.text 'initials' - t.text 'locale' - t.string 'confirmation_token' - t.datetime 'confirmed_at' - t.datetime 'confirmation_sent_at' - t.string 'unconfirmed_email' - t.boolean 'created_password', default: true - t.index ['confirmation_token'], name: 'index_readers_on_confirmation_token', unique: true, using: :btree - t.index ['email'], name: 'index_readers_on_email', unique: true, using: :btree - t.index ['reset_password_token'], name: 'index_readers_on_reset_password_token', unique: true, using: :btree + create_table "readers_roles", id: false, force: :cascade do |t| + t.integer "reader_id" + t.integer "role_id" + t.index ["reader_id", "role_id"], name: "index_readers_roles_on_reader_id_and_role_id", using: :btree end - create_table 'readers_roles', id: false, force: :cascade do |t| - t.integer 'reader_id' - t.integer 'role_id' - t.index %w[reader_id role_id], name: 'index_readers_roles_on_reader_id_and_role_id', using: :btree + create_table "reply_notifications", force: :cascade do |t| + t.integer "reader_id" + t.integer "notifier_id" + t.integer "comment_id" + t.integer "comment_thread_id" + t.integer "case_id" + t.integer "page_id" + t.integer "card_id" + t.index ["reader_id"], name: "index_reply_notifications_on_reader_id", using: :btree end - create_table 'roles', force: :cascade do |t| - t.string 'name' - t.string 'resource_type' - t.integer 'resource_id' - t.datetime 'created_at' - t.datetime 'updated_at' - t.index %w[name resource_type resource_id], name: 'index_roles_on_name_and_resource_type_and_resource_id', using: :btree - t.index ['name'], name: 'index_roles_on_name', using: :btree + create_table "roles", force: :cascade do |t| + t.string "name" + t.string "resource_type" + t.integer "resource_id" + t.datetime "created_at" + t.datetime "updated_at" + t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id", using: :btree + t.index ["name"], name: "index_roles_on_name", using: :btree end - create_table 'visits', force: :cascade do |t| - t.string 'visit_token' - t.string 'visitor_token' - t.string 'ip' - t.text 'user_agent' - t.text 'referrer' - t.text 'landing_page' - t.integer 'user_id' - t.string 'referring_domain' - t.string 'search_keyword' - t.string 'browser' - t.string 'os' - t.string 'device_type' - t.integer 'screen_height' - t.integer 'screen_width' - t.string 'country' - t.string 'region' - t.string 'city' - t.string 'postal_code' - t.decimal 'latitude' - t.decimal 'longitude' - t.string 'utm_source' - t.string 'utm_medium' - t.string 'utm_term' - t.string 'utm_content' - t.string 'utm_campaign' - t.datetime 'started_at' - t.index ['user_id'], name: 'index_visits_on_user_id', using: :btree - t.index ['visit_token'], name: 'index_visits_on_visit_token', unique: true, using: :btree + create_table "visits", force: :cascade do |t| + t.string "visit_token" + t.string "visitor_token" + t.string "ip" + t.text "user_agent" + t.text "referrer" + t.text "landing_page" + t.integer "user_id" + t.string "referring_domain" + t.string "search_keyword" + t.string "browser" + t.string "os" + t.string "device_type" + t.integer "screen_height" + t.integer "screen_width" + t.string "country" + t.string "region" + t.string "city" + t.string "postal_code" + t.decimal "latitude" + t.decimal "longitude" + t.string "utm_source" + t.string "utm_medium" + t.string "utm_term" + t.string "utm_content" + t.string "utm_campaign" + t.datetime "started_at" + t.index ["user_id"], name: "index_visits_on_user_id", using: :btree + t.index ["visit_token"], name: "index_visits_on_visit_token", unique: true, using: :btree end - add_foreign_key 'activities', 'cases' - add_foreign_key 'answers', 'questions' - add_foreign_key 'answers', 'quizzes' - add_foreign_key 'answers', 'readers' - add_foreign_key 'cards', 'cases' - add_foreign_key 'cards', 'pages' - add_foreign_key 'case_elements', 'cases' - add_foreign_key 'comment_threads', 'cards' - add_foreign_key 'comment_threads', 'cases' - add_foreign_key 'comment_threads', 'groups' - add_foreign_key 'comment_threads', 'readers' - add_foreign_key 'comments', 'comment_threads' - add_foreign_key 'comments', 'readers' - add_foreign_key 'deployments', 'cases' - add_foreign_key 'deployments', 'groups' - add_foreign_key 'deployments', 'quizzes' - add_foreign_key 'edgenotes', 'cards' - add_foreign_key 'enrollments', 'cases' - add_foreign_key 'enrollments', 'readers' - add_foreign_key 'group_memberships', 'groups' - add_foreign_key 'group_memberships', 'readers' - add_foreign_key 'notifications', 'readers' - add_foreign_key 'pages', 'cases' - add_foreign_key 'podcasts', 'cases' - add_foreign_key 'questions', 'quizzes' - add_foreign_key 'quizzes', 'cases' + add_foreign_key "activities", "cases" + add_foreign_key "answers", "questions" + add_foreign_key "answers", "quizzes" + add_foreign_key "answers", "readers" + add_foreign_key "cards", "cases" + add_foreign_key "cards", "pages" + add_foreign_key "case_elements", "cases" + add_foreign_key "comment_threads", "cards" + add_foreign_key "comment_threads", "cases" + add_foreign_key "comment_threads", "groups" + add_foreign_key "comment_threads", "readers" + add_foreign_key "comments", "comment_threads" + add_foreign_key "comments", "readers" + add_foreign_key "deployments", "cases" + add_foreign_key "deployments", "groups" + add_foreign_key "deployments", "quizzes" + add_foreign_key "edgenotes", "cards" + add_foreign_key "enrollments", "cases" + add_foreign_key "enrollments", "readers" + add_foreign_key "group_memberships", "groups" + add_foreign_key "group_memberships", "readers" + add_foreign_key "pages", "cases" + add_foreign_key "podcasts", "cases" + add_foreign_key "questions", "quizzes" + add_foreign_key "quizzes", "cases" end diff --git a/spec/features/leaving_a_comment_spec.rb b/spec/features/leaving_a_comment_spec.rb index 706d99b22..df4dfbbf8 100644 --- a/spec/features/leaving_a_comment_spec.rb +++ b/spec/features/leaving_a_comment_spec.rb @@ -64,6 +64,7 @@ fill_in placeholder: 'Write a reply...', with: 'Test reply' click_button 'Submit' + sleep 1 expect(page).to have_content 'Test reply' expect(find('textarea').value).to be_blank end @@ -80,5 +81,27 @@ ) expect(page).to have_content 'replied to your comment' end + + it 'sends an email to the other comment’s author' do + comment_thread.comments.create content: 'Test reply', + reader: enrollment.reader + email = ActionMailer::Base.deliveries.last + + expect(email.to.first).to eq other_reader.email + expect(email.text_part.body.to_s).to match 'Test reply' + expect(email.html_part.body.to_s).to match 'Test reply' + end + + it 'doesn’t send an email notification if the other author has ' \ + 'notifications turned off' do + ActionMailer::Base.deliveries.clear + other_reader.update send_reply_notifications: false + + comment_thread.comments.create content: 'Test reply', + reader: enrollment.reader + email = ActionMailer::Base.deliveries.last + + expect(email).to be_nil + end end end