diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 23b49bd0..3d046a22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,8 @@ jobs: RAILS_ENV: test DATABASE_URL: postgres://test:test@localhost:5432/test CI: "true" + DOMAIN_NAME: xxxx + DOMAIN_EMAIL_ADDRESS: xxxx steps: - name: Checkout code diff --git a/Gemfile b/Gemfile index 2915308f..41f4f11c 100644 --- a/Gemfile +++ b/Gemfile @@ -109,3 +109,4 @@ gem "devise-i18n", "~> 1.10" gem "dockerfile-rails", ">= 1.5", group: :development gem "bugsnag", "~> 6.26" +gem "noticed", "~> 1.6" diff --git a/Gemfile.lock b/Gemfile.lock index 7dc294f3..cabd4f69 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -151,6 +151,7 @@ GEM activerecord (>= 4.2, < 8) dockerfile-rails (1.5.3) rails + domain_name (0.6.20231109) dotenv (2.8.1) dotenv-rails (2.8.1) dotenv (= 2.8.1) @@ -166,6 +167,9 @@ GEM faker (3.0.0) i18n (>= 1.8.11, < 2) ffi (1.16.3) + ffi-compiler (1.0.1) + ffi (>= 1.0.0) + rake formatador (1.1.0) globalid (1.0.1) activesupport (>= 5.0) @@ -183,6 +187,14 @@ GEM guard (~> 2.1) guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) + http (5.1.1) + addressable (~> 2.8) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + llhttp-ffi (~> 0.4.0) + http-cookie (1.0.5) + domain_name (~> 0.5) + http-form_data (2.3.0) i18n (1.14.1) concurrent-ruby (~> 1.0) image_processing (1.12.2) @@ -224,6 +236,9 @@ GEM listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + llhttp-ffi (0.4.0) + ffi-compiler (~> 1.0) + rake (~> 13.0) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -259,6 +274,9 @@ GEM racc (~> 1.4) nokogiri (1.16.0-x86_64-linux) racc (~> 1.4) + noticed (1.6.3) + http (>= 4.0.0) + rails (>= 5.2.0) notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) @@ -492,6 +510,7 @@ DEPENDENCIES kaminari (~> 1.2) mobility (~> 1.2) mobility-ransack (~> 1.2.2) + noticed (~> 1.6) pg (~> 1.1) puma (~> 6.4) pundit (~> 2.2) diff --git a/app/assets/stylesheets/application.bootstrap.scss b/app/assets/stylesheets/application.bootstrap.scss index 50491410..8d350770 100644 --- a/app/assets/stylesheets/application.bootstrap.scss +++ b/app/assets/stylesheets/application.bootstrap.scss @@ -2,13 +2,4 @@ @import 'bootstrap-icons/font/bootstrap-icons'; @import './direct_uploads'; @import 'trix/dist/trix'; - -trix-editor { - min-height: 100px; -} - -.trix-button-group--block-tools, -.trix-button-group--file-tools, -.trix-button-group--history-tools { - display: none !important; -} +@import './socialchange'; diff --git a/app/assets/stylesheets/socialchange.scss b/app/assets/stylesheets/socialchange.scss new file mode 100644 index 00000000..70aa598d --- /dev/null +++ b/app/assets/stylesheets/socialchange.scss @@ -0,0 +1,36 @@ +trix-editor { + min-height: 100px; +} + +.trix-button-group--block-tools, +.trix-button-group--file-tools, +.trix-button-group--history-tools { + display: none !important; +} + +.unread-dot { + content: ''; + width: 8px; /* Adjust the width and height to control the size of the dot */ + height: 8px; + background-color: #3498db; /* Blue color, adjust as needed */ + border-radius: 50%; + position: absolute; + top: 50%; /* Adjust vertical positioning as needed */ + left: -6px; /* Adjust horizontal positioning as needed */ + transform: translateY(-50%); /* Center the dot vertically */ +} + +.nav-item.dropdown.nav-unread-notifications { + &> .nav-account-dropdown { + position: relative; + margin-left: 15px; + &:before { + @extend .unread-dot; + } + } + + .dropdown-item.notifications { + font-weight: bold; + } +} + diff --git a/app/controllers/contributions_controller.rb b/app/controllers/contributions_controller.rb new file mode 100644 index 00000000..299ddc59 --- /dev/null +++ b/app/controllers/contributions_controller.rb @@ -0,0 +1,54 @@ +# Contributions controller +# +class ContributionsController < ApplicationController + before_action :authenticate_user! + before_action :set_story + before_action :set_contribution, only: %i[destroy] + + def new + # use story policy update permissions + authorize @story, :update?, policy_class: StoryPolicy + + @contribution = Contribution.new(story: @story) + end + + def create + # use story policy update permissions + authorize @story, :update?, policy_class: StoryPolicy + + @story.invite_contributors(params[:contribution][:emails].split(","), current_user) + @story.reload + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace(:story_contributors, partial: "stories/contributors", + locals: {story: @story}) + end + format.html { redirect_to story_url(@story), notice: t(".invited") } + format.json { head :no_content } + end + end + + def destroy + # use story policy update permissions + authorize @story, :update?, policy_class: StoryPolicy + + @contribution.destroy + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.remove(helpers.dom_id(@contribution)) + end + format.html { redirect_to story_url(@story), notice: t(".removed") } + format.json { head :no_content } + end + end + + private + + def set_story + @story = policy_scope(Story).find(params[:story_id]) + end + + def set_contribution + @contribution = @story.contributions.find(params[:id]) + end +end diff --git a/app/controllers/discussions_controller.rb b/app/controllers/discussions_controller.rb index 363d0c87..009e0c87 100644 --- a/app/controllers/discussions_controller.rb +++ b/app/controllers/discussions_controller.rb @@ -43,7 +43,7 @@ def update respond_to do |format| if @discussion.update(**permitted_attributes(@discussion), updater: current_user) - format.html { redirect_to story_discussion_url(@discussion.story, @discussion), notice: I18n.t("discussions.updated") } + format.html { redirect_to story_discussion_url(@discussion.story, @discussion) } format.json { render :show, status: :ok, location: @discussion } else format.html { render :edit, status: :unprocessable_entity } diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb new file mode 100644 index 00000000..fd17a695 --- /dev/null +++ b/app/controllers/notifications_controller.rb @@ -0,0 +1,12 @@ +class NotificationsController < ApplicationController + after_action :mark_notifications_as_read, only: :index + def index + @notifications = current_user.notifications.newest_first + end + + private + + def mark_notifications_as_read + current_user.notifications.mark_as_read! + end +end diff --git a/app/controllers/preferences_controller.rb b/app/controllers/preferences_controller.rb new file mode 100644 index 00000000..7c1d8784 --- /dev/null +++ b/app/controllers/preferences_controller.rb @@ -0,0 +1,36 @@ +# Preferences controller +# +class PreferencesController < ApplicationController + before_action :authenticate_user! + before_action :set_preference, only: %i[index update] + + def index + end + + def update + respond_to do |format| + if @preference.update(**permitted_params) + format.html { redirect_to preferences_url, notice: I18n.t("preferences.updated") } + format.json { render :index, status: :ok, location: @preference } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @preference.errors, status: :unprocessable_entity } + end + end + end + + private + + def set_preference + @preference = current_user.preference + end + + def permitted_params + params.require(:preference).permit( + :notify_any_post_in_discussion, + :notify_new_discussion_on_story, + :notify_new_post_on_discussion, + :notify_new_story + ) + end +end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 8b119dc9..8a6570c1 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -2,5 +2,7 @@ import { application } from "./application"; import ModalController from "./modal_controller"; +import TooltipController from "./tooltip_controller"; application.register("modal", ModalController); +application.register("tooltip", TooltipController); diff --git a/app/javascript/controllers/tooltip_controller.js b/app/javascript/controllers/tooltip_controller.js new file mode 100644 index 00000000..6ee9c621 --- /dev/null +++ b/app/javascript/controllers/tooltip_controller.js @@ -0,0 +1,10 @@ +import { Controller } from "@hotwired/stimulus"; +import * as bootstrap from "bootstrap"; + +// initialise bootstrap tooltips +export default class Tooltip extends Controller { + connect() { + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); + } +} diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c814..dfb4dd8f 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,4 @@ class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" + default from: Rails.application.config.mailer_default_from layout "mailer" end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb new file mode 100644 index 00000000..ea138173 --- /dev/null +++ b/app/mailers/notification_mailer.rb @@ -0,0 +1,65 @@ +class NotificationMailer < ApplicationMailer + layout "mailer" + + def notify_new_story + @user = params[:recipient] + @story = params[:story] + + # make sure that the email is always sent in the language preferred + # by the user + I18n.with_locale(@user.language) do + mail( + to: email_address_with_name(@user.email, @user.name), + subject: t(".subject", title: @story.title) + ) + end + end + + def notify_new_discussion + @user = params[:recipient] + @discussion = params[:discussion] + @story = params[:story] + + # make sure that the email is always sent in the language preferred + # by the user + I18n.with_locale(@user.language) do + mail( + to: email_address_with_name(@user.email, @user.name), + subject: t(".subject", story_title: @story.title) + ) + end + end + + def notify_new_post + @user = params[:recipient] + @discussion = params[:discussion] + @story = params[:story] + @post = params[:post] + + # make sure that the email is always sent in the language preferred + # by the user + I18n.with_locale(@user.language) do + mail( + to: email_address_with_name(@user.email, @user.name), + subject: t(".subject", title: @discussion.title) + ) + end + end + + def invite_contributor + @user = params[:recipient] + @story = params[:story] + + unless @user.confirmed? + @user.invitation_sent_at = Time.zone.now + @token, enc = Devise.token_generator.generate(User, :invitation_token) + @user.invitation_token = enc + @user.save(validate: false) + end + + mail( + to: @user.email, + subject: t(".subject", title: @story.title) + ) + end +end diff --git a/app/models/contribution.rb b/app/models/contribution.rb new file mode 100644 index 00000000..d4a989ba --- /dev/null +++ b/app/models/contribution.rb @@ -0,0 +1,12 @@ +class Contribution < ApplicationRecord + belongs_to :user + belongs_to :story + + after_create_commit :invite_contributor, unless: -> { Rails.env.test? } + + private + + def invite_contributor + InviteContributorNotification.with(story:).deliver_later([user]) + end +end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 05e0e421..8cec2238 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -7,9 +7,19 @@ class Discussion < ApplicationRecord belongs_to :story belongs_to :updater, class_name: "User" has_many :posts, dependent: :destroy + has_noticed_notifications validates :title, :description, presence: true + # @todo remove the unless filter + # figure out why tests get stuck and thorw errors like these + # https://github.com/compassionprojects/socialchange/actions/runs/7434566400/job/20228849583?pr=57 + # WARNING: there is already a transaction in progress + # ActiveRecord::StatementInvalid: + # PG::UnableToSend: insufficient data in "T" message + # + after_create_commit :notify, unless: -> { Rails.env.test? } + # After a topic is discarded, discard it's posts # after_discard do @@ -19,4 +29,14 @@ class Discussion < ApplicationRecord after_undiscard do posts.undiscard_all end + + def participants + User.joins(:posts).merge(Post.kept.where(discussion_id: id)).distinct + end + + private + + def notify + NewDiscussionNotification.with(discussion: self, story:).deliver_later([story.user]) + end end diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 00000000..99cf7cee --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,4 @@ +class Notification < ApplicationRecord + include Noticed::Model + belongs_to :recipient, polymorphic: true +end diff --git a/app/models/post.rb b/app/models/post.rb index b0487eb1..eeb99c2e 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -6,6 +6,19 @@ class Post < ApplicationRecord belongs_to :discussion belongs_to :user belongs_to :updater, class_name: "User" + has_noticed_notifications validates :body, presence: true + + # @todo remove the unless filter after fixing the tests + # same issue as discussion model + after_create_commit :notify, unless: -> { Rails.env.test? } + + private + + def notify + # notify discussion creator and anyone else participating in the discussion + participants = (discussion.participants + [discussion.user]).uniq + NewPostNotification.with(post: self, discussion:, story: discussion.story).deliver_later(participants) + end end diff --git a/app/models/preference.rb b/app/models/preference.rb new file mode 100644 index 00000000..72364d94 --- /dev/null +++ b/app/models/preference.rb @@ -0,0 +1,3 @@ +class Preference < ApplicationRecord + belongs_to :user +end diff --git a/app/models/story.rb b/app/models/story.rb index 891f8f3b..1208f1cf 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -5,11 +5,19 @@ class Story < ApplicationRecord include Discard::Model include Translatable + MAX_CONTRIBUTORS = 4 + belongs_to :user belongs_to :updater, class_name: "User" has_many :story_updates, -> { kept.order(created_at: :asc) }, dependent: :destroy, inverse_of: :story has_many :discussions, dependent: :destroy # @todo: add inverse_of and default order has_many_attached :documents + has_many :contributions, dependent: :destroy + has_many :contributors, through: :contributions, source: :user + + has_noticed_notifications + + after_create_commit :notify # @todo: remove status enum :status, %i[draft published] @@ -49,4 +57,54 @@ def country_name c = ISO3166::Country[country] c.translations[I18n.locale.to_s] || c.common_name || c.iso_short_name end + + # Invite contributors to the story + # @param [Array] emails + # @param [User] inviter + # - Skip if user is already a contributor, inviter, or the story owner + # - If the user is not confirmed, re-invite him + # - If the user doesn't exist, create him with invitation and skip invitation email (let contribution hooks send the email) + # - Add the user as a contributor if they are not already + # - Make sure inviter is a contributor or the story owner + # + def invite_contributors(emails, inviter = nil) + # Make sure inviter is a contributor or the story owner + # @todo use policy + raise StandardError, "Inviter must be a contributor or the owner of the story" if inviter && !contributed?(inviter) && inviter != user + + # Validate emails + # And add limit + limit = MAX_CONTRIBUTORS - contributors.length + emails.map(&:strip).uniq.select { |e| e =~ Devise.email_regexp }.take(limit).each do |email| + user = User.find_by(email:) + + # Skip if the user is already a contributor, the inviter, or the story owner + next if (user&.confirmed? && contributed?(user)) || user == inviter || user == self.user + + # If the user is not confirmed, re-invite them + if user + user.invite! unless user.confirmed? + else + # If the user doesn't exist, invite them + user = User.invite!({email:}, inviter) { |u| u.skip_invitation = true } + end + + # Add the user as a contributor if they are not already + contributions.create(user:) unless contributed?(user) + end + end + + def contributed?(user) + contributors.any? { |u| u.id == user.id } + end + + def confirmed_contributors + contributors.filter { |u| u.confirmed? } + end + + private + + def notify + NewStoryNotification.with(story: self).deliver_later(User.with_notify_new_story_preference) + end end diff --git a/app/models/user.rb b/app/models/user.rb index 1b9f108f..2cefa549 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,6 +8,10 @@ class User < ApplicationRecord has_many :stories, dependent: :destroy has_many :discussions, dependent: :destroy has_many :posts, dependent: :destroy + has_one :preference, dependent: :destroy + has_many :notifications, as: :recipient, dependent: :destroy + has_many :contributions, dependent: :destroy + has_many :contributed_stories, through: :contributions, source: :story # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable @@ -15,6 +19,8 @@ class User < ApplicationRecord :recoverable, :rememberable, :validatable, :confirmable, :trackable, :lockable + after_invitation_accepted :create_default_preference + validates :name, presence: true validates :language, inclusion: {in: I18n.available_locales.map(&:to_s)}, allow_nil: true @@ -32,6 +38,10 @@ class User < ApplicationRecord posts.undiscard_all end + scope :with_notify_new_story_preference, -> { + joins(:preference).kept.where(preferences: {notify_new_story: true}) + } + def language=(u) self["language"] = u.presence end @@ -47,4 +57,18 @@ def permissions def active_for_authentication? super && !discarded? end + + def after_confirmation + create_default_preference + end + + def unread_notifications? + notifications.unread.count > 0 + end + + private + + def create_default_preference + build_preference.save + end end diff --git a/app/notifications/invite_contributor_notification.rb b/app/notifications/invite_contributor_notification.rb new file mode 100644 index 00000000..9565ecaf --- /dev/null +++ b/app/notifications/invite_contributor_notification.rb @@ -0,0 +1,28 @@ +# Invite a story contributor +# +class InviteContributorNotification < Noticed::Base + deliver_by :database + deliver_by :email, mailer: "NotificationMailer", method: :invite_contributor # , if: :email_notifications? + + # Add required params + # + param :story + + # Define helper methods to make rendering easier. + # + def message + t(".message", story_title: story.title) + end + + def url + story_path(story) + end + + # def email_notifications? + # recipient.accepted_invitation? + # end + + def story + params[:story] + end +end diff --git a/app/notifications/new_discussion_notification.rb b/app/notifications/new_discussion_notification.rb new file mode 100644 index 00000000..97578d3a --- /dev/null +++ b/app/notifications/new_discussion_notification.rb @@ -0,0 +1,38 @@ +# To deliver this notification: +# +# NewDiscussionNotification.with(post: @post).deliver_later(current_user) +# NewDiscussionNotification.with(post: @post).deliver(current_user) + +class NewDiscussionNotification < Noticed::Base + deliver_by :database, if: :email_notifications? + deliver_by :email, mailer: "NotificationMailer", method: :notify_new_discussion, if: :email_notifications? + + # Add required params + # + param :discussion + param :story + + # Define helper methods to make rendering easier. + # + def message + t(".message", title: discussion.title, story_title: story.title) + end + + def url + story_discussion_path(story, discussion) + end + + # Make sure recipient is not the discussion creator himself + # Also check if recipient has a preference enabled to be notified + def email_notifications? + recipient.preference.notify_new_discussion_on_story && recipient.id != discussion.user.id + end + + def discussion + params[:discussion] + end + + def story + params[:story] + end +end diff --git a/app/notifications/new_post_notification.rb b/app/notifications/new_post_notification.rb new file mode 100644 index 00000000..6eff050c --- /dev/null +++ b/app/notifications/new_post_notification.rb @@ -0,0 +1,49 @@ +# To deliver this notification: +# +# NewPostNotification.with(post: @post).deliver_later(current_user) +# NewPostNotification.with(post: @post).deliver(current_user) + +class NewPostNotification < Noticed::Base + deliver_by :database, if: :email_notifications? + deliver_by :email, mailer: "NotificationMailer", method: :notify_new_post, if: :email_notifications? + + # Add required params + # + param :post + param :discussion + param :story + + # Define helper methods to make rendering easier. + # + def message + t(".message", discussion_title: discussion.title) + end + + def url + story_discussion_path(story, discussion) + end + + # we send notifications to all participants who have set their preference + # and also to the discussion creator if preference is set + def email_notifications? + # skip when recipient is the post creator + return false if recipient.id == post.user.id + if recipient.id == discussion.user.id # discussion owner + recipient.preference.notify_new_post_on_discussion + else # any participants in post + recipient.preference.notify_any_post_in_discussion + end + end + + def post + params[:post] + end + + def discussion + params[:discussion] + end + + def story + params[:story] + end +end diff --git a/app/notifications/new_story_notification.rb b/app/notifications/new_story_notification.rb new file mode 100644 index 00000000..9734f4cf --- /dev/null +++ b/app/notifications/new_story_notification.rb @@ -0,0 +1,34 @@ +# Deliver notifications to all users about the new story to all users who have enabled +# :notify_new_story in their preferences, except for the story creator (or story +# collaborators) + +class NewStoryNotification < Noticed::Base + deliver_by :database + deliver_by :email, mailer: "NotificationMailer", method: :notify_new_story, if: :email_notifications? + + # Add required params + # + param :story + + # Define helper methods to make rendering easier. + # + def message + t(".message", title: story.title) + end + + def url + story_path(story) + end + + # @todo make sure this checks for story collaborators as well when + # collaboration feature is ready. + # Note that we are not checking for recepient.preference.notify_new_story + # here becasue this has already been filtered out in the deliver_later call + def email_notifications? + recipient.id != story.user.id + end + + def story + params[:story] + end +end diff --git a/app/policies/story_policy.rb b/app/policies/story_policy.rb index 6757250f..2e601a69 100644 --- a/app/policies/story_policy.rb +++ b/app/policies/story_policy.rb @@ -33,7 +33,12 @@ def resource "stories" end + # for now assume all contributors have the same permissions as the owner def owns_resource? user.id == record.user.id end + + def update? + can_manage_resource? || user.has_permission?(:update, resource) || owns_resource? || record.contributed?(user) + end end diff --git a/app/policies/story_update_policy.rb b/app/policies/story_update_policy.rb index 49ca03e2..8c2bc7c0 100644 --- a/app/policies/story_update_policy.rb +++ b/app/policies/story_update_policy.rb @@ -34,6 +34,6 @@ def resource end def owns_resource? - user.id == record.user.id && user.id == record.story.user_id + (user.id == record.user.id && user.id == record.story.user_id) || record.story.contributed?(user) end end diff --git a/app/views/contributions/_form.html.erb b/app/views/contributions/_form.html.erb new file mode 100644 index 00000000..b7bb6358 --- /dev/null +++ b/app/views/contributions/_form.html.erb @@ -0,0 +1,13 @@ +<%= form_with(model: [contribution.story, contribution]) do |form| %> +
+ <%= form.label :emails, :class => "form-label" %> + <%= form.text_area :emails, required: true, class: "form-control", placeholder: t('.placeholder') %> +
+ <%= t('.help_html', count: Story::MAX_CONTRIBUTORS) %> +
+
+ +
+ <%= form.submit t('.invite'), :class => "btn btn-primary" %> +
+<% end %> diff --git a/app/views/contributions/new.html.erb b/app/views/contributions/new.html.erb new file mode 100644 index 00000000..20485773 --- /dev/null +++ b/app/views/contributions/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, t('.title') %> + +<%= turbo_frame_tag "modal" do %> + +

