mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Merge pull request #1734 from Freika/feature/follow-up-emails
Add follow up emails
This commit is contained in:
commit
608fa41fa8
33 changed files with 830 additions and 187 deletions
|
|
@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
# [UNRELEASED]
|
||||
|
||||
## Fixed
|
||||
|
||||
- Tracked distance on year card on the Stats page will always be equal to the sum of distances on the monthly chart below it. #466
|
||||
- Stats are now being calculated for trial users as well as active ones.
|
||||
|
||||
## Added
|
||||
|
||||
- A cron job to generate daily tracks for users with new points since their last track generation. Being run every 4 hours.
|
||||
|
|
@ -14,6 +19,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
## Changed
|
||||
|
||||
- Stats page now loads significantly faster due to caching.
|
||||
- Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour.
|
||||
- Minor versions are now being built only for amd64 architecture to speed up the build process.
|
||||
- If user is not authorized to see a page, they will be redirected to the home page with appropriate message instead of seeing an error.
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -21,58 +21,6 @@ module ApplicationHelper
|
|||
%w[info success warning error accent secondary primary]
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_year(year, stats)
|
||||
data = { countries: [], cities: [] }
|
||||
|
||||
stats.select { _1.year == year }.each do
|
||||
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
|
||||
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
|
||||
end
|
||||
|
||||
data[:cities].flatten!.uniq!
|
||||
data[:countries].flatten!.uniq!
|
||||
|
||||
grouped_by_country = {}
|
||||
stats.select { _1.year == year }.each do |stat|
|
||||
stat.toponyms.flatten.each do |toponym|
|
||||
country = toponym['country']
|
||||
next unless country.present?
|
||||
|
||||
grouped_by_country[country] ||= []
|
||||
|
||||
next unless toponym['cities'].present?
|
||||
|
||||
toponym['cities'].each do |city_data|
|
||||
city = city_data['city']
|
||||
grouped_by_country[country] << city if city.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_by_country.transform_values!(&:uniq)
|
||||
|
||||
{
|
||||
countries_count: data[:countries].count,
|
||||
cities_count: data[:cities].count,
|
||||
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
|
||||
year: year,
|
||||
modal_id: "countries_cities_modal_#{year}"
|
||||
}
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_month(stat)
|
||||
countries = stat.toponyms.count { _1['country'] }
|
||||
cities = stat.toponyms.sum { _1['cities'].count }
|
||||
|
||||
"#{countries} countries, #{cities} cities"
|
||||
end
|
||||
|
||||
def year_distance_stat(year, user)
|
||||
# Distance is now stored in meters, convert to user's preferred unit for display
|
||||
total_distance_meters = Stat.year_distance(year, user).sum { _1[1] }
|
||||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def new_version_available?
|
||||
CheckAppVersion.new.call
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,58 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module StatsHelper
|
||||
def year_distance_stat(year_data, user)
|
||||
total_distance_meters = year_data.sum { _1[1] }
|
||||
|
||||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_year(year, stats)
|
||||
data = { countries: [], cities: [] }
|
||||
|
||||
stats.select { _1.year == year }.each do
|
||||
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
|
||||
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
|
||||
end
|
||||
|
||||
data[:cities].flatten!.uniq!
|
||||
data[:countries].flatten!.uniq!
|
||||
|
||||
grouped_by_country = {}
|
||||
stats.select { _1.year == year }.each do |stat|
|
||||
stat.toponyms.flatten.each do |toponym|
|
||||
country = toponym['country']
|
||||
next if country.blank?
|
||||
|
||||
grouped_by_country[country] ||= []
|
||||
|
||||
next if toponym['cities'].blank?
|
||||
|
||||
toponym['cities'].each do |city_data|
|
||||
city = city_data['city']
|
||||
grouped_by_country[country] << city if city.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_by_country.transform_values!(&:uniq)
|
||||
|
||||
{
|
||||
countries_count: data[:countries].count,
|
||||
cities_count: data[:cities].count,
|
||||
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
|
||||
year: year,
|
||||
modal_id: "countries_cities_modal_#{year}"
|
||||
}
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_month(stat)
|
||||
countries = stat.toponyms.count { _1['country'] }
|
||||
cities = stat.toponyms.sum { _1['cities'].count }
|
||||
|
||||
"#{countries} countries, #{cities} cities"
|
||||
end
|
||||
|
||||
def distance_traveled(user, stat)
|
||||
distance_unit = user.safe_settings.distance_unit
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ class BulkStatsCalculatingJob < ApplicationJob
|
|||
queue_as :stats
|
||||
|
||||
def perform
|
||||
user_ids = User.active.pluck(:id)
|
||||
user_ids = User.active.pluck(:id) + User.trial.pluck(:id)
|
||||
|
||||
user_ids.each do |user_id|
|
||||
Stats::BulkCalculator.new(user_id).call
|
||||
|
|
|
|||
18
app/jobs/cache/preheating_job.rb
vendored
18
app/jobs/cache/preheating_job.rb
vendored
|
|
@ -10,6 +10,24 @@ class Cache::PreheatingJob < ApplicationJob
|
|||
user.years_tracked,
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_points_geocoded_stats",
|
||||
StatsQuery.new(user).send(:cached_points_geocoded_stats),
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_countries_visited",
|
||||
user.send(:countries_visited_uncached),
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_cities_visited",
|
||||
user.send(:cities_visited_uncached),
|
||||
expires_in: 1.day
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
def perform(user_id, email_type, **options)
|
||||
user = User.find(user_id)
|
||||
|
||||
if trial_related_email?(email_type) && user.active?
|
||||
Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed"
|
||||
if should_skip_email?(user, email_type)
|
||||
ExceptionReporter.call(
|
||||
'Users::MailerSendingJob',
|
||||
"Skipping #{email_type} email for user ID #{user_id} - #{skip_reason(user, email_type)}"
|
||||
)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
|
|
@ -15,12 +19,33 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
|
||||
UsersMailer.with(params).public_send(email_type).deliver_later
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
Rails.logger.warn "User with ID #{user_id} not found. Skipping #{email_type} email."
|
||||
ExceptionReporter.call(
|
||||
'Users::MailerSendingJob',
|
||||
"User with ID #{user_id} not found. Skipping #{email_type} email."
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trial_related_email?(email_type)
|
||||
%w[trial_expires_soon trial_expired].include?(email_type.to_s)
|
||||
def should_skip_email?(user, email_type)
|
||||
case email_type.to_s
|
||||
when 'trial_expires_soon', 'trial_expired'
|
||||
user.active?
|
||||
when 'post_trial_reminder_early', 'post_trial_reminder_late'
|
||||
user.active? || !user.trial?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def skip_reason(user, email_type)
|
||||
case email_type.to_s
|
||||
when 'trial_expires_soon', 'trial_expired'
|
||||
'user is already subscribed'
|
||||
when 'post_trial_reminder_early', 'post_trial_reminder_late'
|
||||
user.active? ? 'user is subscribed' : 'user is not in trial state'
|
||||
else
|
||||
'unknown reason'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,26 +2,44 @@
|
|||
|
||||
class UsersMailer < ApplicationMailer
|
||||
def welcome
|
||||
# Sent after user signs up
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: 'Welcome to Dawarich!')
|
||||
end
|
||||
|
||||
def explore_features
|
||||
# Sent 2 days after user signs up
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: 'Explore Dawarich features!')
|
||||
end
|
||||
|
||||
def trial_expires_soon
|
||||
# Sent 2 days before trial expires
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days')
|
||||
end
|
||||
|
||||
def trial_expired
|
||||
# Sent when trial expires
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '💔 Your Dawarich trial expired')
|
||||
end
|
||||
|
||||
def post_trial_reminder_early
|
||||
# Sent 2 days after trial expires
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '🚀 Still interested in Dawarich? Subscribe now!')
|
||||
end
|
||||
|
||||
def post_trial_reminder_late
|
||||
# Sent 7 days after trial expires
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '📍 Your location data is waiting - Subscribe to Dawarich')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,15 +36,20 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
end
|
||||
|
||||
def countries_visited
|
||||
points
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do
|
||||
points
|
||||
.without_raw_data
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
end
|
||||
end
|
||||
|
||||
def cities_visited
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
end
|
||||
|
||||
def total_distance
|
||||
|
|
@ -156,5 +161,24 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features')
|
||||
Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon')
|
||||
Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired')
|
||||
schedule_post_trial_emails
|
||||
end
|
||||
|
||||
def schedule_post_trial_emails
|
||||
Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early')
|
||||
Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late')
|
||||
end
|
||||
|
||||
def countries_visited_uncached
|
||||
points
|
||||
.without_raw_data
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
end
|
||||
|
||||
def cities_visited_uncached
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,10 +6,25 @@ class StatsQuery
|
|||
end
|
||||
|
||||
def points_stats
|
||||
cached_stats = Rails.cache.fetch("dawarich/user_#{user.id}_points_geocoded_stats", expires_in: 1.day) do
|
||||
cached_points_geocoded_stats
|
||||
end
|
||||
|
||||
{
|
||||
total: user.points_count,
|
||||
geocoded: cached_stats[:geocoded],
|
||||
without_data: cached_stats[:without_data]
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user
|
||||
|
||||
def cached_points_geocoded_stats
|
||||
sql = ActiveRecord::Base.sanitize_sql_array([
|
||||
<<~SQL.squish,
|
||||
SELECT
|
||||
COUNT(id) as total,
|
||||
COUNT(reverse_geocoded_at) as geocoded,
|
||||
COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data
|
||||
FROM points
|
||||
|
|
@ -21,13 +36,8 @@ class StatsQuery
|
|||
result = Point.connection.select_one(sql)
|
||||
|
||||
{
|
||||
total: result['total'].to_i,
|
||||
geocoded: result['geocoded'].to_i,
|
||||
without_data: result['without_data'].to_i
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user
|
||||
end
|
||||
|
|
|
|||
15
app/services/cache/clean.rb
vendored
15
app/services/cache/clean.rb
vendored
|
|
@ -7,6 +7,8 @@ class Cache::Clean
|
|||
delete_control_flag
|
||||
delete_version_cache
|
||||
delete_years_tracked_cache
|
||||
delete_points_geocoded_stats_cache
|
||||
delete_countries_cities_cache
|
||||
Rails.logger.info('Cache cleaned')
|
||||
end
|
||||
|
||||
|
|
@ -25,5 +27,18 @@ class Cache::Clean
|
|||
Rails.cache.delete("dawarich/user_#{user.id}_years_tracked")
|
||||
end
|
||||
end
|
||||
|
||||
def delete_points_geocoded_stats_cache
|
||||
User.find_each do |user|
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_points_geocoded_stats")
|
||||
end
|
||||
end
|
||||
|
||||
def delete_countries_cities_cache
|
||||
User.find_each do |user|
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_countries_visited")
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_cities_visited")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<% content_for :title, 'Statistics' %>
|
||||
|
||||
<div class="w-full my-5">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 relative">
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-primary">
|
||||
<%= number_with_delimiter(current_user.total_distance.round) %> <%= current_user.safe_settings.distance_unit %>
|
||||
|
|
@ -21,6 +21,10 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class='text-xs text-gray-500 text-center mt-5'>
|
||||
All stats data above except for total distance and number of geopoints tracked is being updated daily
|
||||
</div>
|
||||
|
||||
<% if current_user.active? %>
|
||||
<%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %>
|
||||
<% end %>
|
||||
|
|
@ -40,9 +44,7 @@
|
|||
</div>
|
||||
</h2>
|
||||
<p>
|
||||
<% cache [current_user, 'year_distance_stat', year], skip_digest: true do %>
|
||||
<%= number_with_delimiter year_distance_stat(year, current_user).round %> <%= current_user.safe_settings.distance_unit %>
|
||||
<% end %>
|
||||
<%= number_with_delimiter year_distance_stat(@year_distances[year], current_user).round %> <%= current_user.safe_settings.distance_unit %>
|
||||
</p>
|
||||
<% if DawarichSettings.reverse_geocoding_enabled? %>
|
||||
<div class="card-actions justify-end">
|
||||
|
|
|
|||
|
|
@ -17,12 +17,17 @@
|
|||
<h1>Explore Dawarich Features</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>,</p>
|
||||
<p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
|
||||
|
||||
<p>You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.</p>
|
||||
<p>You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data.</p>
|
||||
|
||||
<p>Here are some powerful features you might want to explore:</p>
|
||||
|
||||
<div class="feature">
|
||||
<h3>✈️ Reliving your travels</h3>
|
||||
<p>Revisit your past journeys with detailed maps and insights.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>📊 Statistics & Analytics</h3>
|
||||
<p>View detailed insights about distances traveled and time spent in different locations.</p>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
Explore Dawarich Features
|
||||
|
||||
Hi <%= @user.email %>,
|
||||
Hi <%= @user.email %>, this is Evgenii from Dawarich.
|
||||
|
||||
You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.
|
||||
You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data.
|
||||
|
||||
Here are some powerful features you might want to explore:
|
||||
|
||||
✈️ Reliving your travels
|
||||
Revisit your past journeys with detailed maps and insights.
|
||||
|
||||
📊 Statistics & Analytics
|
||||
View detailed insights about distances traveled and time spent in different locations.
|
||||
|
||||
|
|
|
|||
49
app/views/users_mailer/post_trial_reminder_early.html.erb
Normal file
49
app/views/users_mailer/post_trial_reminder_early.html.erb
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #2563eb; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background: #f9fafb; }
|
||||
.cta { background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
.reminder { background: #dbeafe; border: 1px solid #2563eb; padding: 15px; border-radius: 6px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚀 Still Interested in Dawarich?</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
|
||||
|
||||
<div class="reminder">
|
||||
<p><strong>Your Dawarich trial ended 2 days ago.</strong></p>
|
||||
</div>
|
||||
|
||||
<p>I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer!</p>
|
||||
|
||||
<p>Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off.</p>
|
||||
|
||||
<h3>🌟 What you're missing:</h3>
|
||||
<ul>
|
||||
<li>Real-time location tracking and analysis</li>
|
||||
<li>Beautiful, interactive maps with your travel history</li>
|
||||
<li>Detailed statistics and insights about your journeys</li>
|
||||
<li>Data export capabilities for your peace of mind</li>
|
||||
</ul>
|
||||
|
||||
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=post_trial_reminder_early&utm_content=subscribe_now" class="cta">Subscribe Now</a>
|
||||
|
||||
<p>Ready to unlock your location story? Subscribe today and continue your journey with Dawarich!</p>
|
||||
|
||||
<p>Questions? Just reply to this email – I'm here to help.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Evgenii from Dawarich</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
24
app/views/users_mailer/post_trial_reminder_early.text.erb
Normal file
24
app/views/users_mailer/post_trial_reminder_early.text.erb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
🚀 Still Interested in Dawarich?
|
||||
|
||||
Hi <%= @user.email %>,
|
||||
|
||||
Your Dawarich trial ended 2 days ago.
|
||||
|
||||
I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer!
|
||||
|
||||
Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off.
|
||||
|
||||
🌟 What you're missing:
|
||||
- Real-time location tracking and analysis
|
||||
- Beautiful, interactive maps with your travel history
|
||||
- Detailed statistics and insights about your journeys
|
||||
- Data export capabilities for your peace of mind
|
||||
|
||||
Subscribe now: https://my.dawarich.app
|
||||
|
||||
Ready to unlock your location story? Subscribe today and continue your journey with Dawarich!
|
||||
|
||||
Questions? Just reply to this email – I'm here to help.
|
||||
|
||||
Best regards,
|
||||
Evgenii from Dawarich
|
||||
51
app/views/users_mailer/post_trial_reminder_late.html.erb
Normal file
51
app/views/users_mailer/post_trial_reminder_late.html.erb
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #059669; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background: #f9fafb; }
|
||||
.cta { background: #059669; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
.waiting { background: #d1fae5; border: 1px solid #059669; padding: 15px; border-radius: 6px; margin: 20px 0; }
|
||||
.special { background: #fef3c7; border: 1px solid #f59e0b; padding: 15px; border-radius: 6px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📍 Your Location Data is Waiting</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
|
||||
|
||||
<div class="waiting">
|
||||
<p><strong>It's been a week since your Dawarich trial ended.</strong></p>
|
||||
</div>
|
||||
|
||||
<p>Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time.</p>
|
||||
|
||||
<h3>🗺️ Here's what's waiting for you:</h3>
|
||||
<ul>
|
||||
<li>All your location data, preserved and ready</li>
|
||||
<li>Reliving your travels through detailed maps and insights</li>
|
||||
<li>Privacy-first approach – your data stays yours</li>
|
||||
<li>Beautiful visualizations of your travel patterns</li>
|
||||
<li>Regular updates and new features</li>
|
||||
</ul>
|
||||
|
||||
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=post_trial_reminder_late&utm_content=special_offer" class="cta">Return to Dawarich</a>
|
||||
|
||||
<p>This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back.</p>
|
||||
|
||||
<p>Thank you for giving Dawarich a try. I hope to see you again soon!</p>
|
||||
|
||||
<p>Safe travels,<br>
|
||||
Evgenii from Dawarich</p>
|
||||
|
||||
<p><em>P.S. If you have any questions or need assistance, just hit reply – I'm here to help!</em></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
26
app/views/users_mailer/post_trial_reminder_late.text.erb
Normal file
26
app/views/users_mailer/post_trial_reminder_late.text.erb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
📍 Your Location Data is Waiting
|
||||
|
||||
Hi <%= @user.email %>, this is Evgenii from Dawarich.
|
||||
|
||||
It's been a week since your Dawarich trial ended.
|
||||
|
||||
Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time.
|
||||
|
||||
🗺️ Here's what's waiting for you:
|
||||
- All your location data, preserved and ready
|
||||
- Reliving your travels through detailed maps and insights
|
||||
- Privacy-first approach – your data stays yours
|
||||
- Beautiful visualizations of your travel patterns
|
||||
- Integration with popular location apps and services
|
||||
- Regular updates and new features
|
||||
|
||||
Return to Dawarich: https://my.dawarich.app
|
||||
|
||||
This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back.
|
||||
|
||||
Thank you for giving Dawarich a try. I hope to see you again soon!
|
||||
|
||||
Safe travels,
|
||||
Evgenii from Dawarich
|
||||
|
||||
P.S. If you have any questions or need assistance, just hit reply – I'm here to help!
|
||||
|
|
@ -17,13 +17,13 @@
|
|||
<h1>🔒 Your Trial Has Expired</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>,</p>
|
||||
<p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
|
||||
|
||||
<div class="expired">
|
||||
<p><strong>Your 7-day Dawarich trial has ended.</strong></p>
|
||||
</div>
|
||||
|
||||
<p>Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.</p>
|
||||
<p>Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week.</p>
|
||||
|
||||
<p>Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.</p>
|
||||
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<p>Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!</p>
|
||||
|
||||
<p>We'd love to have you back as a subscriber.</p>
|
||||
<p>I'd love to have you back as a subscriber.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Evgenii from Dawarich</p>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
🔒 Your Trial Has Expired
|
||||
|
||||
Hi <%= @user.email %>,
|
||||
Hi <%= @user.email %>, this is Evgenii from Dawarich.
|
||||
|
||||
Your 7-day Dawarich trial has ended.
|
||||
|
||||
Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.
|
||||
Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week.
|
||||
|
||||
Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ Subscribe to continue: https://my.dawarich.app
|
|||
|
||||
Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!
|
||||
|
||||
We'd love to have you back as a subscriber.
|
||||
I'd love to have you back as a subscriber.
|
||||
|
||||
Best regards,
|
||||
Evgenii from Dawarich
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@
|
|||
<h1>⏰ Your Trial Expires Soon</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>,</p>
|
||||
<p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
|
||||
|
||||
<div class="urgent">
|
||||
<p><strong>⚠️ Important:</strong> Your Dawarich trial expires in just <strong>2 days</strong>!</p>
|
||||
</div>
|
||||
|
||||
<p>We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.</p>
|
||||
<p>I hope you've enjoyed exploring your location data with Dawarich over the past 5 days.</p>
|
||||
|
||||
<p>To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.</p>
|
||||
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<p>Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!</p>
|
||||
|
||||
<p>Questions? Drop us a message at hi@dawarich.app or just reply to this email.</p>
|
||||
<p>Questions? Drop me a message at hi@dawarich.app or just reply to this email.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Evgenii from Dawarich</p>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
⏰ Your Trial Expires Soon
|
||||
|
||||
Hi <%= @user.email %>,
|
||||
Hi <%= @user.email %>, this is Evgenii from Dawarich.
|
||||
|
||||
⚠️ Important: Your Dawarich trial expires in just 2 days!
|
||||
|
||||
We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.
|
||||
I hope you've enjoyed exploring your location data with Dawarich over the past 5 days.
|
||||
|
||||
To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ Subscribe now: https://my.dawarich.app
|
|||
|
||||
Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!
|
||||
|
||||
Questions? Drop us a message at hi@dawarich.app
|
||||
Questions? Drop me a message at hi@dawarich.app or just reply to this email.
|
||||
|
||||
Best regards,
|
||||
Evgenii from Dawarich
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
<h1>Welcome to Dawarich!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi <%= @user.email %>,</p>
|
||||
<p>Hi <%= @user.email %>, this is Evgenii from Dawarich.</p>
|
||||
|
||||
<p>Welcome to Dawarich! We're excited to have you on board.</p>
|
||||
<p>Welcome to Dawarich! I'm excited to have you on board.</p>
|
||||
|
||||
<p>Your 7-day free trial has started. During this time, you can:</p>
|
||||
<ul>
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
|
||||
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=welcome&utm_content=start_exploring" class="cta">Start Exploring Dawarich</a>
|
||||
|
||||
<p>If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.</p>
|
||||
<p>If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email.</p>
|
||||
|
||||
<p>Happy tracking!<br>
|
||||
Evgenii from Dawarich</p>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
Welcome to Dawarich!
|
||||
|
||||
Hi <%= @user.email %>,
|
||||
Hi <%= @user.email %>, this is Evgenii from Dawarich.
|
||||
|
||||
Welcome to Dawarich! We're excited to have you on board.
|
||||
Welcome to Dawarich! I'm excited to have you on board.
|
||||
|
||||
Your 7-day free trial has started. During this time, you can:
|
||||
- Track your location data
|
||||
|
|
@ -12,7 +12,7 @@ Your 7-day free trial has started. During this time, you can:
|
|||
|
||||
Start exploring Dawarich: https://my.dawarich.app
|
||||
|
||||
If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.
|
||||
If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email.
|
||||
|
||||
Happy tracking!
|
||||
Evgenii from Dawarich
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@
|
|||
Rails.application.config.after_initialize do
|
||||
# Only run in server mode and ensure one-time execution with atomic write
|
||||
if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true)
|
||||
# Clear the cache
|
||||
Cache::CleaningJob.perform_later
|
||||
|
||||
# Preheat the cache
|
||||
Cache::PreheatingJob.perform_later
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddUserCountryCompositeIndexToPoints < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :points, %i[user_id country_name],
|
||||
algorithm: :concurrently,
|
||||
name: 'idx_points_user_country_name',
|
||||
if_not_exists: true
|
||||
end
|
||||
end
|
||||
|
|
@ -4,28 +4,153 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe BulkStatsCalculatingJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:user1) { create(:user) }
|
||||
let(:user2) { create(:user) }
|
||||
|
||||
let(:timestamp) { DateTime.new(2024, 1, 1).to_i }
|
||||
|
||||
let!(:points1) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: user1.id, timestamp: timestamp + i.minutes)
|
||||
context 'with active users' do
|
||||
let!(:active_user1) { create(:user, status: :active) }
|
||||
let!(:active_user2) { create(:user, status: :active) }
|
||||
|
||||
let!(:points1) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: active_user1.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:points2) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: active_user2.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
# Remove any leftover users from other tests, keeping only our test users
|
||||
User.where.not(id: [active_user1.id, active_user2.id]).destroy_all
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
||||
it 'processes all active users' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(active_user1.id)
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(active_user2.id)
|
||||
end
|
||||
|
||||
it 'calls Stats::BulkCalculator for each active user' do
|
||||
calculator1 = instance_double(Stats::BulkCalculator)
|
||||
calculator2 = instance_double(Stats::BulkCalculator)
|
||||
|
||||
allow(Stats::BulkCalculator).to receive(:new).with(active_user1.id).and_return(calculator1)
|
||||
allow(Stats::BulkCalculator).to receive(:new).with(active_user2.id).and_return(calculator2)
|
||||
allow(calculator1).to receive(:call)
|
||||
allow(calculator2).to receive(:call)
|
||||
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(calculator1).to have_received(:call)
|
||||
expect(calculator2).to have_received(:call)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:points2) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: user2.id, timestamp: timestamp + i.minutes)
|
||||
context 'with trial users' do
|
||||
let!(:trial_user1) { create(:user, status: :trial) }
|
||||
let!(:trial_user2) { create(:user, status: :trial) }
|
||||
|
||||
let!(:points1) do
|
||||
(1..5).map do |i|
|
||||
create(:point, user_id: trial_user1.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:points2) do
|
||||
(1..5).map do |i|
|
||||
create(:point, user_id: trial_user2.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
# Remove any leftover users from other tests, keeping only our test users
|
||||
User.where.not(id: [trial_user1.id, trial_user2.id]).destroy_all
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
||||
it 'processes all trial users' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(trial_user1.id)
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(trial_user2.id)
|
||||
end
|
||||
|
||||
it 'calls Stats::BulkCalculator for each trial user' do
|
||||
calculator1 = instance_double(Stats::BulkCalculator)
|
||||
calculator2 = instance_double(Stats::BulkCalculator)
|
||||
|
||||
allow(Stats::BulkCalculator).to receive(:new).with(trial_user1.id).and_return(calculator1)
|
||||
allow(Stats::BulkCalculator).to receive(:new).with(trial_user2.id).and_return(calculator2)
|
||||
allow(calculator1).to receive(:call)
|
||||
allow(calculator2).to receive(:call)
|
||||
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(calculator1).to have_received(:call)
|
||||
expect(calculator2).to have_received(:call)
|
||||
end
|
||||
end
|
||||
|
||||
it 'enqueues Stats::CalculatingJob for each user' do
|
||||
expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1)
|
||||
expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id, 2024, 1)
|
||||
context 'with inactive users only' do
|
||||
before do
|
||||
allow(User).to receive(:active).and_return(User.none)
|
||||
allow(User).to receive(:trial).and_return(User.none)
|
||||
allow(Stats::BulkCalculator).to receive(:new)
|
||||
end
|
||||
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
it 'does not process any users when no active or trial users exist' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'queries for active and trial users but finds none' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(User).to have_received(:active)
|
||||
expect(User).to have_received(:trial)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed user types' do
|
||||
let(:active_user) { create(:user, status: :active) }
|
||||
let(:trial_user) { create(:user, status: :trial) }
|
||||
let(:inactive_user) { create(:user, status: :inactive) }
|
||||
|
||||
before do
|
||||
active_users_relation = double('ActiveRecord::Relation')
|
||||
trial_users_relation = double('ActiveRecord::Relation')
|
||||
|
||||
allow(active_users_relation).to receive(:pluck).with(:id).and_return([active_user.id])
|
||||
allow(trial_users_relation).to receive(:pluck).with(:id).and_return([trial_user.id])
|
||||
|
||||
allow(User).to receive(:active).and_return(active_users_relation)
|
||||
allow(User).to receive(:trial).and_return(trial_users_relation)
|
||||
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
||||
it 'processes only active and trial users, skipping inactive users' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(active_user.id)
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(trial_user.id)
|
||||
expect(Stats::BulkCalculator).not_to have_received(:new).with(inactive_user.id)
|
||||
end
|
||||
|
||||
it 'processes exactly 2 users (active and trial)' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).to have_received(:new).exactly(2).times
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
92
spec/jobs/cache/preheating_job_spec.rb
vendored
Normal file
92
spec/jobs/cache/preheating_job_spec.rb
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Cache::PreheatingJob do
|
||||
before { Rails.cache.clear }
|
||||
|
||||
describe '#perform' do
|
||||
let!(:user1) { create(:user) }
|
||||
let!(:user2) { create(:user) }
|
||||
let!(:import1) { create(:import, user: user1) }
|
||||
let!(:import2) { create(:import, user: user2) }
|
||||
let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" }
|
||||
let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" }
|
||||
let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" }
|
||||
let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" }
|
||||
let(:user_1_countries_visited_key) { "dawarich/user_#{user1.id}_countries_visited" }
|
||||
let(:user_2_countries_visited_key) { "dawarich/user_#{user2.id}_countries_visited" }
|
||||
let(:user_1_cities_visited_key) { "dawarich/user_#{user1.id}_cities_visited" }
|
||||
let(:user_2_cities_visited_key) { "dawarich/user_#{user2.id}_cities_visited" }
|
||||
|
||||
before do
|
||||
create_list(:point, 3, user: user1, import: import1, reverse_geocoded_at: Time.current)
|
||||
create_list(:point, 2, user: user2, import: import2, reverse_geocoded_at: Time.current)
|
||||
end
|
||||
|
||||
it 'preheats years_tracked cache for all users' do
|
||||
# Clear cache before test to ensure clean state
|
||||
Rails.cache.clear
|
||||
|
||||
described_class.new.perform
|
||||
|
||||
# Verify that cache keys exist after job runs
|
||||
expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true
|
||||
expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true
|
||||
|
||||
# Verify the cached data is reasonable
|
||||
user1_years = Rails.cache.read(user_1_years_tracked_key)
|
||||
user2_years = Rails.cache.read(user_2_years_tracked_key)
|
||||
|
||||
expect(user1_years).to be_an(Array)
|
||||
expect(user2_years).to be_an(Array)
|
||||
end
|
||||
|
||||
it 'preheats points_geocoded_stats cache for all users' do
|
||||
# Clear cache before test to ensure clean state
|
||||
Rails.cache.clear
|
||||
|
||||
described_class.new.perform
|
||||
|
||||
# Verify that cache keys exist after job runs
|
||||
expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true
|
||||
expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true
|
||||
|
||||
# Verify the cached data has the expected structure
|
||||
user1_stats = Rails.cache.read(user_1_points_geocoded_stats_key)
|
||||
user2_stats = Rails.cache.read(user_2_points_geocoded_stats_key)
|
||||
|
||||
expect(user1_stats).to be_a(Hash)
|
||||
expect(user1_stats).to have_key(:geocoded)
|
||||
expect(user1_stats).to have_key(:without_data)
|
||||
expect(user1_stats[:geocoded]).to eq(3)
|
||||
|
||||
expect(user2_stats).to be_a(Hash)
|
||||
expect(user2_stats).to have_key(:geocoded)
|
||||
expect(user2_stats).to have_key(:without_data)
|
||||
expect(user2_stats[:geocoded]).to eq(2)
|
||||
end
|
||||
|
||||
it 'actually writes to cache' do
|
||||
described_class.new.perform
|
||||
|
||||
expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true
|
||||
expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true
|
||||
expect(Rails.cache.exist?(user_1_countries_visited_key)).to be true
|
||||
expect(Rails.cache.exist?(user_1_cities_visited_key)).to be true
|
||||
expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true
|
||||
expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true
|
||||
expect(Rails.cache.exist?(user_2_countries_visited_key)).to be true
|
||||
expect(Rails.cache.exist?(user_2_cities_visited_key)).to be true
|
||||
end
|
||||
|
||||
it 'handles users with no points gracefully' do
|
||||
user_no_points = create(:user)
|
||||
|
||||
expect { described_class.new.perform }.not_to raise_error
|
||||
|
||||
cached_stats = Rails.cache.read("dawarich/user_#{user_no_points.id}_points_geocoded_stats")
|
||||
expect(cached_stats).to eq({ geocoded: 0, without_data: 0 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -117,28 +117,4 @@ RSpec.describe Users::MailerSendingJob, type: :job do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#trial_related_email?' do
|
||||
subject { described_class.new }
|
||||
|
||||
it 'returns true for trial_expires_soon' do
|
||||
expect(subject.send(:trial_related_email?, 'trial_expires_soon')).to be true
|
||||
end
|
||||
|
||||
it 'returns true for trial_expired' do
|
||||
expect(subject.send(:trial_related_email?, 'trial_expired')).to be true
|
||||
end
|
||||
|
||||
it 'returns false for welcome' do
|
||||
expect(subject.send(:trial_related_email?, 'welcome')).to be false
|
||||
end
|
||||
|
||||
it 'returns false for explore_features' do
|
||||
expect(subject.send(:trial_related_email?, 'explore_features')).to be false
|
||||
end
|
||||
|
||||
it 'returns false for unknown email types' do
|
||||
expect(subject.send(:trial_related_email?, 'unknown_email')).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UsersMailer, type: :mailer do
|
||||
let(:user) { create(:user, email: 'test@example.com') }
|
||||
|
|
@ -9,43 +9,61 @@ RSpec.describe UsersMailer, type: :mailer do
|
|||
stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app'))
|
||||
end
|
||||
|
||||
describe "welcome" do
|
||||
describe 'welcome' do
|
||||
let(:mail) { UsersMailer.with(user: user).welcome }
|
||||
|
||||
it "renders the headers" do
|
||||
expect(mail.subject).to eq("Welcome to Dawarich!")
|
||||
expect(mail.to).to eq(["test@example.com"])
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('Welcome to Dawarich!')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
end
|
||||
|
||||
it "renders the body" do
|
||||
expect(mail.body.encoded).to match("test@example.com")
|
||||
it 'renders the body' do
|
||||
expect(mail.body.encoded).to match('test@example.com')
|
||||
end
|
||||
end
|
||||
|
||||
describe "explore_features" do
|
||||
describe 'explore_features' do
|
||||
let(:mail) { UsersMailer.with(user: user).explore_features }
|
||||
|
||||
it "renders the headers" do
|
||||
expect(mail.subject).to eq("Explore Dawarich features!")
|
||||
expect(mail.to).to eq(["test@example.com"])
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('Explore Dawarich features!')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
end
|
||||
end
|
||||
|
||||
describe "trial_expires_soon" do
|
||||
describe 'trial_expires_soon' do
|
||||
let(:mail) { UsersMailer.with(user: user).trial_expires_soon }
|
||||
|
||||
it "renders the headers" do
|
||||
expect(mail.subject).to eq("⚠️ Your Dawarich trial expires in 2 days")
|
||||
expect(mail.to).to eq(["test@example.com"])
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('⚠️ Your Dawarich trial expires in 2 days')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
end
|
||||
end
|
||||
|
||||
describe "trial_expired" do
|
||||
describe 'trial_expired' do
|
||||
let(:mail) { UsersMailer.with(user: user).trial_expired }
|
||||
|
||||
it "renders the headers" do
|
||||
expect(mail.subject).to eq("💔 Your Dawarich trial expired")
|
||||
expect(mail.to).to eq(["test@example.com"])
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('💔 Your Dawarich trial expired')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'post_trial_reminder_early' do
|
||||
let(:mail) { UsersMailer.with(user: user).post_trial_reminder_early }
|
||||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('🚀 Still interested in Dawarich? Subscribe now!')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'post_trial_reminder_late' do
|
||||
let(:mail) { UsersMailer.with(user: user).post_trial_reminder_late }
|
||||
|
||||
it 'renders the headers' do
|
||||
expect(mail.subject).to eq('📍 Your location data is waiting - Subscribe to Dawarich')
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe StatsQuery do
|
||||
before { Rails.cache.clear }
|
||||
|
||||
describe '#points_stats' do
|
||||
subject(:points_stats) { described_class.new(user).points_stats }
|
||||
|
||||
|
|
@ -11,11 +13,13 @@ RSpec.describe StatsQuery do
|
|||
|
||||
context 'when user has no points' do
|
||||
it 'returns zero counts for all statistics' do
|
||||
expect(points_stats).to eq({
|
||||
total: 0,
|
||||
geocoded: 0,
|
||||
without_data: 0
|
||||
})
|
||||
expect(points_stats).to eq(
|
||||
{
|
||||
total: 0,
|
||||
geocoded: 0,
|
||||
without_data: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -45,11 +49,13 @@ RSpec.describe StatsQuery do
|
|||
end
|
||||
|
||||
it 'returns correct counts for all statistics' do
|
||||
expect(points_stats).to eq({
|
||||
total: 3,
|
||||
geocoded: 2,
|
||||
without_data: 1
|
||||
})
|
||||
expect(points_stats).to eq(
|
||||
{
|
||||
total: 3,
|
||||
geocoded: 2,
|
||||
without_data: 1
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
context 'when another user has points' do
|
||||
|
|
@ -64,11 +70,13 @@ RSpec.describe StatsQuery do
|
|||
end
|
||||
|
||||
it 'only counts points for the specified user' do
|
||||
expect(points_stats).to eq({
|
||||
total: 3,
|
||||
geocoded: 2,
|
||||
without_data: 1
|
||||
})
|
||||
expect(points_stats).to eq(
|
||||
{
|
||||
total: 3,
|
||||
geocoded: 2,
|
||||
without_data: 1
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -83,11 +91,13 @@ RSpec.describe StatsQuery do
|
|||
end
|
||||
|
||||
it 'returns correct statistics' do
|
||||
expect(points_stats).to eq({
|
||||
total: 5,
|
||||
geocoded: 5,
|
||||
without_data: 0
|
||||
})
|
||||
expect(points_stats).to eq(
|
||||
{
|
||||
total: 5,
|
||||
geocoded: 5,
|
||||
without_data: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -101,11 +111,13 @@ RSpec.describe StatsQuery do
|
|||
end
|
||||
|
||||
it 'returns correct statistics' do
|
||||
expect(points_stats).to eq({
|
||||
total: 3,
|
||||
geocoded: 3,
|
||||
without_data: 3
|
||||
})
|
||||
expect(points_stats).to eq(
|
||||
{
|
||||
total: 3,
|
||||
geocoded: 3,
|
||||
without_data: 3
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -119,12 +131,55 @@ RSpec.describe StatsQuery do
|
|||
end
|
||||
|
||||
it 'returns correct statistics' do
|
||||
expect(points_stats).to eq({
|
||||
total: 4,
|
||||
geocoded: 0,
|
||||
without_data: 0
|
||||
})
|
||||
expect(points_stats).to eq(
|
||||
{
|
||||
total: 4,
|
||||
geocoded: 0,
|
||||
without_data: 0
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'caching behavior' do
|
||||
let!(:points) do
|
||||
create_list(:point, 2,
|
||||
user: user,
|
||||
import: import,
|
||||
reverse_geocoded_at: Time.current,
|
||||
geodata: { 'address' => 'Test Address' })
|
||||
end
|
||||
|
||||
it 'caches the geocoded stats' do
|
||||
expect(Rails.cache).to receive(:fetch).with(
|
||||
"dawarich/user_#{user.id}_points_geocoded_stats",
|
||||
expires_in: 1.day
|
||||
).and_call_original
|
||||
|
||||
points_stats
|
||||
end
|
||||
|
||||
it 'returns cached results on subsequent calls' do
|
||||
# First call - should hit database and cache
|
||||
expect(Point.connection).to receive(:select_one).once.and_call_original
|
||||
first_result = points_stats
|
||||
|
||||
# Second call - should use cache, not hit database
|
||||
expect(Point.connection).not_to receive(:select_one)
|
||||
second_result = points_stats
|
||||
|
||||
expect(first_result).to eq(second_result)
|
||||
end
|
||||
|
||||
it 'uses counter cache for total count' do
|
||||
# Ensure counter cache is set correctly
|
||||
user.reload
|
||||
expect(user.points_count).to eq(2)
|
||||
|
||||
# The total should come from counter cache, not from SQL
|
||||
result = points_stats
|
||||
expect(result[:total]).to eq(user.points_count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ RSpec.describe 'Api::V1::Subscriptions', type: :request do
|
|||
JWT.encode({ user_id: 'invalid', status: nil }, jwt_secret, 'HS256')
|
||||
end
|
||||
|
||||
it 'returns unprocessable_entity error with invalid data message' do
|
||||
it 'returns unprocessable_content error with invalid data message' do
|
||||
allow(Subscription::DecodeJwtToken).to receive(:new).with(token)
|
||||
.and_raise(ArgumentError.new('Invalid token data'))
|
||||
|
||||
|
|
|
|||
90
spec/services/cache/clean_spec.rb
vendored
Normal file
90
spec/services/cache/clean_spec.rb
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Cache::Clean do
|
||||
before { Rails.cache.clear }
|
||||
|
||||
describe '.call' do
|
||||
let!(:user1) { create(:user) }
|
||||
let!(:user2) { create(:user) }
|
||||
let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" }
|
||||
let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" }
|
||||
let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" }
|
||||
let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" }
|
||||
|
||||
before do
|
||||
# Set up cache entries that should be cleaned
|
||||
Rails.cache.write('cache_jobs_scheduled', true)
|
||||
Rails.cache.write(CheckAppVersion::VERSION_CACHE_KEY, '1.0.0')
|
||||
Rails.cache.write(user_1_years_tracked_key, { 2023 => %w[Jan Feb] })
|
||||
Rails.cache.write(user_2_years_tracked_key, { 2023 => %w[Mar Apr] })
|
||||
Rails.cache.write(user_1_points_geocoded_stats_key, { geocoded: 5, without_data: 2 })
|
||||
Rails.cache.write(user_2_points_geocoded_stats_key, { geocoded: 3, without_data: 1 })
|
||||
end
|
||||
|
||||
it 'deletes control flag cache' do
|
||||
expect(Rails.cache.exist?('cache_jobs_scheduled')).to be true
|
||||
|
||||
described_class.call
|
||||
|
||||
expect(Rails.cache.exist?('cache_jobs_scheduled')).to be false
|
||||
end
|
||||
|
||||
it 'deletes version cache' do
|
||||
expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be true
|
||||
|
||||
described_class.call
|
||||
|
||||
expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be false
|
||||
end
|
||||
|
||||
it 'deletes years tracked cache for all users' do
|
||||
expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true
|
||||
expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true
|
||||
|
||||
described_class.call
|
||||
|
||||
expect(Rails.cache.exist?(user_1_years_tracked_key)).to be false
|
||||
expect(Rails.cache.exist?(user_2_years_tracked_key)).to be false
|
||||
end
|
||||
|
||||
it 'deletes points geocoded stats cache for all users' do
|
||||
expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true
|
||||
expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true
|
||||
|
||||
described_class.call
|
||||
|
||||
expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be false
|
||||
expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be false
|
||||
end
|
||||
|
||||
it 'logs cache cleaning process' do
|
||||
expect(Rails.logger).to receive(:info).with('Cleaning cache...')
|
||||
expect(Rails.logger).to receive(:info).with('Cache cleaned')
|
||||
|
||||
described_class.call
|
||||
end
|
||||
|
||||
it 'handles users being added during execution gracefully' do
|
||||
# Create a user that will be found during the cleaning process
|
||||
user3 = nil
|
||||
|
||||
allow(User).to receive(:find_each).and_yield(user1).and_yield(user2) do |&block|
|
||||
# Create a new user while iterating - this should not cause errors
|
||||
user3 = create(:user)
|
||||
Rails.cache.write("dawarich/user_#{user3.id}_years_tracked", { 2023 => ['May'] })
|
||||
Rails.cache.write("dawarich/user_#{user3.id}_points_geocoded_stats", { geocoded: 1, without_data: 0 })
|
||||
|
||||
# Continue with the original block
|
||||
[user1, user2].each(&block)
|
||||
end
|
||||
|
||||
expect { described_class.call }.not_to raise_error
|
||||
|
||||
# The new user's cache should still exist since it wasn't processed
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user3.id}_years_tracked")).to be true
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user3.id}_points_geocoded_stats")).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue