diff --git a/app/controllers/v1/users_controller.rb b/app/controllers/v1/users_controller.rb index c759abf..7277582 100644 --- a/app/controllers/v1/users_controller.rb +++ b/app/controllers/v1/users_controller.rb @@ -21,6 +21,6 @@ def update private def prediction_params - params.require(:user).permit(:name, :timezone, :photo_key) + params.require(:user).permit(:name, :timezone, :photo_key, notifications: {}) end end diff --git a/app/jobs/notifications/prediction_missing_job.rb b/app/jobs/notifications/prediction_missing_job.rb new file mode 100644 index 0000000..eef7c0f --- /dev/null +++ b/app/jobs/notifications/prediction_missing_job.rb @@ -0,0 +1,16 @@ +class Notifications::PredictionMissingJob < ApplicationJob + queue_as :default + + def perform(match_ids) + matches = Match.where(id: match_ids) + matches.each do |match| + # loads all users in this competition that have emailing turned on + users_to_email = User.need_prediction_notifications(match) + users_to_email.each do |user| + # Checks to see if an email has already been sent + email = Email.find_by(user: user, topic: match, notification: 'prediction_missing') + UserMailer.with(user: user, match: match, notification: 'prediction_missing').prediction_missing.deliver_later unless email + end + end + end +end diff --git a/app/jobs/schedule_daily_tasks_job.rb b/app/jobs/schedule_daily_tasks_job.rb index 3df356f..1785960 100644 --- a/app/jobs/schedule_daily_tasks_job.rb +++ b/app/jobs/schedule_daily_tasks_job.rb @@ -4,10 +4,14 @@ class ScheduleDailyTasksJob < ApplicationJob def perform competitions = Competition.on_going competitions.each do |competition| + # Schedules the matches as "started" based on their kickoff_time matches = competition.matches.where(kickoff_time: Date.today.all_day) matches.pluck(:kickoff_time).uniq.each do |kickoff_time| MatchStartedJob.set(wait_until: kickoff_time).perform_later(kickoff_time) end + # Schedules notifications for missing predicitions + matches_tomorrow = competition.matches.where(kickoff_time: Date.tomorrow.all_day) + Notifications::PredictionMissingJob.perform_later(matches_tomorrow.pluck(:id)) end end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 286b223..d90f92a 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: 'hello@octacle.app' layout 'mailer' end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..294f814 --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,15 @@ +class UserMailer < ApplicationMailer + + # Subject can be set in your I18n file at config/locales/en.yml + # with the following lookup: + # + # en.user_mailer.prediction_missing.subject + # + def prediction_missing + @user = params[:user] # Instance variable => available in view + @match = params[:match] + @notification = params[:notification] + mail(to: @user.email, subject: "Octacle - You're missing a prediction for #{@match.team_home.abbrev} vs. #{@match.team_away.abbrev}") + Email.create(user: @user, topic: @match, notification: @notification) + end +end diff --git a/app/models/competition.rb b/app/models/competition.rb index 6032651..3261357 100644 --- a/app/models/competition.rb +++ b/app/models/competition.rb @@ -1,6 +1,6 @@ class Competition < ApplicationRecord belongs_to :current_round, class_name: 'Round', optional: true - has_many :matches, dependent: :destroy + has_many :matches, -> { distinct }, dependent: :destroy has_many :rounds, -> { distinct }, through: :matches has_many :groups, through: :rounds has_many :affiliations, through: :groups @@ -8,6 +8,8 @@ class Competition < ApplicationRecord has_many :leaderboards, dependent: :destroy has_many :predictions, through: :matches, dependent: :destroy has_many :users, through: :leaderboards, source: :users + # For some reason, this doesn't give me back a collection + # has_many :all_users_predicted, -> { distinct }, through: :predictions, source: :user has_one_attached :photo validates :name, presence: true, uniqueness: { scope: :start_date} @@ -26,4 +28,13 @@ def destroy_rounds def max_possible_score matches.finished.joins(:round).sum('rounds.points') end + + def users_predicted + # For some reason, this doesn't give me back a collection + # User.joins(:predictions) + # .where(predictions: { user_id: predictions.select(:user_id).distinct }).distinct + # TODO: refactor using an Active Record query that works + user_ids = User.find_by_sql(['SELECT DISTINCT users.id FROM users JOIN predictions ON predictions.user_id = users.id JOIN matches ON matches.id = predictions.match_id WHERE matches.competition_id = ?', id]) + User.where(id: user_ids.pluck(:id)) + end end diff --git a/app/models/email.rb b/app/models/email.rb new file mode 100644 index 0000000..f1eb3e4 --- /dev/null +++ b/app/models/email.rb @@ -0,0 +1,4 @@ +class Email < ApplicationRecord + belongs_to :user + belongs_to :topic, polymorphic: true, optional: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 79c214a..7333c3c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,10 +10,15 @@ class User < ApplicationRecord # has_many :competitions, through: :leaderboards has_many :predictions, dependent: :destroy has_many :matches, through: :predictions + has_many :emails, dependent: :destroy # Scenic views has_many :scores, class_name: 'UserScore' + scope :with_email_prediction_missing, -> { + where("notifications->'email'->>'prediction_missing' = ?", 'true') + } + validates :name, presence: true, on: :update, if: :name_changed? after_create :auto_join_leaderboards @@ -22,6 +27,35 @@ def name super || email.split('@').first end + # user.notification_enabled?(:email, :prediction_missing) + # (query) User.where("notifications->'email'->>'prediction_missing' = ?", 'true') + def notification_enabled?(method, event) + notifications.dig(method.to_s, event.to_s) || false + end + + # user.enable_notification!(:email, :prediction_missing) + def enable_notification!(method, event) + self.notifications[method.to_s] ||= {} + self.notifications[method.to_s][event.to_s] = true + save + end + + # user.disable_notification!(:email, :prediction_missing) + def disable_notification!(method, event) + self.notifications[method.to_s] ||= {} + self.notifications[method.to_s][event.to_s] = false + save + end + + def self.need_prediction_notifications(next_match) + return [] if next_match.blank? + + # total number of people who have made predicitons (and have email on) + users = next_match.competition.users_predicted.with_email_prediction_missing + # minus the ones who have made predictions for this match + users - users.joins(:predictions).where(predictions: { match_id: next_match.id }) + end + private def auto_join_leaderboards diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index cbd34d2..afa23db 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -2,11 +2,27 @@ + - <%= yield %> diff --git a/app/views/shared/_banner.html.erb b/app/views/shared/_banner.html.erb new file mode 100644 index 0000000..077f518 --- /dev/null +++ b/app/views/shared/_banner.html.erb @@ -0,0 +1,4 @@ +
+ <%= image_tag 'https://raw.githubusercontent.com/trouni/predictor-vue/main/src/assets/logo.png', width: 100, alt: "text", class: 'me-2' %> +