<%= yield(:title) %>

+ + + +<% end %> diff --git a/app/views/discussions/_discussion.html.erb b/app/views/discussions/_discussion.html.erb index 27a3437f..71000df5 100644 --- a/app/views/discussions/_discussion.html.erb +++ b/app/views/discussions/_discussion.html.erb @@ -1,46 +1,41 @@ -
-

- <%= @discussion.title %> -

-
- <%= time_ago(@discussion.created_at) %> <%= t("posted_by", name: @discussion.user.name) %> -
- -
-
-
-

- <%= display_rich_text(@discussion.description) %> -

-
- -
- <% if can_edit?(@discussion) %> - <%= link_to t("common.edit"), edit_discussion_path(@discussion), :class => "btn btn-link p-0" %>  - <% end %> - <% if can_destroy?(@discussion) %> - <%= button_to t("common.delete"), discussion_path(@discussion), method: :delete, :class => "btn btn-link text-danger py-0", form: { data: { turbo_method: :delete, turbo_confirm: t('common.are_you_sure') } } %> - <% end %> -
- -
- <%= turbo_frame_tag :posts do %> - <% @posts.each_with_index do |post, index| %> - <%= turbo_frame_tag dom_id(post) do %> - <%= render "posts/list_item", post:, index:, border_top: true %> - <% end %> - <% end %> - <% end %> +
+
+
+

