Now that we have covered unit testing and background jobs with Sidekiq, let's add a new feature to send reminder emails to users with outstanding tasks.
In this chapter we will discuss concepts like idempotency, mail delivery windows, setting user preferences, and more.
Features
These are the basic requirements of the feature :
- Send reminder emails to users with pending tasks.
- Users should have an option to enable/disable the email notifications.
- User can set preferred hour i.e delivery time to receive mail with all pending tasks, if any.
- Users should not be able to update the email delivery hour unless the email notifications are enabled.
Technical design
To implement this feature, we need to introduce the following changes:
On the backend
-
For scheduling periodic Sidekiq jobs, we can use the sidekiq-cron gem, which helps us to schedule the worker, which will be sending the mail.
-
In the development environment, instead of sending an email to the user's email address, we can preview the email in our browser using the letter_opener gem.
-
Mail will be sent to each user at 10 AM by default, or at their preferred time, which we will store in a new table called preference. This preferences table consists of notification_delivery_hour,receive_email and user columns.
-
Create user_notifications table with last_notification_sent_date and user columns.
-
In user_notification model, add validations to ensure that the last_notification_sent_date attribute is present, has a valid date format, and the date is not in the past.
-
We will be creating a default preference for the user whenever a new user is created.
-
We will be saving the default preference value in a constant DEFAULT_NOTIFICATION_DELIVERY_HOUR, which will be loaded automatically from config/initializers/constants.rb, by Rails, after it loads the framework plus any gems and plugins in our application.
-
Add PreferencesController which will add the ability for the user to update their preference for if and when to receive the email.
Inside the PreferencesController, we will add a custom action called mail which will handle enabling and disabling the email notification.
-
Mail sending can often fail due to network errors, among others. When that happens, Sidekiq will retry based on rules we set in the initializer.
On the frontend
-
Define preference related axios connectors.
-
We will add a MyPreferences component which will handle the preferences of the user.
-
Delegate the job of taking in the preferred time for mail delivery to a PreferenceForm component within the MyPreferences component.
-
Add a function fetchPreferenceDetails in the MyPreferences container component which will fetch preference details like notification_delivery_hour and receive_email.
-
Add a function updateEmailNotification in the MyPreferences component which will handle enabling/disabling email notifications.
-
Similarly, add a function updatePreference in the MyPreferences container component which will update notification_delivery_hour.
Sidekiq worker
To send each batch of reminder emails, we will schedule a TodoNotificationsWorker to run every hour and delegate the job of finding the recipients to a TodoNotificationsService.
This service will ultimately invoke separate UserNotificationWorker's for each recipient it finds.
Invoking separate workers helps to avoid having a single point of failure in the batch, which may bring the whole delivery system down.
We will also create a scheduler file to store cron values that will help schedule our TodoNotificationsWorker at the desired time interval for each environment.
The parent Sidekiq worker will then invoke TodoNotificationsService, which will find all the users with pending tasks but no entries in a new table called user_notifications.
What is idempotency?
We need our service to be idempotent, ie: duplicate mail shouldn't be sent every time we retry.
From a RESTful service standpoint, an operation or service call is idempotent when clients can perform a duplicate request harmlessly. In other words, making multiple identical requests has the same effect as making a single request.
Note that while idempotent operations produce the same result on the server, the response itself may not be the same; e.g. a resource's state may change between requests.
To ensure this, we will use the user_notifications table from earlier to associate each user with a last_notification_sent_date.
If a user has an entry for today in user_notifications, it means there's no need to try again.
Action Mailer
Action Mailer allows you to send emails from your application using mailer classes and views. They inherit from ActionMailer::Base and live in app/mailers.
To make sure that the email has truly been sent to the user, we will make use of Action Mailer's after_action callback to safely set the last_notification_sent_date value only after the mail has been sent successfully.
Mailers have methods called "actions" and they use views to structure their content.
A controller generates content like say HTML, which will be sent back to the client, whereas a Mailer creates a message to be delivered via email.
We will be creating the TaskMailer mailer and task_mailer view.
We are now ready to start coding. Let us dive in.
Setting up base
Open the Gemfile.rb and add the following lines:
1# For periodic sidekiq jobs 2gem "sidekiq-cron" 3 4# For opening mails in development env 5gem "letter_opener", :group => :development
Install the gems:
1bundle install
In order for the letter_opener gem to open the mails automatically in our browser, we need to set the following in config/environments/development.rb:
1config.action_mailer.delivery_method = :letter_opener 2config.action_mailer.perform_deliveries = true
Now any email delivery will pop up in our browser instead of being sent via the internet. The messages will be stored in ./tmp/letter_opener.
Creating models
1bundle exec rails g model user_notification 2bundle exec rails g model preference
Go to db/migrate folder and paste the following lines of code for the CreateUserNotifications migration:
1class CreateUserNotifications < ActiveRecord::Migration[6.1] 2 def change 3 create_table :user_notifications do |t| 4 t.date :last_notification_sent_date, null: false 5 t.references :user, foreign_key: true 6 t.index [:user_id, :last_notification_sent_date], 7 name: :index_user_preferences_on_user_id_and_notification_sent_date, 8 unique: true 9 t.timestamps 10 end 11 end 12end
Also, paste the following lines of code for the CreatePreferences migration:
1class CreatePreferences < ActiveRecord::Migration[6.1] 2 def change 3 create_table :preferences do |t| 4 t.integer :notification_delivery_hour 5 t.boolean :receive_email, default: true, null: false 6 t.references :user, foreign_key: true 7 t.timestamps 8 end 9 end 10end
Run the migrations:
1bundle exec rails db:migrate
Adding validations
Now let's setup UserNotification model and add validations for last_notification_sent_date.
Open app/models/user_notification.rb and add the following lines to it:
1class UserNotification < ApplicationRecord 2 belongs_to :user 3 4 validates :last_notification_sent_date, presence: true 5 validate :last_notification_sent_date_is_valid_date 6 validate :last_notification_sent_date_cannot_be_in_the_past 7 8 private 9 10 def last_notification_sent_date_is_valid_date 11 if last_notification_sent_date.present? 12 Date.parse(last_notification_sent_date.to_s) 13 end 14 rescue ArgumentError 15 errors.add(:last_notification_sent_date, "must be a valid date") 16 end 17 18 def last_notification_sent_date_cannot_be_in_the_past 19 if last_notification_sent_date.present? && 20 last_notification_sent_date < Time.zone.today 21 errors.add(:last_notification_sent_date, "can't be in the past") 22 end 23 end 24end
Now let's setup our Preference model along with validations in app/models/preference.rb:
1class Preference < ApplicationRecord 2 belongs_to :user 3 4 validates :notification_delivery_hour, presence: true, 5 numericality: { only_integer: true }, 6 inclusion: { 7 in: 0..23, 8 message: "value should be between 0 and 23" 9 } 10end
Currently, we are only providing the ability to set the preference for when to receive an email notification.
Thus in the above code, we have added the validations for the notification_delivery_hour attribute which makes sure that it's present, and it only accepts integers ranging from 0-23.
Add the following line in config/initializers/constants.rb file::
1module Constants 2 is_sqlite_db = ActiveRecord::Base.connection_db_config.configuration_hash[:adapter] == "sqlite3" 3 DB_REGEX_OPERATOR = is_sqlite_db ? "REGEXP" : "~*" 4 DEFAULT_NOTIFICATION_DELIVERY_HOUR = 10 5end
We are setting the DEFAULT_NOTIFICATION_DELIVERY_HOUR in our constants.rb file instead of a specific model. This is because the constant is not limited to a specific context and will be used across different files. Thus it makes sense to add it to a global Constants context instead.
Now let's set the associations correctly in our User model too:
1class User < ApplicationRecord 2 # previous code if any 3 has_many :user_notifications, dependent: :destroy, foreign_key: :user_id 4 has_one :preference, dependent: :destroy, foreign_key: :user_id 5 6 # previous code if any 7 before_save :to_lowercase 8 before_create :build_default_preference 9 10 private 11 12 # previous code if any 13 14 def to_lowercase 15 email.downcase! 16 end 17 18 def build_default_preference 19 self.build_preference(notification_delivery_hour: Constants::DEFAULT_NOTIFICATION_DELIVERY_HOUR) 20 end 21end
The private method, build_default_preference, will create a default preference for the user whenever a new user is created.
Inside the build_default_preference method we have used the build_preference method. Let's take a look at where it came from.
When we declare a belongs_to or has_one association, the declaring class automatically will have access to a build_association method related to the association. In this case, because of the has_one :preference association in User model, Rails adds the build_preference method to the User class.
In case of has_many or has_and_belongs_to_many associations, we should use the association.build method instead of the build_association method. For example, if we were to declare a has_many :preferences association in the User model, we would have to use the preferences.build method given that Rails won't add the build_preferences method for an association that is not has_one.
Adding preferences for existing users
The build_preference method we had added in the previous section will only add a default preference for new users. The preference for all existing users will remain nil. There are two ways by which we can fix this issue:
-
Deleting all users from the console and creating new users. Newly added users will have a default preference but we shouldn't take this approach because:
-
It's considered a bad practice.
-
Once the application is deployed, we shouldn't delete users from console in production environment.
-
-
A better way to do this would be to add a migration which will add default preference for all existing users where preference is nil.
If you recall, we have done something similar in the Adding slug to task chapter where we had used a migration to add slugs to existing task records.
Before moving on towards adding a migration to fix the issue, you should attempt to do this by yourself. If you need help you can refer to the Adding slug to task chapter.
Now, let's us add a migration to fix this issue. Run the following command to generate a migration:
1bundle exec rails g migration AddDefaultPreferenceToExistingUsers
The above command with generate a migration file. Inside the migration file add the following lines of code:
1class AddDefaultPreferenceToExistingUsers < ActiveRecord::Migration[6.1] 2 def up 3 users_with_nil_preference = User.where.missing(:preference) 4 users_with_nil_preference.each do |user| 5 user.send(:build_default_preference) 6 user.save! 7 end 8 end 9 10 def down 11 User.find_each do |user| 12 user.preference.delete 13 end 14 end 15end
We have used the missing method which performs a left outer join operation on parent and child tables then uses a where clause to identify the missing relations. In this case we are querying all user records which have a missing preference relation and then adding a default preference for all those user records.
You can read more about the missing method from official documentation.
Run the following command to execute the migration:
1bundle exec rails db:migrate
Creating the controller
Create a new controller for the preference model by running the following command:
1bundle exec rails generate controller Preferences
Open app/controllers/preferences_controller.rb and add the following lines to it:
1class PreferencesController < ApplicationController 2 before_action :load_preference 3 4 def show 5 respond_with_json(@preference) 6 end 7 8 def update 9 @preference.update!(preference_params) 10 respond_with_success(t("successfully_updated", entity: "Preference")) 11 end 12 13 def mail 14 @preference.update!(receive_email: preference_params[:receive_email]) 15 respond_with_success( 16 t( 17 "preference.mail.notification_status", 18 status: @preference.receive_email ? "enabled" : "disabled" 19 ) 20 ) 21 end 22 23 private 24 25 def preference_params 26 params.require(:preference).permit(:notification_delivery_hour, :receive_email) 27 end 28 29 def load_preference 30 @preference = current_user.preference 31 unless @preference 32 respond_with_error("Preference not found", :not_found) 33 end 34 end 35end
We have added a mail action in the PreferencesController which will handle the logic to enable and disable the email notifications. Now, the question arises that why we need another action when updating a record should be handled by the update action. We want to do so because the mail action is only concerned with updating the receive_email attribute of a preference record and the response for that will also be different from the update action. Therefore, it is better to add a custom action.
We have created a load_preference method to load the preference before the show, update and mail actions are called. Since each user has one associated preference record we can query it using current_user.preference inside the load_preference method. Hence appending the preference id to the preference routes or sending it as a request param while making a request is not required in this case.
Now, let's create the route for the preference. We should declare preference as a singular resource because we do not need a resource identifier in the routes. Open the routes.rb and append the following line:
1Rails.application.routes.draw do 2 constraints(lambda { |req| req.format == :json }) do 3 # ---Previous Routes--- 4 resource :preference, only: %i[show update] do 5 patch :mail, on: :collection 6 end 7 end 8 9 root "home#index" 10 get '*path', to: 'home#index', via: :all 11end
We have added a collection route called mail for the preference resource. Any patch request on preference/mail will invoke the mail action inside the PreferencesController.
Note that we have used a patch verb for the mail route over the put verb. This is so because a put verb is used when the entire entity needs to be updated whereas a patch request is used when the entity needs to be updated partially. In our case, we are only updating the receive_email attribute of a preference record thus the use of patch verb.
Why we do not need a preference policy?
Policies prevent any unauthorized access of database records. In case of preferences, a user should not be able to access the preferences of another user. We do not need a policy to ensure this because we are using the preferences association of the User model to load the preference inside the PreferencesController.
Doing so will only load the preference of the current_user thus eliminating the need for authorization using a policy.
Building the UI for user preference
Since we have already added APIs for handling user preference, let's now build the views for it.
Let's create a new file to define all the preference related APIs:
1touch app/javascript/src/apis/preferences.js
Open the apis/preferences.js and paste the following lines:
1import axios from "axios"; 2 3const show = () => axios.get("/preference"); 4 5const mail = ({ payload }) => axios.patch(`/preference//mail`, payload); 6 7const update = ({ payload }) => axios.put("/preference", payload); 8 9const preferencesApi = { 10 show, 11 update, 12 mail, 13}; 14 15export default preferencesApi;
Now, let's create the preference component:
1mkdir -p app/javascript/src/components/MyPreferences 2touch app/javascript/src/components/MyPreferences/index.jsx
Paste the following lines into index.jsx:
1import React, { useState, useEffect } from "react"; 2 3import preferencesApi from "apis/preferences"; 4import Container from "components/Container"; 5import PageLoader from "components/PageLoader"; 6import { getFromLocalStorage } from "utils/storage"; 7 8import PreferenceForm from "./Form"; 9 10const MyPreferences = () => { 11 const [notificationDeliveryHour, setNotificationDeliveryHour] = useState(""); 12 const [receiveEmail, setReceiveEmail] = useState(true); 13 const [loading, setLoading] = useState(false); 14 const [pageLoading, setPageLoading] = useState(true); 15 const [preferenceId, setPreferenceId] = useState(""); 16 const userId = getFromLocalStorage("authUserId"); 17 18 const updatePreference = async () => { 19 setLoading(true); 20 try { 21 await preferencesApi.update({ 22 payload: { 23 notification_delivery_hour: notificationDeliveryHour, 24 receive_email: receiveEmail, 25 }, 26 }); 27 } catch (error) { 28 logger.error(error); 29 } finally { 30 setLoading(false); 31 } 32 }; 33 34 const updateEmailNotification = async emailNotificationStatus => { 35 setLoading(true); 36 try { 37 await preferencesApi.mail({ 38 id: preferenceId, 39 payload: { 40 receive_email: emailNotificationStatus, 41 }, 42 }); 43 } catch (error) { 44 logger.error(error); 45 } finally { 46 setLoading(false); 47 } 48 }; 49 50 const fetchPreferenceDetails = async () => { 51 try { 52 const { data } = await preferencesApi.show(); 53 setNotificationDeliveryHour(data.preference.notification_delivery_hour); 54 setReceiveEmail(data.preference.receive_email); 55 setPreferenceId(data.preference.id); 56 } catch (error) { 57 logger.error(error); 58 } finally { 59 setPageLoading(false); 60 } 61 }; 62 63 useEffect(() => { 64 fetchPreferenceDetails(); 65 }, []); 66 67 if (pageLoading || !userId || !preferenceId) { 68 return ( 69 <div className="w-screen h-screen"> 70 <PageLoader /> 71 </div> 72 ); 73 } 74 75 return ( 76 <Container> 77 <PreferenceForm 78 notificationDeliveryHour={notificationDeliveryHour} 79 setNotificationDeliveryHour={setNotificationDeliveryHour} 80 receiveEmail={receiveEmail} 81 setReceiveEmail={setReceiveEmail} 82 loading={loading} 83 updatePreference={updatePreference} 84 updateEmailNotification={updateEmailNotification} 85 /> 86 </Container> 87 ); 88}; 89 90export default MyPreferences;
Here, fetchPreferenceDetails function uses the show axios API call from preference related axios APIs. Similarly, the updatePreference function will be using the update axios API.
As you can see we have used a PreferenceForm component which is not yet created. Let's create it:
1mkdir -p app/javascript/src/components/MyPreferences/Form 2touch app/javascript/src/components/MyPreferences/Form/index.jsx
Paste the following lines in the MyPreferences/Form/index.jsx:
1import React from "react"; 2 3import classnames from "classnames"; 4import Button from "components/Button"; 5import Input from "components/Input"; 6import Select from "react-select"; 7 8const defaultTimezone = "UTC"; 9 10const PreferenceForm = ({ 11 notificationDeliveryHour, 12 setNotificationDeliveryHour, 13 receiveEmail, 14 setReceiveEmail, 15 loading, 16 updatePreference, 17 updateEmailNotification, 18}) => { 19 const onHandleDeliveryHourChange = event => { 20 const regex = /^[0-9\b]*$/; 21 const deliveryHour = event.target.value; 22 if (!regex.test(deliveryHour)) return null; 23 24 return setNotificationDeliveryHour(deliveryHour); 25 }; 26 27 const handleSubmit = event => { 28 event.preventDefault(); 29 if (receiveEmail) { 30 updatePreference(); 31 } 32 }; 33 34 const handleEmailNotificationChange = e => { 35 setReceiveEmail(e.target.checked); 36 return updateEmailNotification(e.target.checked); 37 }; 38 39 return ( 40 <form className="max-w-lg mx-auto" onSubmit={handleSubmit}> 41 <div className="flex justify-between text-bb-gray-600 mt-10 mb-2"> 42 <h1 className="pb-3 mt-5 text-2xl leading-5 font-bold"> 43 Pending Tasks in Email 44 </h1> 45 </div> 46 47 <div 48 className={classnames("flex items-baseline space-x-1", { 49 "text-bb-gray-700": receiveEmail, 50 "text-bb-gray-600": !receiveEmail, 51 })} 52 > 53 <input 54 type="checkbox" 55 checked={receiveEmail} 56 id="receiveEmail" 57 onChange={handleEmailNotificationChange} 58 /> 59 <span> 60 Send me a daily email of the pending tasks assigned to me. 61 <br /> No email will be sent if there are no pending tasks. 62 </span> 63 </div> 64 65 <div 66 className={classnames("flex space-x-4 items-center", { 67 "text-bb-gray-700": receiveEmail, 68 "text-bb-gray-600": !receiveEmail, 69 })} 70 > 71 <p className="text-sm font-medium mt-6 leading-5 "> 72 Delivery Time (Hours) 73 </p> 74 <Input 75 type="number" 76 placeholder="Enter hour" 77 disabled={!receiveEmail} 78 min={0} 79 max={23} 80 value={notificationDeliveryHour} 81 onChange={onHandleDeliveryHourChange} 82 /> 83 <p className="mt-6 font-extrabold">(UTC)</p> 84 </div> 85 86 <div className="w-2/6"> 87 <Button 88 type="submit" 89 buttonText="Schedule Email" 90 className={classnames({ 91 "cursor-not-allowed bg-opacity-60": !receiveEmail, 92 })} 93 loading={loading} 94 /> 95 </div> 96 </form> 97 ); 98}; 99 100export default PreferenceForm;
The PreferenceForm component contains a checkbox for enabling and disabling the email notifications. When the checkbox value changes, updateEmailNotification function defined inside the MyPreferences component will be called and it will in turn make an API for mail action in the PreferencesController.
There is a updatePreference function as well which will be called upon submitting the form and it will make an API request to update the logged in user's preference.
We need to provide an Input component from where the user can set their preferred time for mail delivery.
We have to constraint the values that this component can accept, when it's type is set to be number.
Thus let's update our reusable Input component to provide this functionality:
1import React from "react"; 2 3import classnames from "classnames"; 4import PropTypes from "prop-types"; 5 6const Input = ({ 7 type = "text", 8 label, 9 value, 10 onChange, 11 placeholder, 12 min, 13 max, 14 required = true, 15}) => { 16 return ( 17 <div className="mt-6"> 18 {label && ( 19 <label className="block text-sm font-medium leading-5 text-bb-gray-700"> 20 {label} 21 </label> 22 )} 23 <div className="mt-1 rounded-md shadow-sm"> 24 <input 25 type={type} 26 required={required} 27 value={value} 28 onChange={onChange} 29 placeholder={placeholder} 30 min={min} 31 max={max} 32 className={classnames( 33 "block w-full px-3 py-2 placeholder-gray-400 transition duration-150 ease-in-out border border-gray-300 rounded-md appearance-none focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5", 34 [className] 35 )} 36 /> 37 </div> 38 </div> 39 ); 40}; 41 42Input.propTypes = { 43 type: PropTypes.string, 44 label: PropTypes.string, 45 value: PropTypes.node, 46 placeholder: PropTypes.string, 47 onChange: PropTypes.func, 48 required: PropTypes.bool, 49 min: PropTypes.number, 50 max: PropTypes.number, 51}; 52 53export default Input;
Finally, our Preference component is ready to use. Let's add a route for this component in App.jsx and let's also create a link to it in our navbar:
1// previous code if any 2import MyPreferences from "components/MyPreferences"; 3 4const App = () => { 5 // previous code if any 6 return ( 7 <Router> 8 <ToastContainer /> 9 <Switch> 10 {/* previous code if any */} 11 <Route exact path="/login" component={Login} /> 12 <Route exact path="/my/preferences" component={MyPreferences} /> 13 {/* previous code if any */} 14 </Switch> 15 </Router> 16 ); 17}; 18 19export default App;
Now open the Navbar component from components/NavBar/index.jsx and add the following line in order to create a Preferences item in navbar:
1// previous code if any 2const NavBar = () => { 3 // previous code if any 4 return ( 5 <nav className="bg-white shadow"> 6 <div className="px-2 mx-auto max-w-7xl sm:px-4 lg:px-8"> 7 <div className="flex justify-between h-16"> 8 <div className="flex px-2 lg:px-0"> 9 <div className="hidden lg:flex"> 10 <NavItem name="Todos" path="/dashboard" /> 11 <NavItem 12 name="Add" 13 iconClass="ri-add-fill" 14 path="/tasks/create" 15 /> 16 </div> 17 </div> 18 <div className="flex items-center justify-end gap-x-4"> 19 <span 20 className="inline-flex items-center px-2 pt-1 text-sm 21 font-regular leading-5 text-bb-gray-600 text-opacity-50 22 transition duration-150 ease-in-out border-b-2 23 border-transparent focus:outline-none 24 focus:text-bb-gray-700" 25 > 26 <Link to="/my/preferences">Preferences </Link> 27 </span> 28 <span 29 className="inline-flex items-center px-2 pt-1 text-sm 30 font-regular leading-5 text-bb-gray-600 text-opacity-50 31 transition duration-150 ease-in-out border-b-2 32 border-transparent focus:outline-none 33 focus:text-bb-gray-700" 34 > 35 {userName} 36 </span> 37 <a 38 onClick={handleLogout} 39 className="inline-flex items-center px-1 pt-1 text-sm 40 font-semibold leading-5 text-bb-gray-600 text-opacity-50 41 transition duration-150 ease-in-out border-b-2 42 border-transparent hover:text-bb-gray-600 focus:outline-none 43 focus:text-bb-gray-700 cursor-pointer" 44 > 45 LogOut 46 </a> 47 </div> 48 </div> 49 </div> 50 </nav> 51 ); 52};
Implementing task mailer
Let's create the task mailer by running the following Rails command:
1bundle exec rails g mailer TaskMailer
Open the application_mailer.rb from app/mailers/application_mailer.rb and, provide the default email from which the mail will be send:
1class ApplicationMailer < ActionMailer::Base 2 default from: 'no-reply@granite.com' 3 layout 'mailer' 4end
Mailers have methods called "actions" and they use views to structure their content. A controller generates content like say HTML, which will be sent back to the client, whereas a Mailer creates a message to be delivered via email.
Now, let's edit the task_mailer.rb which we have created.
Paste the following lines in task_mailer.rb:
1class TaskMailer < ApplicationMailer 2 after_action :create_user_notification, if: -> { @receiver } 3 4 def pending_tasks_email(receiver_id) 5 @receiver = User.find_by(id: receiver_id) 6 return unless @receiver 7 8 @tasks = @receiver.assigned_tasks.pending 9 mail(to: @receiver.email, subject: "Pending Tasks") 10 end 11 12 private 13 14 def create_user_notification 15 @receiver.user_notifications.create(last_notification_sent_date: Time.zone.today) 16 end 17end
As you can see in the above code we have used after_action which is an Action Mailer Callback. Using an after_action callback enables you to perform operations after successfully sending the mail.
Once we ensure that mail is successfully sent, we will create an entry in user_notifications with today's date, for the current user.
Our TaskMailer is ready. Let's create a mailer view, which will contain the content and styling of the email.
Open app/views and you should be able to see the task_mailer directory. Let's create a pending_tasks_email.html.erb file into it:
1touch app/views/task_mailer/pending_tasks_email.html.erb
Paste the following lines into it:
1<html> 2 <head> 3 <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" /> 4 </head> 5 <body> 6 <h1>Hi! <%= @receiver.name %></h1> 7 <p>Here is a list of your pending tasks for today.<br /></p> 8 <ul> 9 <% @tasks.each do |task| %> 10 <li><%= task.title %></li> 11 <% end %> 12 </ul> 13 </body> 14</html>
Use case of secrets.yml file
Rails supports the secrets.yml file to store your application's secrets. It is used to store secrets such as access keys for external APIs or our Redis URL etc.
This will provide reusability and also will help provide us easier access.
Let's create this file:
1touch config/secrets.yml
Open the file and paste the following lines:
1default: &default 2 redis_url: <%= ENV['REDISTOGO_URL'] || ENV['REDIS_URL'] || 'redis://localhost:6379/1' %>
Creating schedule.yml
We will create a scheduler file containing the cron syntax for different environments and workers.
Cron is a standard Unix utility that is used to schedule commands for automatic execution at specific intervals.
Here we are using cron to send an email notification to the users every day at the default time, i.e., at 10 AM or preferred time, if set by the user.
Let's create the scheduler file:
1touch config/schedule.yml
Open the file and paste the following lines into it:
1default: &default 2 todo_notifications_worker: 3 cron: "0 * * * *" 4 class: "TodoNotificationsWorker" 5 queue: "default" 6 7development: 8 todo_notifications_worker: 9 cron: "* * * * *" 10 class: "TodoNotificationsWorker" 11 queue: "default" 12 13test: 14 <<: *default 15 16staging: 17 <<: *default 18 19production: 20 <<: *default
The default and development are two important keys in the YAML file. Each of those keys invoke a worker.
In the above code, you can see that the cron value for default and development are different. The reason is that in development we don't need to wait. We need the cron to get triggered as soon as possible.
The cron value for development executes a cron job every minute, whereas for default the same is executed every hour.
Here in our YAML file, the default key is the main key because its value will be used in other environments like test, staging, and production.
We create an alias for the default key using an anchor marked with the ampersand & and then reference that alias using the asterisk *. This is purely a "YAML" way of inheriting existing keys.
Adding logic to schedule cron jobs
We have already written down when and what needs to be enqueued, in our schedule.yml file.
Thus in the Sidekiq initializer, we have to add logic that will read the content from our scheduler file and will enqueue based on the cron defined for the specific environment.
Also, we have to update the REDIS_URL with the URL we have specified in our secrets file.
Open sidekiq.rb from config/initializers.
Add the following lines to this file:
1# frozen_string_literal: true 2 3if Rails.env.test? || Rails.env.heroku? 4 require 'sidekiq/testing' 5 Sidekiq::Testing.inline! 6end 7 8Sidekiq::Extensions.enable_delay! 9 10Sidekiq.configure_server do |config| 11 config.redis = { url: Rails.application.secrets.redis_url, size: 9 } 12 unless Rails.env.test? || Rails.env.production? 13 schedule_file = "config/schedule.yml" 14 15 if File.exists?(schedule_file) 16 Sidekiq::Cron::Job.load_from_hash! YAML.load_file(schedule_file)[Rails.env] 17 end 18 end 19end 20 21Sidekiq.configure_client do |config| 22 config.redis = { url: Rails.application.secrets.redis_url, size: 1 } 23end
We have currently not enabled mail sending in production environment.
If possible, we will be handling this case in the upcoming chapters.
Adding TodoNotifications functionality
For Sidekiq workers, there already exists a generator for generating a worker file.
Let's create the TodoNotifications worker:
1rails generate sidekiq:worker TodoNotificationsWorker
The sole purpose of the TodoNotificationsWorker is process a TodoNotificationsService, which will take care of other core functionalities.
Open app/workers/todo_notifications_worker.rb and paste the following:
1# frozen_string_literal: true 2 3class TodoNotificationsWorker 4 include Sidekiq::Worker 5 6 def perform 7 todo_notification_service = TodoNotificationService.new 8 todo_notification_service.process 9 end 10end
Let's create our TodoNotificationService to handle the heavy work.
Rails do not provide any inbuilt generators to create services.
Currently, we can only manually create the service as well as its test file.
This service will take care of finding users with pending tasks, whose delivery time falls with the current execution time range, and invoke workers which will send the mail to each user.
Let's create the service:
1mkdir -p app/services 2touch app/services/todo_notification_service.rb
Open the todo_notification_service.rb file and paste the following:
1# frozen_string_literal: true 2 3class TodoNotificationService 4 attr_reader :users_to_notify 5 6 def initialize 7 @users_to_notify = get_users_to_notify 8 end 9 10 def process 11 notify_users 12 end 13 14 private 15 16 def get_users_to_notify 17 users = get_users_with_pending_tasks 18 users_with_pending_notifications(users) 19 end 20 21 def notify_users 22 users_to_notify.each do |user| 23 UserNotificationsWorker.perform_async(user.id) 24 end 25 end 26 27 def get_users_with_pending_tasks 28 User.includes(:assigned_tasks, :preference).where( 29 tasks: { progress: 'pending' }, 30 preferences: { 31 receive_email: true, 32 notification_delivery_hour: hours_till_now 33 } 34 ) 35 end 36 37 def users_with_pending_notifications(users) 38 no_mail_sent = "user_notifications.last_notification_sent_date <>" 39 first_time_mailing = "user_notifications.id is NULL" 40 delivery_condition = "#{no_mail_sent} ? OR #{first_time_mailing}" 41 42 users.left_outer_joins(:user_notifications) 43 .where(delivery_condition, Time.zone.today).distinct 44 end 45 46 def hours_till_now 47 current_hour = Time.now.utc.hour 48 (0..current_hour) 49 end 50end
Let's discuss the initialize method before moving ahead. initialize method in Ruby is basically a constructor which we use to initialize instance variables during object creation. The initialize method gets invoked implicitly even if no instance variables are initialized.
If there is an attribute which you need to access in multiple methods defined inside the class then you should declare it as an instance variable and initialize it in the initialize method. At BigBinary we use the attr_accessor macro to access the instance methods inside the class.
Let's also create a separate worker, named UserNotificationsWorker.
As per our technical design, we need to ensure that mail delivery for one single user, doesn't break the whole delivery system.
The first line of defense against this case, is to invoke a separate worker, which is UserNotificationsWorker, for each of the user.
This way failure of one worker won't affect the other workers, given that Sidekiq runs them in separate threads.
Let's create UserNotificationsWorker:
1rails generate sidekiq:worker UserNotificationsWorker
Open app/workers/user_notifications_worker.rb and paste the following:
1class UserNotificationsWorker 2 include Sidekiq::Worker 3 4 def perform(user_id) 5 TaskMailer.delay.pending_tasks_email(user_id) 6 end 7end
Phew! That was a lot of content with loads of good stuff.
That completes our feature and mail delivery should be working in a development environment.
We will be writing tests for the same, in the next chapter.
Moving messages to i18n en.locales
Recall that we already have a Task not found. translation definition inside en.yml file and it is very similar to Preference not found. which is one of the error translation definitions we have to add.
Rather than having two similar translations for messages which look like record not found, we can use the variable interpolation feature provided by i18n to pass the name of entity which is not found. This will prevent code duplication.
Fully replace en.yml with the following lines of code:
1en: 2 authorization: 3 denied: "Access denied. You are not authorized to perform this action." 4 session: 5 could_not_auth: "Could not authenticate with the provided credentials." 6 incorrect_credentials: "Incorrect credentials, try again." 7 successfully_created: "%{entity} was successfully created!" 8 successfully_updated: "%{entity} was successfully updated!" 9 successfully_deleted: "%{entity} was successfully deleted!" 10 not_found: "%{entity} not found." 11 task: 12 slug: 13 immutable: "is immutable!" 14 preference: 15 notification_delivery_hour: 16 range: "value should be between 0 and 23" 17 mail: 18 notification_status: "Email notifications %{status}." 19 date: 20 invalid: "must be a valid date" 21 cant_be_in_past: "can't be in the past"
Now, the not_found translation in en.yml, will expect a value to be passed for the entity variable. So let's go through our JSON responses and pass that variable with appropriate entity name, wherever we are using the not_found translation key.
Update the following highlighted line in the load_preference method in PreferencesController:
1def load_preference 2 @preference = current_user.preference 3 unless @preference 4 respond_with_error(t("not_found", entity:"Preference"), :not_found) 5 end 6end
We have also used a Task not found translation inside test_not_found_error_rendered_for_invalid_task_slug in TasksControllerTest where we are testing if the correct error is being rendered for an invalid task slug. Let's update the test case to use the updated i18n translation string.
1def test_not_found_error_rendered_for_invalid_task_slug 2 invalid_slug = "invalid-slug" 3 4 get task_path(invalid_slug), headers: @creator_headers 5 assert_response :not_found 6 assert_equal response_json["error"], t("not_found", entity: "Task") 7end
Update the test_shouldnt_create_comment_without_task inside the CommentsControllerTest like so:
1def test_shouldnt_create_comment_without_task 2 post comments_path, params: { comment: { content: "This is a comment", task_id: "" } }, headers: @headers 3 assert_response :not_found 4 response_json = response.parsed_body 5 assert_equal response_json["error"], t("not_found", entity: "Task") 6end
Update the test_should_respond_with_not_found_error_if_user_is_not_present inside the SessionsControllerTest like so:
1def test_should_respond_with_not_found_error_if_user_is_not_present 2 non_existent_email = "this_email_does_not_exist_in_db@example.email" 3 post session_path, params: { login: { email: non_existent_email, password: "welcome" } }, as: :json 4 assert_response :not_found 5 assert_equal response_json["error"], t("not_found", entity: "User") 6end
Let's also add the error translations in UserNotification and Preference models.
Update UserNotification model with the following lines of code:
1class UserNotification < ApplicationRecord 2 belongs_to :user 3 4 validates :last_notification_sent_date, presence: true 5 validate :last_notification_sent_date_is_valid_date 6 validate :last_notification_sent_date_cannot_be_in_the_past 7 8 private 9 10 def last_notification_sent_date_is_valid_date 11 if last_notification_sent_date.present? 12 Date.parse(last_notification_sent_date.to_s) 13 end 14 rescue ArgumentError 15 errors.add(:last_notification_sent_date, t("date.invalid")) 16 end 17 18 def last_notification_sent_date_cannot_be_in_the_past 19 if last_notification_sent_date.present? && last_notification_sent_date < Time.zone.today 20 errors.add(:last_notification_sent_date, t("date.cant_be_in_past")) 21 end 22 end 23end
Update the following highlighted line in the Preference model:
1class Preference < ApplicationRecord 2 belongs_to :user 3 4 validates :notification_delivery_hour, 5 presence: true, 6 numericality: { only_integer: true }, 7 inclusion: { 8 in: 0..23, 9 message: I18n.t("preference.notification_delivery_hour.range") 10 } 11end
Testing PreferencesController
To test the preferences controller, we do not need to create a preference factory since a preference association is created automatically for each user when that new user is created.
Now, create a new file test/controllers/preferences_controller_test.rb and add the following content:
1require "test_helper" 2 3class PreferencesControllerTest < ActionDispatch::IntegrationTest 4 def setup 5 @user = create(:user) 6 @preference = @user.preference 7 @headers = headers(@user) 8 end 9end
Now, let us test if the correct preferences are rendered for a valid user. Append the following test case to the PreferencesControllerTest:
1def test_show_preference_for_a_valid_user 2 get preference_path, headers: @headers 3 assert_response :ok 4 assert_equal response.parsed_body["preference"]["id"], @preference.id 5end
We should also add a test case to check if correct response is being rendered if the preference is not found. Add the following test case to the PreferencesControllerTest:
1def test_not_found_error_rendered_if_preference_is_not_present 2 @user.preference = nil 3 get preference_path, headers: @headers 4 assert_response :not_found 5end
Let us test if the update action is working as intended. Add the following test cases to the PreferencesControllerTest:
1def test_update_success 2 preference_params = { preference: { receive_email: false } } 3 4 put preference_path, params: preference_params, headers: @headers 5 @preference.reload 6 assert_response :ok 7 refute @preference.receive_email 8end 9 10def test_update_failure_for_invalid_notification_delivery_hour 11 preference_params = { preference: { notification_delivery_hour: 24 } } 12 13 put preference_path, params: preference_params, headers: @headers 14 assert_response :unprocessable_entity 15 assert_equal response.parsed_body["error"], "Notification delivery hour #{t("preference.notification_delivery_hour.range")}" 16end
Let us test the mail action. Add the following test case to the PreferencesControllerTest:
1def test_update_success_mail 2 preference_params = { preference: { receive_email: false } } 3 4 patch mail_preference_path, params: preference_params, headers: @headers 5 @preference.reload 6 assert_response :ok 7 refute @preference.receive_email 8 end
Now you can try running all the preference test cases and they should be passing:
1bundle exec rails test -v test/controllers/preferences_controller_test.rb
Now let's commit these changes:
1git add -A 2git commit -m "Added ability to send email to each user with pending tasks"