Octacle

+
diff --git a/app/views/shared/_unsubscribe.html.erb b/app/views/shared/_unsubscribe.html.erb new file mode 100644 index 0000000..3ac0bba --- /dev/null +++ b/app/views/shared/_unsubscribe.html.erb @@ -0,0 +1 @@ +<%= link_to 'Unsubscribe', 'https://www.octacle.app/profile', class: 'text-muted text-decoration-none' %> diff --git a/app/views/user_mailer/prediction_missing.html.erb b/app/views/user_mailer/prediction_missing.html.erb new file mode 100644 index 0000000..34b376a --- /dev/null +++ b/app/views/user_mailer/prediction_missing.html.erb @@ -0,0 +1,18 @@ +
+ <%= render 'shared/banner' %> +
+
+
+ <%= image_tag cl_image_path(@match.team_home.flag.key), alt: "text", width: 100, class: 'me-2 rounded' %><%= @match.team_home.name %> vs. <%= @match.team_away.name %> <%= image_tag cl_image_path(@match.team_away.flag.key), alt: "text", width: 100, class: 'ms-2 rounded' %> +
+

The match kicks off at <%= @match.kickoff_time.strftime('%a, %e %b %H:%M') %> UTC.

+

Don't forget to get your prediction locked in!

+

+ <%= link_to 'Visit Octacle', 'https://www.octacle.app/competitions/predictions', class: 'btn btn-purple', target: '_blank' %> +

+

+ <%= render 'shared/unsubscribe' %> +