+ <%= @discussion.title %> +

+

+ <%= display_rich_text(@discussion.description) %> +

+
+ <%= time_ago(@discussion.created_at) %> <%= t("posted_by", name: @discussion.user.name) %>
+
-
- <%= paginate @posts, except: [:first_page, :last_page] %> -
+
+ <% if can_edit?(@discussion) %> + <%= link_to t("common.edit"), edit_discussion_path(@discussion), :class => "btn btn-link p-0" %>  + <% end %> + <% if can_destroy?(@discussion) %> + <%= button_to t("common.delete"), discussion_path(@discussion), method: :delete, :class => "btn btn-link text-danger py-0", form: { data: { turbo_method: :delete, turbo_confirm: t('common.are_you_sure') } } %> + <% end %>
-
- <% if user_signed_in? %> - <%= link_to t(".new_post"), new_discussion_post_path(@discussion), :class => "btn btn-primary", data: { turbo_frame: "modal" } %> + +
+ <%= turbo_frame_tag :posts do %> + <% @posts.each_with_index do |post, index| %> + <%= render "posts/list_item", post:, index:, border_top: true %> + <% end %> <% end %>
+ +
+ <%= paginate @posts, except: [:first_page, :last_page] %> +
+
+
+ <% if user_signed_in? %> + <%= link_to t(".new_post"), new_discussion_post_path(@discussion), :class => "btn btn-primary", data: { turbo_frame: "modal" } %> + <% end %>
diff --git a/app/views/includes/_header.html.erb b/app/views/includes/_header.html.erb index bd120df4..ae560586 100644 --- a/app/views/includes/_header.html.erb +++ b/app/views/includes/_header.html.erb @@ -26,14 +26,20 @@ -
diff --git a/app/views/story_updates/_list_item.html.erb b/app/views/story_updates/_list_item.html.erb index ccbc63d2..343e85a0 100644 --- a/app/views/story_updates/_list_item.html.erb +++ b/app/views/story_updates/_list_item.html.erb @@ -1,17 +1,19 @@ -
- <%= story_update.title %> -
- <%= time_ago(story_update.created_at) %> <%= t("posted_by", name: story_update.user.name) %> +<%= turbo_frame_tag dom_id(story_update) do %> +
+ <%= story_update.title %> +
+ <%= time_ago(story_update.created_at) %> <%= t("posted_by", name: story_update.user.name) %> +
+

