Merge pull request #974 from basecamp/bundle-emails
Bundled notification emails
This commit is contained in:
@@ -60,6 +60,7 @@ group :development, :test do
|
||||
gem "bundler-audit", require: false
|
||||
gem "brakeman", require: false
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
gem "letter_opener"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
@@ -276,6 +276,8 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
@@ -342,6 +344,12 @@ GEM
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.1)
|
||||
@@ -611,6 +619,7 @@ DEPENDENCIES
|
||||
importmap-rails
|
||||
jbuilder
|
||||
kamal!
|
||||
letter_opener
|
||||
lexxy!
|
||||
mission_control-jobs
|
||||
mocha
|
||||
|
||||
@@ -63,6 +63,19 @@ A typical scenario is making modifications LLM prompts. You need to:
|
||||
Notice that if you pass changing data to the prompt, this mechanism won't work. E.g: if you pass data timestamps.
|
||||
You need to make sure those timestamps are always the same across executions.
|
||||
|
||||
### Outbound Emails
|
||||
|
||||
#### Development
|
||||
|
||||
You can view email previews at http://fizzy.localhost:3006/rails/mailers.
|
||||
|
||||
You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to
|
||||
open sent emails automatically with:
|
||||
|
||||
bin/rails dev:email
|
||||
|
||||
Under the hood, this will create or remove `tmp/email-dev.txt`.
|
||||
|
||||
## Environments
|
||||
|
||||
Fizzy is deployed with Kamal. You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment.
|
||||
@@ -73,7 +86,7 @@ Beta is primarily intended for testing product features.
|
||||
|
||||
Beta tenant is:
|
||||
|
||||
- https://fizzy.37signals.works/
|
||||
- https://fizzy-beta.37signals.com
|
||||
|
||||
This environment uses local disk for Active Storage.
|
||||
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
class Notifications::SettingsController < ApplicationController
|
||||
include FilterScoped
|
||||
before_action :set_settings
|
||||
|
||||
def show
|
||||
@collections = Current.user.collections.alphabetically
|
||||
end
|
||||
|
||||
def update
|
||||
@settings.update!(settings_params)
|
||||
redirect_to notifications_settings_path, notice: "Settings updated"
|
||||
end
|
||||
|
||||
private
|
||||
def set_settings
|
||||
@settings = Current.user.settings
|
||||
end
|
||||
|
||||
def settings_params
|
||||
params.expect(user_settings: :bundle_email_frequency)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
class Notifications::UnsubscribesController < ApplicationController
|
||||
allow_unauthenticated_access
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
before_action :set_user
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
@user.settings.bundle_email_never!
|
||||
redirect_to notifications_unsubscribe_path(access_token: params[:access_token])
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
private
|
||||
def set_user
|
||||
unless @user = User.find_by_token_for(:unsubscribe, params[:access_token])
|
||||
redirect_to root_path, alert: "Invalid unsubscribe link"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,6 @@ module AvatarsHelper
|
||||
end
|
||||
|
||||
def avatar_image_tag(user, **options)
|
||||
image_tag user_avatar_path(user), aria: { hidden: "true" }, size: 48, title: user.name, **options
|
||||
image_tag user_avatar_url(user), aria: { hidden: "true" }, size: 48, title: user.name, **options
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,6 +65,15 @@ module NotificationsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def bundle_email_frequency_options_for(settings)
|
||||
options_for_select([
|
||||
[ "Never", "never" ],
|
||||
[ "Every few hours", "every_few_hours" ],
|
||||
[ "Every day", "daily" ],
|
||||
[ "Every week", "weekly" ]
|
||||
], settings.bundle_email_frequency)
|
||||
end
|
||||
|
||||
private
|
||||
def event_notification_action(event)
|
||||
if event.action.card_published? && event.eventable.assigned_to?(event.creator)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class Notification::Bundle::DeliverAllJob < ApplicationJob
|
||||
queue_as :backend
|
||||
|
||||
def perform
|
||||
ApplicationRecord.with_each_tenant do |tenant|
|
||||
Notification::Bundle.deliver_all
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class Notification::Bundle::DeliverJob < ApplicationJob
|
||||
queue_as :backend
|
||||
|
||||
def perform(bundle)
|
||||
bundle.deliver
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
class PushNotificationJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(notification)
|
||||
NotificationPusher.new(notification).push
|
||||
end
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
default from: "The Fizzy team <support@37signals.com>"
|
||||
|
||||
layout "mailer"
|
||||
append_view_path Rails.root.join("app/views/mailers")
|
||||
helper AvatarsHelper, HtmlHelper
|
||||
|
||||
private
|
||||
def default_url_options
|
||||
super.merge(script_name: Account.sole.slug)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
module Mailers::Unsubscribable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :set_unsubscribe_headers
|
||||
end
|
||||
|
||||
def set_unsubscribe_headers
|
||||
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
|
||||
headers["List-Unsubscribe"] = "<#{notifications_unsubscribe_url(access_token: @unsubscribe_token)}>"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
class Notification::BundleMailer < ApplicationMailer
|
||||
include Mailers::Unsubscribable
|
||||
|
||||
helper NotificationsHelper
|
||||
|
||||
def notification(bundle)
|
||||
@user = bundle.user
|
||||
@bundle = bundle
|
||||
@notifications = bundle.notifications
|
||||
@unsubscribe_token = @user.generate_token_for(:unsubscribe)
|
||||
|
||||
mail \
|
||||
to: bundle.user.email_address,
|
||||
subject: "Latest Activity in Fizzy"
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,7 @@ class Notification < ApplicationRecord
|
||||
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
|
||||
|
||||
after_create_commit :broadcast_unread
|
||||
after_create :bundle
|
||||
|
||||
delegate :notifiable_target, to: :source
|
||||
delegate :card, to: :source
|
||||
@@ -40,4 +41,8 @@ class Notification < ApplicationRecord
|
||||
def broadcast_read
|
||||
broadcast_remove_to user, :notifications
|
||||
end
|
||||
|
||||
def bundle
|
||||
user.bundle(self) if user.settings.bundling_emails?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
class Notification::Bundle < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
enum :status, %i[ pending processing delivered ]
|
||||
|
||||
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
|
||||
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) }
|
||||
scope :overlapping_with, ->(other_bundle) do
|
||||
where(
|
||||
"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)",
|
||||
other_bundle.starts_at, other_bundle.starts_at,
|
||||
other_bundle.ends_at, other_bundle.ends_at,
|
||||
other_bundle.starts_at, other_bundle.ends_at
|
||||
)
|
||||
end
|
||||
|
||||
before_create :set_default_window
|
||||
|
||||
validate :validate_no_overlapping
|
||||
|
||||
class << self
|
||||
def deliver_all
|
||||
due.in_batches do |batch|
|
||||
jobs = batch.collect { DeliverJob.new(it) }
|
||||
ActiveJob.perform_all_later jobs
|
||||
end
|
||||
end
|
||||
|
||||
def deliver_all_later
|
||||
DeliverAllJob.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
def notifications
|
||||
user.notifications.where(created_at: window).unread
|
||||
end
|
||||
|
||||
def deliver
|
||||
processing!
|
||||
|
||||
Notification::BundleMailer.notification(self).deliver if notifications.any?
|
||||
|
||||
delivered!
|
||||
end
|
||||
|
||||
def deliver_later
|
||||
DeliverJob.perform_later(self)
|
||||
end
|
||||
|
||||
private
|
||||
def set_default_window
|
||||
self.starts_at ||= Time.current
|
||||
self.ends_at ||= user.settings.bundle_aggregation_period.from_now
|
||||
end
|
||||
|
||||
def window
|
||||
starts_at..ends_at
|
||||
end
|
||||
|
||||
def validate_no_overlapping
|
||||
if overlapping_bundles.exists?
|
||||
errors.add(:base, "Bundle window overlaps with an existing pending bundle")
|
||||
end
|
||||
end
|
||||
|
||||
def overlapping_bundles
|
||||
user.notification_bundles.where.not(id: id).overlapping_with(self)
|
||||
end
|
||||
end
|
||||
+2
-6
@@ -1,6 +1,6 @@
|
||||
class User < ApplicationRecord
|
||||
include Accessor, AiQuota, Assignee, Attachable, Mentionable, Named, Role,
|
||||
Searcher, SignalUser, Staff, Transferable, Conversational
|
||||
include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Mentionable, Named,
|
||||
Notifiable, Role, Searcher, SignalUser, Staff, Transferable
|
||||
include Timelined # Depends on Accessor
|
||||
|
||||
has_one_attached :avatar
|
||||
@@ -10,15 +10,11 @@ class User < ApplicationRecord
|
||||
|
||||
has_many :comments, inverse_of: :creator, dependent: :destroy
|
||||
|
||||
has_many :notifications, dependent: :destroy
|
||||
|
||||
has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy
|
||||
has_many :closures, dependent: :nullify
|
||||
has_many :pins, dependent: :destroy
|
||||
has_many :pinned_cards, through: :pins, source: :card
|
||||
has_many :commands, dependent: :destroy
|
||||
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
|
||||
has_many :period_activity_summaries, dependent: :destroy
|
||||
|
||||
normalizes :email_address, with: ->(value) { value.strip.downcase }
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module User::Configurable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :settings, class_name: "User::Settings", dependent: :destroy
|
||||
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
|
||||
|
||||
after_create :create_settings, unless: :system?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
module User::Notifiable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :notifications, dependent: :destroy
|
||||
has_many :notification_bundles, class_name: "Notification::Bundle", dependent: :destroy
|
||||
|
||||
generates_token_for :unsubscribe, expires_in: 1.month
|
||||
end
|
||||
|
||||
def bundle(notification)
|
||||
transaction do
|
||||
find_or_create_bundle_for(notification)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def find_or_create_bundle_for(notification)
|
||||
find_bundle_for(notification) || create_bundle_for(notification)
|
||||
end
|
||||
|
||||
def find_bundle_for(notification)
|
||||
notification_bundles.pending.containing(notification).last
|
||||
end
|
||||
|
||||
def create_bundle_for(notification)
|
||||
notification_bundles.create!(starts_at: notification.created_at)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class User::Settings < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
enum :bundle_email_frequency, %i[ never every_few_hours daily weekly ],
|
||||
default: :every_few_hours, prefix: :bundle_email
|
||||
|
||||
after_update :review_pending_bundles, if: :saved_change_to_bundle_email_frequency?
|
||||
|
||||
def bundle_aggregation_period
|
||||
case bundle_email_frequency
|
||||
when "every_few_hours"
|
||||
4.hours
|
||||
when "daily"
|
||||
1.day
|
||||
when "weekly"
|
||||
1.week
|
||||
else
|
||||
1.day
|
||||
end
|
||||
end
|
||||
|
||||
def bundling_emails?
|
||||
!bundle_email_never?
|
||||
end
|
||||
|
||||
private
|
||||
def review_pending_bundles
|
||||
if bundling_emails?
|
||||
flush_pending_bundles
|
||||
else
|
||||
cancel_pending_bundles
|
||||
end
|
||||
end
|
||||
|
||||
def cancel_pending_bundles
|
||||
user.notification_bundles.pending.find_each do |bundle|
|
||||
bundle.destroy
|
||||
end
|
||||
end
|
||||
|
||||
def flush_pending_bundles
|
||||
user.notification_bundles.pending.find_each do |bundle|
|
||||
bundle.deliver_later
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@
|
||||
<%= filter_place_menu_item account_settings_path, "Account Settings", "settings" %>
|
||||
<%= filter_place_menu_item user_path(Current.user), "My Profile", "person" %>
|
||||
<%= filter_place_menu_item notifications_path, "Notifications", "bell" %>
|
||||
<%= filter_place_menu_item settings_notifications_path, "Notification Settings", "settings" %>
|
||||
<%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %>
|
||||
<%= filter_place_menu_item search_path, "Search", "search" %>
|
||||
<%= filter_place_menu_item workflows_path, "Workflows", "bolt" %>
|
||||
</ul>
|
||||
|
||||
@@ -3,11 +3,127 @@
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<style>
|
||||
/* Email styles need to be inline */
|
||||
html {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0 none;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2d71e5;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: 0 none;
|
||||
}
|
||||
|
||||
table, td {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#body {
|
||||
height: 100% !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
height: 3em;
|
||||
margin-right: 0.3em;
|
||||
max-height: 3em;
|
||||
min-height: 3em;
|
||||
overflow: hidden;
|
||||
width: 3em;
|
||||
}
|
||||
|
||||
.avatar__container {
|
||||
padding-top: 1em;
|
||||
vertical-align: top;
|
||||
width: 3.5em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #2d71e5;
|
||||
border-color: #2d71e5;
|
||||
border-radius: 3rem;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
padding: 0.2em 0.4em;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event__details {
|
||||
margin-bottom: 0.2em;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
|
||||
.event__title {
|
||||
font-size: 1.2em;
|
||||
font-weight: 900;
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid #ccc;
|
||||
margin-top: 3em;
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.notification {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.notification__author {
|
||||
opacity: 0.66;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.notification__title {
|
||||
font-size: 0.9em;
|
||||
font-weight: normal;
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
opacity: 0.66;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.3em;
|
||||
font-weight: normal;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5em;
|
||||
font-weight: 900;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%= yield %>
|
||||
<table id="body">
|
||||
<%= yield %>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<h2 class="notification__title">#<%= notification.card.id %> | <%= notification.card.collection.name %> | <%= notification.creator.name %></h2>
|
||||
@@ -0,0 +1 @@
|
||||
#<%= notification.card.id %> | <%= notification.card.collection.name %> | <%= notification.creator.name %>
|
||||
@@ -0,0 +1,9 @@
|
||||
<table class="notification">
|
||||
<tr>
|
||||
<td class="avatar__container"><%= image_tag user_avatar_url(notification.creator), alt: notification.creator.name, class: "avatar", size: 48 %></td>
|
||||
<td>
|
||||
<%= render "notification/bundle_mailer/header", notification: notification %>
|
||||
<%= render "notification/bundle_mailer/#{notification.source_type.underscore}/body", notification: notification %>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1,5 @@
|
||||
================================================================================
|
||||
|
||||
<%= render "notification/bundle_mailer/header", notification: notification %>
|
||||
<%= render "notification/bundle_mailer/#{notification.source_type.underscore}/body", notification: notification %>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<% event = notification.source %>
|
||||
|
||||
<h3 class="event__title">
|
||||
<%= link_to event_notification_title(event), notification.source %>
|
||||
</h3>
|
||||
|
||||
<p class="event__details">
|
||||
<%= event_notification_body(event) %>
|
||||
</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
<% event = notification.source %>
|
||||
|
||||
<%= event_notification_title(event) %>
|
||||
|
||||
<%= event_notification_body(event).squish %>
|
||||
|
||||
<%= url_for notification %>
|
||||
@@ -0,0 +1,9 @@
|
||||
<% mention = notification.source %>
|
||||
|
||||
<h3 class="event__title">
|
||||
<%= link_to "#{mention.mentioner.first_name} mentioned you", mention %>
|
||||
</h3>
|
||||
|
||||
<p class="event__details">
|
||||
<%= mention.source.mentionable_content.truncate(250) %>
|
||||
</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
<% mention = notification.source %>
|
||||
|
||||
<%= mention.mentioner.first_name %> mentioned you.
|
||||
|
||||
<%= mention.source.mentionable_content.truncate(250) %>
|
||||
|
||||
<%= url_for mention %>
|
||||
@@ -0,0 +1,11 @@
|
||||
<tr>
|
||||
<td>
|
||||
<h1 class="title">Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %></h1>
|
||||
<p class="subtitle">You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %>.</p>
|
||||
<%= render partial: "notification/bundle_mailer/notification", collection: @notifications, as: :notification %>
|
||||
<p class="footer">Fizzy emails you about new notifications every few hours. <br>
|
||||
<%= link_to "Change how often you get these", notifications_settings_url %>
|
||||
or <%= link_to "unsubscribe from all email notifications", new_notifications_unsubscribe_url(access_token: @unsubscribe_token) %>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,13 @@
|
||||
Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>
|
||||
You have <%= pluralize @notifications.count, "new notification" %>.
|
||||
|
||||
<%= render partial: "notification/bundle_mailer/notification", collection: @notifications, as: :notification %>
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Fizzy emails you about new notifications every few hours.
|
||||
|
||||
Change how often you get these:
|
||||
<%= notifications_settings_url %>
|
||||
|
||||
Unsubscribe from all email notifications:
|
||||
<%= new_notifications_unsubscribe_url(access_token: @unsubscribe_token) %>
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="tray__item tray__item--hat txt-x-small gap-half">
|
||||
<div data-navigable-list-target="item" class="full-width">
|
||||
<%= link_to settings_notifications_path,
|
||||
<%= link_to notifications_settings_path,
|
||||
class: "btn borderless tray__notification-settings",
|
||||
title: "Notification settings",
|
||||
data: { action: "dialog#close" } do %>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 class="header__title"><%= @page_title %></h1>
|
||||
|
||||
<div class="header__actions header__actions--end">
|
||||
<%= link_to settings_notifications_path, class: "btn" do %>
|
||||
<%= link_to notifications_settings_path, class: "btn" do %>
|
||||
<%= icon_tag "settings" %> <span class="for-screen-reader">Notification settings</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<section class="panel unpad borderless center flex flex-column gap-half margin-block-start">
|
||||
<h2 class="txt-medium txt-uppercase divider margin-none-block-end">Email Notifications</h2>
|
||||
<p class="margin-none">Get a single email with all your notifications every few hours, daily, or weekly.</p>
|
||||
<%= form_with model: settings, url: notifications_settings_path,
|
||||
class: "flex flex-column gap-half",
|
||||
method: :patch, local: true, data: { controller: "form" } do |form| %>
|
||||
<div class="flex flex-column gap-half pad">
|
||||
<strong><%= form.label :bundle_email_frequency, "Email me about new notifications..." %></strong>
|
||||
<%= form.select :bundle_email_frequency, bundle_email_frequency_options_for(settings), {}, class: "input input--select txt-align-center", data: { action: "change->form#submit" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
<div class="notifications__status panel fill-shade center" data-controller="notifications" data-notifications-subscriptions-url-value="<%= user_push_subscriptions_path(Current.user) %>" data-notifications-enabled-class="notifications--on">
|
||||
<h2 class="notifications__on-message txt-medium">Push notifications are ON</h2>
|
||||
<h2 class="notifications__off-message txt-medium">Push notifications are OFF</h2>
|
||||
|
||||
<div class="margin-block-start-half" data-notifications-target="subscribeButton" hidden>
|
||||
<button class="btn txt-small" data-action="notifications#attemptToSubscribe">
|
||||
<%= icon_tag "bell" %>
|
||||
<span>Turn ON push notifications on this device</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="margin-block-start-half" data-notifications-target="explainer">
|
||||
<summary class="btn txt-x-small">
|
||||
<%= icon_tag "lifebuoy" %>
|
||||
<span class="notifications__off-message">Help me fix this</span>
|
||||
<span class="notifications__on-message">Not receiving notifications?</span>
|
||||
</summary>
|
||||
|
||||
<div class="notifications-help__explainer fill-white margin-block-start border-radius border txt-align-start">
|
||||
<p class="margin-none-block-start notifications__on-message">When push notifications aren’t working, this can usually be fixed by checking your notification settings to make sure they’re allowed.</p>
|
||||
<%= render partial: "notifications/settings/browser" %>
|
||||
<%= render partial: "notifications/settings/system" %>
|
||||
<%= render partial: "notifications/settings/install" %>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -5,34 +5,16 @@
|
||||
<h1 class="header__title"><%= @page_title %></h1>
|
||||
<% end %>
|
||||
|
||||
<div class="notifications__status panel fill-shade center margin-block-start margin-block-end-double" data-controller="notifications" data-notifications-subscriptions-url-value="<%= user_push_subscriptions_path(Current.user) %>" data-notifications-enabled-class="notifications--on">
|
||||
<h2 class="notifications__on-message txt-medium">Push notifications are ON</h2>
|
||||
<h2 class="notifications__off-message txt-medium">Push notifications are OFF</h2>
|
||||
|
||||
<div class="margin-block-start-half" data-notifications-target="subscribeButton" hidden>
|
||||
<button class="btn txt-small" data-action="notifications#attemptToSubscribe">
|
||||
<%= icon_tag "bell" %>
|
||||
<span>Turn ON push notifications on this device</span>
|
||||
</button>
|
||||
<section class="settings">
|
||||
<div class="settings__panel settings__panel--users panel shadow center">
|
||||
<h2 class="txt-medium txt-uppercase divider">Collections</h2>
|
||||
<div class="settings__user-list flex flex-column gap-half">
|
||||
<%= render partial: "notifications/settings/collection", collection: @collections, locals: { user: Current.user } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="margin-block-start-half" data-notifications-target="explainer">
|
||||
<summary class="btn txt-x-small">
|
||||
<%= icon_tag "lifebuoy" %>
|
||||
<span class="notifications__off-message">Help me fix this</span>
|
||||
<span class="notifications__on-message">Not receiving notifications?</span>
|
||||
</summary>
|
||||
|
||||
<div class="notifications-help__explainer fill-white margin-block-start border-radius border txt-align-start">
|
||||
<p class="margin-none-block-start notifications__on-message">When push notifications aren’t working, this can usually be fixed by checking your notification settings to make sure they’re allowed.</p>
|
||||
<%= render partial: "notifications/settings/browser" %>
|
||||
<%= render partial: "notifications/settings/system" %>
|
||||
<%= render partial: "notifications/settings/install" %>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<section class="panel unpad borderless center flex flex-column gap-half">
|
||||
<h2 class="txt-medium txt-uppercase divider">Collections</h2>
|
||||
<%= render partial: "notifications/settings/collection", collection: @collections, locals: { user: Current.user } %>
|
||||
<div class="settings__panel panel shadow center">
|
||||
<%= render "notifications/settings/push_notifications" %>
|
||||
<%= render "notifications/settings/email", settings: @settings %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<p>Unsubscribing from all email notifications as <%= @user.name %>…</p>
|
||||
|
||||
<div class="push_double--top centered delayed-fade-in">
|
||||
<%= auto_submit_form_with model: @user, url: notifications_unsubscribe_path(access_token: params[:access_token]), method: :post do |form| %>
|
||||
<%= form.submit "Unsubscribe now", class: "btn" %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="panel margin-block-double center shadow">
|
||||
<h1 class="font-black txt-x-large margin-none-block-end">You’re unsubscribed</h1>
|
||||
<p class="margin-block-start-half">Thanks, <%= @user.first_name %>! Fizzy won’t send you any more email notifications. If you change your mind you can turn them back on in <%= link_to "Notifications Settings", notifications_settings_path %>.</p>
|
||||
|
||||
<%= link_to root_path, class: "btn btn--link margin-block-start" do %>
|
||||
<%= icon_tag "arrow-left" %>
|
||||
<span>Back to Fizzy</span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,7 +1,9 @@
|
||||
require_relative "production"
|
||||
|
||||
Rails.application.configure do
|
||||
config.action_mailer.default_url_options = { host: "%{tenant}.37signals.works" }
|
||||
config.action_mailer.smtp_settings[:domain] = "fizzy-beta.37signals.com"
|
||||
config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging"
|
||||
config.action_mailer.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" }
|
||||
|
||||
# Let's keep beta on local disk. See https://github.com/basecamp/fizzy/pull/557 for context.
|
||||
config.active_storage.service = :local
|
||||
|
||||
@@ -72,8 +72,17 @@ Rails.application.configure do
|
||||
# Raise error when a before_action's only/except options reference missing actions
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
# Allow all hosts in development
|
||||
config.hosts = nil
|
||||
if Rails.root.join("tmp/email-dev.txt").exist?
|
||||
config.action_mailer.delivery_method = :letter_opener
|
||||
config.action_mailer.perform_deliveries = true
|
||||
else
|
||||
config.action_mailer.raise_delivery_errors = false
|
||||
end
|
||||
|
||||
config.hosts = %w[ fizzy.localhost localhost 127.0.0.1 ]
|
||||
|
||||
# Host to be used in links rendered from ActionMailer
|
||||
config.action_mailer.default_url_options = { host: config.hosts.first, port: 3006 }
|
||||
|
||||
if Rails.root.join("tmp/solid-queue.txt").exist?
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
|
||||
@@ -74,7 +74,9 @@ Rails.application.configure do
|
||||
# config.action_mailer.raise_delivery_errors = false
|
||||
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "%{tenant}.fizzy.37signals.com" }
|
||||
config.action_mailer.default_url_options = { host: "fizzy.37signals.com", protocol: "https" }
|
||||
|
||||
config.action_mailer.smtp_settings = { domain: "fizzy.37signals.com", address: "smtp-outbound", port: 25, enable_starttls_auto: false }
|
||||
|
||||
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
|
||||
# the I18n.default_locale when a translation cannot be found).
|
||||
@@ -86,11 +88,6 @@ Rails.application.configure do
|
||||
# Do not dump schema after migrations.
|
||||
config.active_record.dump_schema_after_migration = false
|
||||
|
||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||
# config.hosts = [
|
||||
# "example.com", # Allow requests from example.com
|
||||
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
|
||||
# ]
|
||||
# Skip DNS rebinding protection for the default health check endpoint.
|
||||
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
||||
end
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
require_relative "production"
|
||||
|
||||
Rails.application.configure do
|
||||
config.action_mailer.default_url_options = { host: "%{tenant}.fizzy.37signals-staging.com" }
|
||||
config.action_mailer.smtp_settings[:domain] = "fizzy.37signals-staging.com"
|
||||
config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging"
|
||||
config.action_mailer.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" }
|
||||
end
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ default: &default
|
||||
- polling_interval: 1
|
||||
batch_size: 500
|
||||
workers:
|
||||
- queues: "*"
|
||||
- queues: [ "default", "backend" ]
|
||||
threads: 3
|
||||
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
|
||||
polling_interval: 0.1
|
||||
|
||||
@@ -17,6 +17,9 @@ production: &production
|
||||
clear_solid_queue_finished_jobs:
|
||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||
schedule: every hour at minute 12
|
||||
deliver_bundled_notifications:
|
||||
command: "Notification::Bundle.deliver_all_later"
|
||||
schedule: every 30 minutes
|
||||
|
||||
beta: *production
|
||||
staging: *production
|
||||
|
||||
+8
-4
@@ -51,10 +51,14 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
namespace :notifications do
|
||||
resource :settings
|
||||
resource :unsubscribe
|
||||
end
|
||||
|
||||
resources :notifications do
|
||||
scope module: :notifications do
|
||||
get "tray", to: "trays#show", on: :collection
|
||||
get "settings", to: "settings#show", on: :collection
|
||||
|
||||
post "readings", to: "readings#create_all", on: :collection, as: :read_all
|
||||
post "reading", to: "readings#create", on: :member, as: :read
|
||||
@@ -162,15 +166,15 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
resolve "Mention" do |mention, options|
|
||||
polymorphic_path(mention.source, options)
|
||||
polymorphic_url(mention.source, options)
|
||||
end
|
||||
|
||||
resolve "Notification" do |notification, options|
|
||||
polymorphic_path(notification.notifiable_target, options)
|
||||
polymorphic_url(notification.notifiable_target, options)
|
||||
end
|
||||
|
||||
resolve "Event" do |event, options|
|
||||
polymorphic_path(event.target, options)
|
||||
polymorphic_url(event.eventable, options)
|
||||
end
|
||||
|
||||
get "up", to: "rails/health#show", as: :rails_health_check
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class CreateNotificationBundles < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :notification_bundles do |t|
|
||||
t.references :user, null: false, foreign_key: true, index: { unique: true }
|
||||
t.datetime :starts_at, null: false
|
||||
t.datetime :ends_at, null: false
|
||||
t.integer :status, default: 0, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :notification_bundles, %i[ user_id starts_at ends_at ]
|
||||
add_index :notification_bundles, %i[ user_id status ]
|
||||
end
|
||||
end
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
class RemoveUniqueConstraintFromNotificationBundlesUserId < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
remove_index :notification_bundles, :user_id, unique: true
|
||||
add_index :notification_bundles, %i[ ends_at status ]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class CreateNotificationSettings < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :notification_settings do |t|
|
||||
t.references :user, null: false, foreign_key: true, index: true
|
||||
t.integer :bundle_email_frequency, default: 0, null: false
|
||||
|
||||
t.timestamps
|
||||
|
||||
t.index %i[ user_id bundle_email_frequency ]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class RenameNotificationSettingsToUserSettings < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
rename_table :notification_settings, :user_settings
|
||||
end
|
||||
end
|
||||
Generated
+11
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_08_27_072601) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_08_28_092106) do
|
||||
create_table "accesses", force: :cascade do |t|
|
||||
t.datetime "accessed_at"
|
||||
t.integer "collection_id", null: false
|
||||
@@ -421,6 +421,15 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_27_072601) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "user_settings", force: :cascade do |t|
|
||||
t.integer "bundle_email_frequency", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency"
|
||||
t.index ["user_id"], name: "index_user_settings_on_user_id"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.boolean "active", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -487,6 +496,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_27_072601) do
|
||||
add_foreign_key "steps", "cards"
|
||||
add_foreign_key "taggings", "cards"
|
||||
add_foreign_key "taggings", "tags"
|
||||
add_foreign_key "user_settings", "users"
|
||||
add_foreign_key "watches", "cards"
|
||||
add_foreign_key "watches", "users"
|
||||
add_foreign_key "workflow_stages", "workflows"
|
||||
|
||||
+52
-1
@@ -1231,6 +1231,21 @@ columns:
|
||||
- *6
|
||||
- *35
|
||||
- *9
|
||||
user_settings:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: bundle_email_frequency
|
||||
cast_type: *3
|
||||
sql_type_metadata: *4
|
||||
'null': false
|
||||
default: 0
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- *5
|
||||
- *6
|
||||
- *9
|
||||
- *18
|
||||
users:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
@@ -1378,6 +1393,7 @@ primary_keys:
|
||||
steps: id
|
||||
taggings: id
|
||||
tags: id
|
||||
user_settings: id
|
||||
users: id
|
||||
watches: id
|
||||
workflow_stages: id
|
||||
@@ -1428,6 +1444,7 @@ data_sources:
|
||||
steps: true
|
||||
taggings: true
|
||||
tags: true
|
||||
user_settings: true
|
||||
users: true
|
||||
watches: true
|
||||
workflow_stages: true
|
||||
@@ -2841,6 +2858,40 @@ indexes:
|
||||
comment:
|
||||
valid: true
|
||||
tags: []
|
||||
user_settings:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: user_settings
|
||||
name: index_user_settings_on_user_id
|
||||
unique: false
|
||||
columns:
|
||||
- user_id
|
||||
lengths: {}
|
||||
orders: {}
|
||||
opclasses: {}
|
||||
where:
|
||||
type:
|
||||
using:
|
||||
include:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: user_settings
|
||||
name: index_user_settings_on_user_id_and_bundle_email_frequency
|
||||
unique: false
|
||||
columns:
|
||||
- user_id
|
||||
- bundle_email_frequency
|
||||
lengths: {}
|
||||
orders: {}
|
||||
opclasses: {}
|
||||
where:
|
||||
type:
|
||||
using:
|
||||
include:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
users:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: users
|
||||
@@ -2941,4 +2992,4 @@ indexes:
|
||||
comment:
|
||||
valid: true
|
||||
workflows: []
|
||||
version: 20250827072601
|
||||
version: 20250828092106
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace :dev do
|
||||
desc "Toggle using Letter Opener to preview emails"
|
||||
task :email do
|
||||
file_path = Rails.root.join("tmp", "email-dev.txt")
|
||||
|
||||
if File.exist?(file_path)
|
||||
File.delete(file_path)
|
||||
puts "Letter Opener turned off"
|
||||
else
|
||||
FileUtils.touch(file_path)
|
||||
puts "Letter Opener turned on"
|
||||
end
|
||||
|
||||
%x(bin/rails restart)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
require "test_helper"
|
||||
|
||||
class Notifications::UnsubscribesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:david)
|
||||
@access_token = @user.generate_token_for(:unsubscribe)
|
||||
|
||||
sign_in_as @user
|
||||
end
|
||||
|
||||
test "new" do
|
||||
get new_notifications_unsubscribe_path(access_token: @access_token)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "new with bad token" do
|
||||
get new_notifications_unsubscribe_path(access_token: "bad")
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "create" do
|
||||
@user.reload.settings.bundle_email_every_few_hours!
|
||||
assert_changes -> { @user.reload.settings.bundle_email_frequency }, to: "never" do
|
||||
post notifications_unsubscribe_path(access_token: @access_token)
|
||||
assert_redirected_to notifications_unsubscribe_path(access_token: @access_token)
|
||||
end
|
||||
end
|
||||
|
||||
test "create with bad token" do
|
||||
assert_no_changes -> { @user.reload.settings.bundle_email_frequency } do
|
||||
post notifications_unsubscribe_path(access_token: "bad")
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
end
|
||||
|
||||
test "show" do
|
||||
get notifications_unsubscribe_path(access_token: @access_token)
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
_fixture:
|
||||
model_class: User::Settings
|
||||
|
||||
david_settings:
|
||||
user: david
|
||||
bundle_email_frequency: never
|
||||
|
||||
jz_settings:
|
||||
user: jz
|
||||
bundle_email_frequency: never
|
||||
|
||||
kevin_settings:
|
||||
user: kevin
|
||||
bundle_email_frequency: never
|
||||
|
||||
system_settings:
|
||||
user: system
|
||||
bundle_email_frequency: never
|
||||
@@ -0,0 +1,6 @@
|
||||
class Notification::BundleMailerPreview < ActionMailer::Preview
|
||||
def notification
|
||||
ApplicationRecord.current_tenant = "1065895976"
|
||||
Notification::BundleMailer.notification Notification::Bundle.take!
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
require "test_helper"
|
||||
|
||||
class Notification::BundleTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:david)
|
||||
@user.settings.bundle_email_every_few_hours!
|
||||
end
|
||||
|
||||
test "new notifications are bundled" do
|
||||
notification = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
end
|
||||
|
||||
bundle = @user.notification_bundles.pending.last
|
||||
assert_includes bundle.notifications, notification
|
||||
end
|
||||
|
||||
test "don't bundle new notifications if bundling is disabled" do
|
||||
@user.settings.bundle_email_never!
|
||||
|
||||
assert_no_difference -> { @user.notification_bundles.count } do
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
end
|
||||
end
|
||||
|
||||
test "notifications are bundled withing the aggregation period" do
|
||||
notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
end
|
||||
travel_to 3.hours.from_now
|
||||
|
||||
notification_2 = assert_no_difference -> { @user.notification_bundles.count } do
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
end
|
||||
travel_to 3.days.from_now
|
||||
|
||||
notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
end
|
||||
|
||||
bundle_1, bundle_2 = @user.notification_bundles.last(2)
|
||||
assert_includes bundle_1.notifications, notification_1
|
||||
assert_includes bundle_1.notifications, notification_2
|
||||
assert_includes bundle_2.notifications, notification_3
|
||||
end
|
||||
|
||||
test "overlapping bundles are invalid" do
|
||||
bundle_1 = @user.notification_bundles.create!(
|
||||
starts_at: Time.current,
|
||||
ends_at: 4.hours.from_now,
|
||||
status: :pending
|
||||
)
|
||||
|
||||
bundle_2 = @user.notification_bundles.build(
|
||||
starts_at: 2.hours.from_now,
|
||||
ends_at: 6.hours.from_now,
|
||||
status: :pending
|
||||
)
|
||||
assert_not bundle_2.valid?
|
||||
|
||||
# Bundle with overlapping end time should be invalid
|
||||
bundle_3 = @user.notification_bundles.build(
|
||||
starts_at: 2.hours.ago,
|
||||
ends_at: 2.hours.from_now,
|
||||
status: :pending
|
||||
)
|
||||
assert_not bundle_3.valid?
|
||||
|
||||
# Bundle completely within another bundle should be invalid
|
||||
bundle_4 = @user.notification_bundles.build(
|
||||
starts_at: 1.hour.from_now,
|
||||
ends_at: 3.hours.from_now,
|
||||
status: :pending
|
||||
)
|
||||
assert_not bundle_4.valid?
|
||||
|
||||
# Non-overlapping bundle should be valid
|
||||
bundle_5 = @user.notification_bundles.build(
|
||||
starts_at: 5.hours.from_now,
|
||||
ends_at: 9.hours.from_now,
|
||||
status: :pending
|
||||
)
|
||||
assert bundle_5.valid?
|
||||
end
|
||||
|
||||
test "deliver_all delivers due bundles" do
|
||||
notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
|
||||
bundle = @user.notification_bundles.pending.last
|
||||
|
||||
assert bundle.pending?
|
||||
assert_includes bundle.notifications, notification
|
||||
|
||||
bundle.update!(ends_at: 1.minute.ago)
|
||||
|
||||
perform_enqueued_jobs only: Notification::Bundle::DeliverJob do
|
||||
Notification::Bundle.deliver_all
|
||||
end
|
||||
|
||||
bundle.reload
|
||||
assert bundle.delivered?
|
||||
end
|
||||
|
||||
test "deliver_all don't deliver bundles that are not due" do
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
bundle = @user.notification_bundles.pending.last
|
||||
|
||||
bundle.update!(ends_at: 1.minute.from_now)
|
||||
|
||||
perform_enqueued_jobs only: Notification::Bundle::DeliverJob do
|
||||
Notification::Bundle.deliver_all
|
||||
end
|
||||
|
||||
bundle.reload
|
||||
assert bundle.pending?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
require "test_helper"
|
||||
|
||||
class User::ConfigurableTest < ActiveSupport::TestCase
|
||||
test "should create settings for new users" do
|
||||
user = User.create! name: "Some new user", email_address: "some.new@user.com"
|
||||
assert user.settings.present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
require "test_helper"
|
||||
|
||||
class User::SettingsTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:david)
|
||||
@settings = @user.settings
|
||||
end
|
||||
|
||||
test "changing the bundle email frequency to never will cancel pending bundles" do
|
||||
@settings.update!(bundle_email_frequency: :every_few_hours)
|
||||
bundle = @user.notification_bundles.create!
|
||||
@settings.update!(bundle_email_frequency: :never)
|
||||
assert_nil Notification::Bundle.find_by(id: bundle.id)
|
||||
end
|
||||
|
||||
test "changing the bundle email frequency will deliver pending bundles" do
|
||||
bundle = @user.notification_bundles.create!
|
||||
assert bundle.pending?
|
||||
|
||||
perform_enqueued_jobs only: Notification::Bundle::DeliverJob do
|
||||
@settings.update!(bundle_email_frequency: :daily)
|
||||
end
|
||||
|
||||
assert bundle.reload.delivered?
|
||||
end
|
||||
|
||||
test "changing other settings will not affect pending bundles" do
|
||||
bundle = @user.notification_bundles.create!
|
||||
|
||||
perform_enqueued_jobs only: Notification::Bundle::DeliverJob do
|
||||
@settings.update!(updated_at: 1.hour.from_now)
|
||||
end
|
||||
|
||||
assert bundle.reload.pending?
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user