+
+
+
diff --git a/app/views/user_mailer/prediction_missing.text.erb b/app/views/user_mailer/prediction_missing.text.erb new file mode 100644 index 0000000..57f3353 --- /dev/null +++ b/app/views/user_mailer/prediction_missing.text.erb @@ -0,0 +1,3 @@ +User#prediction_missing + +<%= @greeting %>, find me in app/views/user_mailer/prediction_missing.text.erb diff --git a/app/views/v1/users/_user.json.jbuilder b/app/views/v1/users/_user.json.jbuilder index fcb41bd..a7ecd82 100644 --- a/app/views/v1/users/_user.json.jbuilder +++ b/app/views/v1/users/_user.json.jbuilder @@ -1 +1 @@ -json.extract! user, :id, :email, :timezone, :admin, :photo_key, :name +json.extract! user, :id, :email, :timezone, :admin, :photo_key, :name, :notifications diff --git a/db/migrate/20240623064544_add_notifications_to_users.rb b/db/migrate/20240623064544_add_notifications_to_users.rb new file mode 100644 index 0000000..c43e066 --- /dev/null +++ b/db/migrate/20240623064544_add_notifications_to_users.rb @@ -0,0 +1,15 @@ +class AddNotificationsToUsers < ActiveRecord::Migration[6.1] + def change + add_column :users, :notifications, :jsonb, default: {} + add_index :users, :notifications, using: :gin + User.find_each do |user| + user.notifications = { + email: { + prediction_missing: true, + competition_new: true + } + } + user.save + end + end +end diff --git a/db/migrate/20240627134655_create_emails.rb b/db/migrate/20240627134655_create_emails.rb new file mode 100644 index 0000000..109258f --- /dev/null +++ b/db/migrate/20240627134655_create_emails.rb @@ -0,0 +1,10 @@ +class CreateEmails < ActiveRecord::Migration[6.1] + def change + create_table :emails do |t| + t.references :user, null: false, foreign_key: true + t.string :notification + t.references :topic, polymorphic: true + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b86f751..6f59ecd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_06_06_055739) do +ActiveRecord::Schema.define(version: 2024_06_27_134655) do # These are extensions that must be enabled in order to support this database + enable_extension "pg_stat_statements" enable_extension "plpgsql" create_table "active_storage_attachments", force: :cascade do |t| @@ -64,6 +65,17 @@ t.index ["current_round_id"], name: "index_competitions_on_current_round_id" end + create_table "emails", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "notification" + t.string "topic_type" + t.bigint "topic_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["topic_type", "topic_id"], name: "index_emails_on_topic" + t.index ["user_id"], name: "index_emails_on_user_id" + end + create_table "groups", force: :cascade do |t| t.string "name" t.bigint "round_id", null: false @@ -179,8 +191,10 @@ t.inet "current_sign_in_ip" t.inet "last_sign_in_ip" t.string "photo_key" + t.jsonb "notifications", default: {} t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true + t.index ["notifications"], name: "index_users_on_notifications", using: :gin t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["uid", "provider"], name: "index_users_on_uid_and_provider", unique: true end @@ -190,6 +204,7 @@ add_foreign_key "affiliations", "groups" add_foreign_key "affiliations", "teams" add_foreign_key "competitions", "rounds", column: "current_round_id" + add_foreign_key "emails", "users" add_foreign_key "groups", "rounds" add_foreign_key "leaderboards", "competitions" add_foreign_key "leaderboards", "users" diff --git a/test/fixtures/emails.yml b/test/fixtures/emails.yml new file mode 100644 index 0000000..d45d6f3 --- /dev/null +++ b/test/fixtures/emails.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + notification: 1 + topic: one + +two: + user: two + notification: 1 + topic: two diff --git a/test/jobs/email/prediction_missing_job_test.rb b/test/jobs/email/prediction_missing_job_test.rb new file mode 100644 index 0000000..f6a5ac1 --- /dev/null +++ b/test/jobs/email/prediction_missing_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Emails::PredictionMissingJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/notifications/prediction_missing_job_test.rb b/test/jobs/notifications/prediction_missing_job_test.rb new file mode 100644 index 0000000..055f828 --- /dev/null +++ b/test/jobs/notifications/prediction_missing_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Notifications::PredictionMissingJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb new file mode 100644 index 0000000..f75545b --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -0,0 +1,9 @@ +# Preview all emails at http://localhost:3000/rails/mailers/user_mailer +class UserMailerPreview < ActionMailer::Preview + + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/prediction_missing + def prediction_missing + UserMailer.prediction_missing + end + +end diff --git a/test/mailers/user_mailer_test.rb b/test/mailers/user_mailer_test.rb new file mode 100644 index 0000000..174120a --- /dev/null +++ b/test/mailers/user_mailer_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +class UserMailerTest < ActionMailer::TestCase + test "prediction_missing" do + mail = UserMailer.prediction_missing + assert_equal "Prediction missing", mail.subject + assert_equal ["to@example.org"], mail.to + assert_equal ["from@example.com"], mail.from + assert_match "Hi", mail.body.encoded + end + +end diff --git a/test/models/email_test.rb b/test/models/email_test.rb new file mode 100644 index 0000000..a07b515 --- /dev/null +++ b/test/models/email_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class EmailTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end