+ <%= display_rich_text(story_update.description) %> +

+
+ <% if can_edit?(story_update) %> + <%= link_to t("common.edit"), edit_story_update_path(story_update), :class => "btn btn-sm btn-link px-0" %>  + <% end %> + <% if can_destroy?(story_update) %> + <%= button_to t("common.delete"), story_update, method: :delete, :class => "btn btn-sm btn-link text-danger", form: { data: { turbo_method: :delete, turbo_confirm: t('common.are_you_sure') } } %> + <% end %> +
-

- <%= display_rich_text(story_update.description) %> -

-
- <% if can_edit?(story_update) %> - <%= link_to t("common.edit"), edit_story_update_path(story_update), :class => "btn btn-sm btn-link px-0" %>  - <% end %> - <% if can_destroy?(story_update) %> - <%= button_to t("common.delete"), story_update, method: :delete, :class => "btn btn-sm btn-link text-danger", form: { data: { turbo_method: :delete, turbo_confirm: t('common.are_you_sure') } } %> - <% end %> -
-
+<% end %> diff --git a/app/views/users/_you.html.erb b/app/views/users/_you.html.erb new file mode 100644 index 00000000..f0ba71e9 --- /dev/null +++ b/app/views/users/_you.html.erb @@ -0,0 +1,3 @@ +<% if current_user&.id == user.id %> + (<%= t('users.you') %>) +<% end %> diff --git a/config/application.rb b/config/application.rb index d50c8a5b..129ff1bd 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,5 +23,8 @@ class Application < Rails::Application config.i18n.available_locales = %i[en nl] config.i18n.default_locale = :en config.i18n.fallbacks = true + + # set default email sender + config.mailer_default_from = ENV["DOMAIN_EMAIL_ADDRESS"] end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 05a81316..48a369f1 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -42,7 +42,7 @@ config.action_mailer.perform_caching = false # Default app url - config.action_mailer.default_url_options = {host: ENV["DOMAIN_NAME"], port: 3000} + config.action_mailer.default_url_options = {host: ENV["DOMAIN_NAME"], port: 3000, lang: I18n.locale} # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/environments/production.rb b/config/environments/production.rb index 40b6c6df..b390ceab 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -65,7 +65,7 @@ config.action_mailer.perform_caching = false # Default app url - config.action_mailer.default_url_options = {host: ENV["DOMAIN_NAME"]} + config.action_mailer.default_url_options = {host: ENV["DOMAIN_NAME"], lang: I18n.locale} # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. diff --git a/config/environments/test.rb b/config/environments/test.rb index e95f8e87..bf4fe0f9 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -47,7 +47,7 @@ config.action_mailer.delivery_method = :test # Default app url - config.action_mailer.default_url_options = {host: ENV["DOMAIN_NAME"], port: 3000} + config.action_mailer.default_url_options = {host: ENV["DOMAIN_NAME"], port: 3000, lang: I18n.locale} # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 305c3ac1..29092547 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -31,6 +31,8 @@ # Configure the parent class responsible to send e-mails. # config.parent_mailer = 'ActionMailer::Base' + # use ApplicationMailer so that we have the same layout for all emails + config.parent_mailer = "ApplicationMailer" # ==> ORM configuration # Load and configure the ORM. Supports :active_record (default) and diff --git a/config/initializers/noticed.rb b/config/initializers/noticed.rb new file mode 100644 index 00000000..223990ad --- /dev/null +++ b/config/initializers/noticed.rb @@ -0,0 +1,7 @@ +module Noticed + class Base + def default_url_options + {lang: I18n.locale} + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 846b5781..9c747bac 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -29,6 +29,43 @@ en: cancel_account_notice: "This is an irreversible action, there is no going back!" profile: "Profile" invite: "Invite" + preferences: "Preferences" + notifications: "Notifications" + you: "you" + preferences: + updated: "Preferences updated" + index: + preferences: "Preferences" + save: "Save" + notify_new_discussion_on_story: "Notify me of any new discussions on a story I created" + notify_new_post_on_discussion: "Notify me of any new posts on a discussion I created" + notify_any_post_in_discussion: "Notify me of any new posts in a discussion I participate in" + notify_new_story: "Notify me of any new stories that are added" + notifications: + index: + notifications: "Notifications" + new_story_notification: + message: "A new story '%{title}' was published" + new_discussion_notification: + message: "A new discussion was added %{title} on your Social Change story '%{story_title}'" + new_post_notification: + message: "A new post was added on the discussion '%{discussion_title}'" + invite_contributor_notification: + message: "You have been added as a contributor to '%{story_title}'" + notification_mailer: + notify_new_story: + subject: "A new NVC Social Change Story '%{title}' was published" + body_html: "Hi %{user_first_name}, a new NVC Social Change story %{story_title} was published. You may check it out by clicking on the below link." + notify_new_discussion: + subject: "A new discussion was posted on your story '%{story_title}'" + body_html: "Hi %{user_first_name}, a new discussion has been posted on your Social Change Story '%{story_title}'. You may check it out by clicking on the below link." + notify_new_post: + subject: "A new post has been added on the discussion '%{title}'" + body_html: "Hi %{user_first_name}, a new post has been added to the discussion %{discussion_title} in the Social Change Story '%{story_title}'. You may check it out by clicking on the below link." + invite_contributor: + subject: "You have been added as a contributor to a Social Change Story '%{title}'" + body_invite_html: "Hi, you have been added as a contributor to NVC Social Change story %{story_title}. Please accept the invite by clicking on the below link and making an account." + body_confirmed_html: "Hi, you have been added as a contributor to NVC Social Change story %{story_title}. You can now access the story by clicking on the below link." stories: created: "Story was successfully created" updated: "Story was successfully updated" @@ -53,10 +90,29 @@ en: story: new_story_update: "New story update" no_story_updates: "There are no updates for this story yet" - num_attached_documents: "%{count} attached documents" + sidebar: + num_attached_documents: "%{count} attached document(s)" num_story_updates: "%{count} story updates" translations_available_in_html: "Translations available in %{languages}" translations_missing_for_html: "This story hasn't been translated to %{languages}" + contributors: "Contributors" + invite_contributors: "Invite contributors" + contributors: + invited: "Invited" + leave_story: "Leave story" + contributions: + new: + title: "Invite contributors" + emails: "Email addresses" + invite_contributors: "Invite contributors" + form: + invite: "Invite" + help_html: "Separate email addresses with a comma. For example: one@domain.com, two@domain.com.
You can invite a maximum of %{count} contributors" + placeholder: "one@domain.com, two@domain.com" + create: + created: "Contributor was successfully added" + destroy: + removed: "Contributor was successfully removed" story_updates: new: title: "New story update" diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 868466e1..4d876a37 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -29,6 +29,43 @@ nl: cancel_account_notice: "Dit is een onomkeerbare actie, er is geen weg terug!" profile: "Profiel" invite: "Uitnodigen" + preferences: "Preferences" + notifications: "Notifications" + you: "you" + preferences: + updated: "Voorkeuren bijgewerkt" + index: + preferences: "Voorkeuren" + save: "Opslaan" + notify_new_discussion_on_story: "Notify me of any new discussions on a story I created" + notify_new_post_on_discussion: "Notify me of any new posts on a discussion I created" + notify_any_post_in_discussion: "Notify me of any new posts in a discussion I participate in" + notify_new_story: "Notify me of any new stories that are added" + notifications: + index: + notifications: "Notifications" + new_story_notification: + message: "A new story '%{title}' was published" + new_discussion_notification: + message: "A new discussion was added %{title} on your Social Change story '%{story_title}'" + new_post_notification: + message: "A new post was added on the discussion '%{discussion_title}'" + invite_contributor_notification: + message: "You have been added as a contributor to '%{story_title}'" + notification_mailer: + notify_new_story: + subject: "A new NVC Social Change Story '%{title}' was published" + body_html: "Hi %{user_first_name}, a new NVC Social Change story %{story_title} was published. You may check it out by clicking on the below link." + notify_new_discussion: + subject: "A new discussion was posted on your story %{story_title}" + body_html: "Hi %{user_first_name}, a new discussion has been posted on your Social Change Story '%{story_title}'. You may check it out by clicking on the below link." + notify_new_post: + subject: "A new post has been added on the discussion '%{title}'" + body_html: "Hi %{user_first_name}, a new post has been added to the discussion %{discussion_title} in the Social Change Story '%{story_title}'. You may check it out by clicking on the below link." + invite_contributor: + subject: "You have been added as a contributor to a Social Change Story '%{title}'" + body_invite_html: "Hi, you have been added as a contributor to NVC Social Change story %{story_title}. Please accept the invite by clicking on the below link and making an account." + body_confirmed_html: "Hi, you have been added as a contributor to NVC Social Change story %{story_title}. You can now access the story by clicking on the below link." stories: created: "Story was successfully created" updated: "Story was successfully updated" @@ -53,10 +90,29 @@ nl: story: new_story_update: "New Story Update" no_story_updates: "There are no updates for this story yet" - num_attached_documents: "%{count} attached documents" + sidebar: + num_attached_documents: "%{count} attached document(s)" num_story_updates: "%{count} story updates" translations_available_in_html: "Beschikbare vertalingen in %{languages}" translations_missing_for_html: "Dit verhaal is niet vertaald in %{languages}" + contributors: "Contributors" + invite_contributors: "Invite contributors" + contributors: + invited: "Invited" + leave_story: "Leave story" + contributions: + new: + title: "Invite contributors" + emails: "Email addresses" + invite_contributors: "Invite contributors" + form: + invite: "Invite" + help_html: "Separate email addresses with a comma. For example: one@domain.com, two@domain.com.
You can invite a maximum of %{count} contributors" + placeholder: "one@domain.com, two@domain.com" + create: + created: "Contributor was successfully added" + destroy: + removed: "Contributor was successfully removed" story_updates: new: title: "New Story Update" diff --git a/config/routes.rb b/config/routes.rb index 5a35b300..36a733c5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,6 +4,7 @@ scope "(:lang)", lang: /#{I18n.available_locales.join('|')}/ do resources :stories do + resources :contributions, only: %i[new create destroy] collection do match "search" => "stories#search", :via => %i[get post], :as => :search match "remove_documents/:id" => "stories#remove_documents", :via => [:delete], :as => :remove_documents @@ -15,6 +16,8 @@ end get "stories/:story_id/discussions/:id", to: "discussions#show", as: :story_discussion + resources :preferences, only: %i[index update] + resources :notifications, only: %i[index] root "home#index" end diff --git a/db/migrate/20230617123104_create_preferences.rb b/db/migrate/20230617123104_create_preferences.rb new file mode 100644 index 00000000..c7833bc3 --- /dev/null +++ b/db/migrate/20230617123104_create_preferences.rb @@ -0,0 +1,13 @@ +class CreatePreferences < ActiveRecord::Migration[7.0] + def change + create_table :preferences do |t| + t.references :user, null: false, foreign_key: true + t.boolean :notify_new_discussion_on_story, null: false, default: true + t.boolean :notify_new_post_on_discussion, null: false, default: true + t.boolean :notify_any_post_in_discussion, null: false, default: true + t.boolean :notify_new_story, null: false, default: false + + t.timestamps + end + end +end diff --git a/db/migrate/20240102141608_create_notifications.rb b/db/migrate/20240102141608_create_notifications.rb new file mode 100644 index 00000000..feacf502 --- /dev/null +++ b/db/migrate/20240102141608_create_notifications.rb @@ -0,0 +1,13 @@ +class CreateNotifications < ActiveRecord::Migration[7.0] + def change + create_table :notifications do |t| + t.references :recipient, polymorphic: true, null: false + t.string :type, null: false + t.jsonb :params + t.datetime :read_at + + t.timestamps + end + add_index :notifications, :read_at + end +end diff --git a/db/migrate/20240108152053_create_contributions.rb b/db/migrate/20240108152053_create_contributions.rb new file mode 100644 index 00000000..0c70417b --- /dev/null +++ b/db/migrate/20240108152053_create_contributions.rb @@ -0,0 +1,12 @@ +class CreateContributions < ActiveRecord::Migration[7.1] + def change + create_table :contributions do |t| + t.references :user, null: false, foreign_key: true + t.references :story, null: false, foreign_key: true + + t.timestamps + end + + add_index :contributions, [:user_id, :story_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9fdf29ef..cad3e0f8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_02_12_101322) do +ActiveRecord::Schema[7.1].define(version: 2024_01_08_152053) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" enable_extension "plpgsql" @@ -43,6 +43,16 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "contributions", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "story_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["story_id"], name: "index_contributions_on_story_id" + t.index ["user_id", "story_id"], name: "index_contributions_on_user_id_and_story_id", unique: true + t.index ["user_id"], name: "index_contributions_on_user_id" + end + create_table "discussions", force: :cascade do |t| t.string "title", null: false t.text "description", null: false @@ -58,6 +68,18 @@ t.index ["user_id"], name: "index_discussions_on_user_id" end + create_table "notifications", force: :cascade do |t| + t.string "recipient_type", null: false + t.bigint "recipient_id", null: false + t.string "type", null: false + t.jsonb "params" + t.datetime "read_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["read_at"], name: "index_notifications_on_read_at" + t.index ["recipient_type", "recipient_id"], name: "index_notifications_on_recipient" + end + create_table "permissions", force: :cascade do |t| t.string "name", null: false t.datetime "created_at", null: false @@ -87,6 +109,17 @@ t.index ["user_id"], name: "index_posts_on_user_id" end + create_table "preferences", force: :cascade do |t| + t.bigint "user_id", null: false + t.boolean "notify_new_discussion_on_story", default: true, null: false + t.boolean "notify_new_post_on_discussion", default: true, null: false + t.boolean "notify_any_post_in_discussion", default: true, null: false + t.boolean "notify_new_story", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_preferences_on_user_id" + end + create_table "roles", force: :cascade do |t| t.string "name", null: false t.datetime "created_at", null: false @@ -181,12 +214,15 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "contributions", "stories" + add_foreign_key "contributions", "users" add_foreign_key "discussions", "stories" add_foreign_key "discussions", "users" add_foreign_key "discussions", "users", column: "updater_id" add_foreign_key "posts", "discussions" add_foreign_key "posts", "users" add_foreign_key "posts", "users", column: "updater_id" + add_foreign_key "preferences", "users" add_foreign_key "stories", "users" add_foreign_key "stories", "users", column: "updater_id" add_foreign_key "story_updates", "stories" diff --git a/db/seeds.rb b/db/seeds.rb index 57574beb..2df694e5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -8,6 +8,6 @@ require "factory_bot_rails" -5.times do |i| - FactoryBot.create(:user_with_stories, stories_count: 3, name: "User #{i}") +2.times do |i| + FactoryBot.create(:user_with_stories_discussions_posts, name: "User #{i}") end diff --git a/lib/tasks/preferences.rake b/lib/tasks/preferences.rake new file mode 100644 index 00000000..daa94f23 --- /dev/null +++ b/lib/tasks/preferences.rake @@ -0,0 +1,8 @@ +namespace :preferences do + desc "Create missing preferences for users" + task create_missing_preferences: :environment do + User.find_each do |user| + user.create_preference unless user.preference && user.confirmed? + end + end +end diff --git a/spec/factories/contributions.rb b/spec/factories/contributions.rb new file mode 100644 index 00000000..075f036e --- /dev/null +++ b/spec/factories/contributions.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :contribution do + user + story + end +end diff --git a/spec/factories/discussion.rb b/spec/factories/discussion.rb index a454ab97..dc65812b 100644 --- a/spec/factories/discussion.rb +++ b/spec/factories/discussion.rb @@ -6,5 +6,13 @@ user updater factory: :user + + factory :discussion_with_posts do + transient do + posts_count { 5 } + end + + posts { build_list(:post, posts_count, discussion: instance) } + end end end diff --git a/spec/factories/notifications.rb b/spec/factories/notifications.rb new file mode 100644 index 00000000..bf5291a2 --- /dev/null +++ b/spec/factories/notifications.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :notification do + recipient factory: :user + type { "" } + params { "" } + read_at { Time.zone.today } + end +end diff --git a/spec/factories/preference.rb b/spec/factories/preference.rb new file mode 100644 index 00000000..a7f4f8fb --- /dev/null +++ b/spec/factories/preference.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :preference do + user + end +end diff --git a/spec/factories/user.rb b/spec/factories/user.rb index db630f7d..e7ce3fe2 100644 --- a/spec/factories/user.rb +++ b/spec/factories/user.rb @@ -17,6 +17,20 @@ end end + factory :user_with_stories_discussions_posts do + transient do + stories_count { 2 } + discussions_count { 2 } + posts_count { 6 } + end + + stories do + Array.new(stories_count) do + association(:story, user: instance, updater: instance, discussions: build_list(:discussion_with_posts, discussions_count, posts_count: posts_count)) + end + end + end + factory :user_with_permissions, parent: :user do transient do permissions { [] } diff --git a/spec/mailers/notification_spec.rb b/spec/mailers/notification_spec.rb new file mode 100644 index 00000000..735a3f3c --- /dev/null +++ b/spec/mailers/notification_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +# we don't care that much about the user preference here +# becasuse that is taken care of in the Noticed notification class +# @todo improve these tests by testing for locales as well + +describe NotificationMailer, type: :mailer do + describe "#notify_new_story" do + let(:user) { create(:user) } + let(:story) { create(:story, title: "Test Story") } + let(:mail) { described_class.with(recipient: user, story: story).notify_new_story } + include_examples "a notification email", "A new NVC Social Change Story", "notify_new_story" + end + + describe "#notify_new_discussion" do + let(:user) { create(:user) } + let(:story) { create(:story) } + let(:discussion) { create(:discussion, title: "Test Discussion", story:) } + let(:mail) { described_class.with(recipient: user, discussion: discussion, story: story).notify_new_discussion } + include_examples "a notification email", "A new discussion", "notify_new_discussion" + end + + describe "#notify_new_post" do + let(:user) { create(:user) } + let(:story) { create(:story) } + let(:discussion) { create(:discussion, story:) } + let(:post) { create(:post, discussion:) } + let(:mail) { described_class.with(recipient: user, discussion: discussion, story: story, post: post).notify_new_post } + include_examples "a notification email", "A new post", "notify_new_post" + end + + describe "#invite_contributor" do + let(:user) { create(:user) } + let(:story) { create(:story, title: "Test Story") } + let(:mail) { described_class.with(recipient: user, story: story).invite_contributor } + + it "sends an email to the user with the correct subject" do + expect(mail.subject).to include("You have been added as a contributor") + end + + it "sends the email to the correct recipient" do + expect(mail.to).to eq([user.email]) + end + end +end diff --git a/spec/mailers/previews/devise_preview.rb b/spec/mailers/previews/devise_preview.rb new file mode 100644 index 00000000..1137e8db --- /dev/null +++ b/spec/mailers/previews/devise_preview.rb @@ -0,0 +1,26 @@ +# Devise emails can be previewed here +# http://localhost:3000/rails/mailers/devise_mailer/ + +class DeviseMailerPreview < ActionMailer::Preview + # We do not have confirmable enabled, but if we did, this is + # how we could generate a preview: + def confirmation_instructions + Devise::Mailer.confirmation_instructions(User.first, "faketoken") + end + + def reset_password_instructions + Devise::Mailer.reset_password_instructions(User.first, "faketoken") + end + + def unlock_instructions + Devise::Mailer.unlock_instructions(User.first, "faketoken") + end + + def email_changed + Devise::Mailer.email_changed(User.first) + end + + def password_changed + Devise::Mailer.password_change(User.first) + end +end diff --git a/spec/mailers/previews/notification_preview.rb b/spec/mailers/previews/notification_preview.rb new file mode 100644 index 00000000..4d2f28fd --- /dev/null +++ b/spec/mailers/previews/notification_preview.rb @@ -0,0 +1,18 @@ +# Preview all emails at http://localhost:3000/rails/mailers/notification +class NotificationPreview < ActionMailer::Preview + def notify_new_story + NotificationMailer.with(recipient: User.first, story: Story.first).notify_new_story + end + + def notify_new_discussion + NotificationMailer.with(recipient: User.first, discussion: Discussion.first, story: Story.first).notify_new_discussion + end + + def notify_new_post + NotificationMailer.with(recipient: User.first, discussion: Discussion.first, story: Story.first, post: Post.first).notify_new_post + end + + def invite_contributor + NotificationMailer.with(recipient: User.first, story: Story.first).invite_contributor + end +end diff --git a/spec/models/contribution_spec.rb b/spec/models/contribution_spec.rb new file mode 100644 index 00000000..927188c3 --- /dev/null +++ b/spec/models/contribution_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" + +describe Contribution, type: :model do + describe "validations" do + describe "user" do + subject { build(:contribution, user: nil) } + + it { is_expected.to be_invalid } + end + + describe "story" do + subject { build(:contribution, story: nil) } + + it { is_expected.to be_invalid } + end + + context "when valid" do + subject { build(:contribution) } + + it { is_expected.to be_valid } + end + end + + describe "associations" do + describe "user" do + subject { build(:contribution).user } + + it { is_expected.to be_a User } + end + + describe "story" do + subject { build(:contribution).story } + + it { is_expected.to be_a Story } + end + end +end diff --git a/spec/models/discussion_spec.rb b/spec/models/discussion_spec.rb index 0a208e5e..6c52b9ef 100644 --- a/spec/models/discussion_spec.rb +++ b/spec/models/discussion_spec.rb @@ -27,7 +27,7 @@ end end - describe "discard" do + describe "#discard" do let(:discussion) { create(:discussion) } it "discards all associated records" do @@ -43,4 +43,25 @@ expect(Post.kept).to be_empty end end + + describe "#participants" do + let(:discussion) { create(:discussion) } + + it "lists all people who have participated on the discussion" do + posts = create_list(:post, 3, discussion:) + posts.first.discard # remove a post + # since we removed one, that post participant should be excluded + expect(discussion.participants.count).to eql(2) + end + end + + xdescribe "after_create_commit" do + let(:discussion) { build(:discussion) } + + it "triggers the notify method from the hook" do + allow(discussion).to receive(:notify).and_call_original + discussion.save + expect(discussion).to have_received(:notify) + end + end end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb new file mode 100644 index 00000000..9d2f9993 --- /dev/null +++ b/spec/models/notification_spec.rb @@ -0,0 +1,6 @@ +require "rails_helper" + +# @todo add tests + +describe Notification, type: :model do +end diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb index e4f7dd17..650f3ad2 100644 --- a/spec/models/story_spec.rb +++ b/spec/models/story_spec.rb @@ -97,4 +97,100 @@ # rubocop:enable RSpec/ExampleLength # rubocop:enable RSpec/MultipleExpectations end + + describe "after_create_commit" do + let(:story) { build(:story) } + + it "triggers the notify method from the hook" do + allow(story).to receive(:notify).and_call_original + story.save + expect(story).to have_received(:notify) + end + end + + describe "#invite_contributors" do + let!(:user) { create(:user) } + let!(:story) { create(:story) } + let!(:contributor) { create(:contribution, story:).user } + let(:emails) { ["one@ex.com", "two@ex.com"] } + let!(:inviter) { create(:user) } + + it "calls the instance method" do + allow(story).to receive(:invite_contributors).with(emails) + story.invite_contributors(emails) + expect(story).to have_received(:invite_contributors).with(emails) + end + + it "allows only the story owner or a contributor to invite" do + expect do + story.invite_contributors(emails, inviter) + end.to raise_error(StandardError) + end + + it "skips invalid emails" do + expect do + story.invite_contributors(emails + ["abcde", "12345"], story.user) + end.to change { User.count }.by(2) + end + + it "limits contributors to a maximum of " + Story::MAX_CONTRIBUTORS.to_s do + story = create(:story) + expect do + story.invite_contributors(Array.new(10) { Faker::Internet.email }, story.user) + end.to change { User.count }.by(Story::MAX_CONTRIBUTORS) + end + + context "when user does not exist" do + it "adds contributors as users" do + expect do + story.invite_contributors(emails, story.user) + end.to change { User.count }.by(2) + end + + xit "invites user only once even if invited multiple times" do + ActiveJob::Base.queue_adapter = :test + expect do + story.invite_contributors(emails + emails, story.user) + end.to have_enqueued_job.twice + end + + it "adds contributions" do + expect do + story.invite_contributors(emails, story.user) + end.to change { Contribution.count }.by(2) + end + end + + context "when user exists" do + context "when user is not a contributor" do + it "does not create a new user" do + expect do + story.invite_contributors([user.email], story.user) + end.not_to change { User.count } + end + + it "adds a contribution" do + expect do + story.invite_contributors([user.email], story.user) + end.to change { Contribution.count }.by(1) + story.reload + expect(story.contributors).to include(user) + end + end + + context "when user is a contributor" do + it "does not create a new user" do + expect do + story.invite_contributors([contributor.email], story.user) + end.not_to change { User.count } + end + + it "does not add him again" do + expect do + story.invite_contributors([contributor.email], story.user) + end.not_to change { Contribution.count } + end + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c2c92142..974cd93a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -52,11 +52,26 @@ end end + describe "preference" do + let(:user) { create(:user, confirmed_at: nil) } + + context "when not confirmed" do + it "does not have a preference" do + expect(user.preference).to be_nil + end + end + + context "when confirmed" do + before { user.confirm } # confirm user's email manually which should run after_confirmation hook to create his preference + it "has a preference" do + expect(user.preference).not_to be_nil + end + end + end + describe "discard" do let(:user) { create(:user) } - # rubocop:disable RSpec/ExampleLength - # rubocop:disable RSpec/MultipleExpectations it "discards all associated records" do create_list(:story, 3, user:) create_list(:discussion, 3, user:) @@ -75,7 +90,17 @@ expect(Discussion.kept.where(user:)).to be_empty expect(Post.kept.where(user:)).to be_empty end - # rubocop:enable RSpec/ExampleLength - # rubocop:enable RSpec/MultipleExpectations + end + + describe "#with_notify_new_story_preference" do + it "only fetches users with preference turned on" do + # create a few users with a preference + users = create_list(:user, 3) do |user| + create(:preference, user:, notify_new_story: true) + end + # remove one user + users.first.discard + expect(described_class.with_notify_new_story_preference.count).to eql(2) + end end end diff --git a/spec/notifications/new_discussion_notification_spec.rb b/spec/notifications/new_discussion_notification_spec.rb new file mode 100644 index 00000000..05e7c10c --- /dev/null +++ b/spec/notifications/new_discussion_notification_spec.rb @@ -0,0 +1,6 @@ +require "rails_helper" + +# @todo add tests + +describe NewDiscussionNotification do +end diff --git a/spec/notifications/new_story_notification_spec.rb b/spec/notifications/new_story_notification_spec.rb new file mode 100644 index 00000000..e9f44c56 --- /dev/null +++ b/spec/notifications/new_story_notification_spec.rb @@ -0,0 +1,6 @@ +require "rails_helper" + +# @todo add tests + +describe NewStoryNotification do +end diff --git a/spec/policies/story_policy_spec.rb b/spec/policies/story_policy_spec.rb index e610f174..b8968e2d 100644 --- a/spec/policies/story_policy_spec.rb +++ b/spec/policies/story_policy_spec.rb @@ -17,6 +17,20 @@ it { is_expected.to permit_actions(%i[update edit destroy show]) } end + context "when contributors are modifying a story" do + let(:story) { create(:story, user:) } + let(:contributor) { create(:user) } + subject { described_class.new(contributor, story) } + + before do + story.invite_contributors([contributor.email], user) + story.reload + end + + it { is_expected.to permit_actions(%i[update edit]) } + it { is_expected.to forbid_actions(%i[destroy]) } + end + context "with stories.update permission" do let(:user) { create(:user_with_permissions, permissions: ["stories.update"]) } diff --git a/spec/policies/story_update_policy_spec.rb b/spec/policies/story_update_policy_spec.rb index b47196a8..2fad8f24 100644 --- a/spec/policies/story_update_policy_spec.rb +++ b/spec/policies/story_update_policy_spec.rb @@ -17,6 +17,20 @@ it { is_expected.to permit_actions(%i[update edit destroy show]) } end + context "with contributors" do + let(:story) { create(:story, user:) } + let(:story_update) { create(:story_update, user:, story:) } + let(:contributor) { create(:user) } + subject { described_class.new(contributor, story_update) } + + before do + story.invite_contributors([contributor.email], user) + story.reload + end + + it { is_expected.to permit_actions(%i[new create update edit destroy]) } + end + context "with story_updates.update permission" do let(:user) { create(:user_with_permissions, permissions: ["story_updates.update"]) } diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 8b0bc614..8dbdd0fd 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -9,6 +9,7 @@ require_relative "./support/factory_bot" require_relative "./support/matchers/have_translation" +require_relative "./support/mailer" require "devise" # Requires supporting ruby files with custom matchers and macros, etc, in diff --git a/spec/requests/discussions_spec.rb b/spec/requests/discussions_spec.rb index 8c69f0f8..05216d97 100644 --- a/spec/requests/discussions_spec.rb +++ b/spec/requests/discussions_spec.rb @@ -68,6 +68,28 @@ end end + xdescribe "creating a new discussion" do + before { ActiveJob::Base.queue_adapter = :test } + + it "notifies strory creator" do + story_one = create(:story, user: create(:user)) + expect do + post story_discussions_url(story_one), params: {discussion: attributes_for(:discussion)} + end.to have_enqueued_job(Noticed::DeliveryMethods::Email).with(hash_including(notification_class: NewDiscussionNotification.name)) + expect(ApplicationMailer.deliveries.count).to eql(1) + end + + it "does not notify story creator" do + story_two = create(:story, user: create(:user, preference: create(:preference, notify_new_discussion_on_story: false))) + expect do + post story_discussions_url(story_two), params: {discussion: attributes_for(:discussion)} + # it does trigger the job but then filtered out in `if: email_notifications?` filter + # so there's no emails sent + end.to have_enqueued_job(Noticed::DeliveryMethods::Email).with(hash_including(notification_class: NewDiscussionNotification.name)) + expect(ApplicationMailer.deliveries.count).to eql(0) + end + end + describe "GET /edit" do let(:discussion) { create(:discussion, story:, user:) } diff --git a/spec/requests/preferences_spec.rb b/spec/requests/preferences_spec.rb new file mode 100644 index 00000000..82e00be9 --- /dev/null +++ b/spec/requests/preferences_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +describe "/preferences", type: :request do + context "when not logged in" do + describe "GET /index" do + it "redirects the user" do + get preferences_url + expect(response).to have_http_status(:redirect) + end + end + end + + context "when logged in" do + let(:user) { create(:user, preference: create(:preference)) } + + before do + sign_in user + end + + describe "GET /index" do + it "renders user preferences" do + get preferences_url + expect(response).to have_http_status(:ok) + expect(response.body).to include(I18n.t("preferences.index.notify_any_post_in_discussion")) + expect(response.body).to include(I18n.t("preferences.index.notify_new_discussion_on_story")) + expect(response.body).to include(I18n.t("preferences.index.notify_new_post_on_discussion")) + expect(response.body).to include(I18n.t("preferences.index.notify_new_story")) + end + end + end +end diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb index 43fdc2f3..bf47d31c 100644 --- a/spec/requests/stories_spec.rb +++ b/spec/requests/stories_spec.rb @@ -53,6 +53,16 @@ post stories_url, params: {story: attributes_for(:story)} expect(response).to redirect_to(story_url(Story.last)) end + + xit "queues a background job to notify users" do + ActiveJob::Base.queue_adapter = :test # enable test helpers + # create a user who can be notified with a preference + create(:user, preference: create(:preference, notify_new_story: true)) + expect do + post stories_url, params: {story: attributes_for(:story)} + end.to have_enqueued_job(Noticed::DeliveryMethods::Email).with(hash_including(notification_class: NewStoryNotification.name)) + expect(ApplicationMailer.deliveries.count).to eql(1) + end end context "with invalid parameters" do @@ -97,6 +107,67 @@ end end + describe "POST /stories/:story_id/contributions" do + let(:story) { create(:story, user:) } + + it "invites the contributors" do + expect do + post story_contributions_url(story), params: {contribution: {emails: "email1@example.com, email2@example.com"}} + end.to change(Contribution, :count).by(2) + end + + it "only invites valid email addresses" do + expect do + post story_contributions_url(story), params: {contribution: {emails: "one@example.com, 12345"}} + end.to change(Contribution, :count).by(1) + end + + context "when user does not own the story" do + let(:story) { create(:story) } # user who is creating this story is not the logged in user + + it "redirects the user to home page" do + post story_contributions_url(story), params: {contribution: {emails: "email1@example.com, email2@example.com"}} + expect(response).to redirect_to(root_url) + end + + it "does not create any contributions" do + expect do + post story_contributions_url(story), params: {contribution: {emails: "email1@example.com, email2@example.com"}} + end.to change(Contribution, :count).by(0) + end + end + end + + describe "DELETE /stories/:story_id/contributions/:id" do + let(:story) { create(:story, user:) } + + it "removes the contributors" do + story.invite_contributors(["email1@example.com", "email2@example.com"], user) + expect do + delete story_contribution_url(story, Contribution.last) + end.to change(Contribution, :count).by(-1) + end + + context "when user is not a creator or collaborator" do + let(:story) { create(:story) } # user who is creating this story is not the logged in user + + before do + story.invite_contributors(["email1@example.com", "email2@example.com"], story.user) + end + + it "redirects the user to home page" do + delete story_contribution_url(story, Contribution.last) + expect(response).to redirect_to(root_url) + end + + it "does not remove any contributors" do + expect do + delete story_contribution_url(story, Contribution.last) + end.to change(Contribution, :count).by(0) + end + end + end + describe "DELETE /destroy" do it "destroys the requested story" do story = create(:story, user:) diff --git a/spec/support/mailer.rb b/spec/support/mailer.rb new file mode 100644 index 00000000..2386ea85 --- /dev/null +++ b/spec/support/mailer.rb @@ -0,0 +1,14 @@ +shared_examples "a notification email" do |subject_prefix, subject_key| + it "sends an email to the user with the correct subject" do + expect(mail.subject).to include(subject_prefix) + end + + it "sends the email to the correct recipient" do + expect(mail.to).to eq([user.email]) + end + + it "renders the body with the user and story information" do + expect(mail.body.encoded).to include(user.name.split(" ").first) + expect(mail.body.encoded).to include(story.title) + end +end