From 71488c9fb1d0c8ceaa64ec7fab0456eb2475d194 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 13 Aug 2025 20:25:48 +0200
Subject: [PATCH] Add trial mode
---
CHANGELOG.md | 1 +
Gemfile | 3 +-
Gemfile.lock | 6 +
app/helpers/application_helper.rb | 27 +-
app/helpers/user_helper.rb | 17 +
app/jobs/users/mailer_sending_job.rb | 13 +
app/jobs/users/trial_webhook_job.rb | 22 ++
app/mailers/users_mailer.rb | 27 ++
app/models/user.rb | 23 +-
app/services/subscription/encode_jwt_token.rb | 16 +
.../devise/registrations/_api_key.html.erb | 9 +-
app/views/devise/registrations/edit.html.erb | 8 +-
app/views/layouts/application.html.erb | 2 +
app/views/map/_onboarding_modal.html.erb | 16 +
app/views/shared/_navbar.html.erb | 30 +-
.../users_mailer/explore_features.html.erb | 55 +++
.../users_mailer/explore_features.text.erb | 26 ++
app/views/users_mailer/trial_expired.html.erb | 50 +++
app/views/users_mailer/trial_expired.text.erb | 25 ++
.../users_mailer/trial_expires_soon.html.erb | 50 +++
.../users_mailer/trial_expires_soon.text.erb | 25 ++
app/views/users_mailer/welcome.html.erb | 40 ++
app/views/users_mailer/welcome.text.erb | 18 +
config/sidekiq.yml | 1 +
spec/factories/users.rb | 5 +
spec/fixtures/users/welcome | 3 +
spec/jobs/users/mailer_sending_job_spec.rb | 75 ++++
spec/jobs/users/trial_webhook_job_spec.rb | 54 +++
spec/mailers/previews/users_mailer_preview.rb | 9 +
spec/mailers/users_mailer_spec.rb | 49 +++
spec/models/user_spec.rb | 104 +++++-
.../subscription/encode_jwt_token_spec.rb | 30 ++
tests/system/test_scenarios.md | 352 ------------------
trials_feature_checklist.md | 28 ++
34 files changed, 848 insertions(+), 371 deletions(-)
create mode 100644 app/helpers/user_helper.rb
create mode 100644 app/jobs/users/mailer_sending_job.rb
create mode 100644 app/jobs/users/trial_webhook_job.rb
create mode 100644 app/mailers/users_mailer.rb
create mode 100644 app/services/subscription/encode_jwt_token.rb
create mode 100644 app/views/map/_onboarding_modal.html.erb
create mode 100644 app/views/users_mailer/explore_features.html.erb
create mode 100644 app/views/users_mailer/explore_features.text.erb
create mode 100644 app/views/users_mailer/trial_expired.html.erb
create mode 100644 app/views/users_mailer/trial_expired.text.erb
create mode 100644 app/views/users_mailer/trial_expires_soon.html.erb
create mode 100644 app/views/users_mailer/trial_expires_soon.text.erb
create mode 100644 app/views/users_mailer/welcome.html.erb
create mode 100644 app/views/users_mailer/welcome.text.erb
create mode 100644 spec/fixtures/users/welcome
create mode 100644 spec/jobs/users/mailer_sending_job_spec.rb
create mode 100644 spec/jobs/users/trial_webhook_job_spec.rb
create mode 100644 spec/mailers/previews/users_mailer_preview.rb
create mode 100644 spec/mailers/users_mailer_spec.rb
create mode 100644 spec/services/subscription/encode_jwt_token_spec.rb
delete mode 100644 tests/system/test_scenarios.md
create mode 100644 trials_feature_checklist.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62a6aa37..1f721b8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- X-Dawarich-Response and X-Dawarich-Version headers are now returned for all API responses.
+
# [0.30.8] - 2025-08-01
## Fixed
diff --git a/Gemfile b/Gemfile
index 614a2e95..c7145245 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,6 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby File.read('.ruby-version').strip
+gem 'activerecord-postgis-adapter'
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
gem 'aws-sdk-s3', '~> 1.177.0', require: false
gem 'aws-sdk-core', '~> 3.215.1', require: false
@@ -24,7 +25,7 @@ gem 'oj'
gem 'parallel'
gem 'pg'
gem 'prometheus_exporter'
-gem 'activerecord-postgis-adapter'
+gem 'rqrcode', '~> 3.0'
gem 'puma'
gem 'pundit'
gem 'rails', '~> 8.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index 4b955b5a..08c2c161 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -127,6 +127,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chartkick (5.2.0)
+ chunky_png (1.4.0)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
@@ -365,6 +366,10 @@ GEM
rgeo-geojson (2.2.0)
multi_json (~> 1.15)
rgeo (>= 1.0.0)
+ rqrcode (3.1.0)
+ chunky_png (~> 1.0)
+ rqrcode_core (~> 2.0)
+ rqrcode_core (2.0.0)
rspec-core (3.13.3)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.4)
@@ -553,6 +558,7 @@ DEPENDENCIES
rgeo
rgeo-activerecord
rgeo-geojson
+ rqrcode (~> 3.0)
rspec-rails
rswag-api
rswag-specs
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index dfd93042..5fdcd917 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -48,11 +48,11 @@ module ApplicationHelper
grouped_by_country[country] ||= []
- if toponym['cities'].present?
- toponym['cities'].each do |city_data|
- city = city_data['city']
- grouped_by_country[country] << city if city.present?
- end
+ 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
@@ -172,4 +172,21 @@ module ApplicationHelper
data: { tip: "Expires on #{active_until.iso8601}" }
)
end
+
+ def onboarding_modal_showable?(user)
+ user.trial_state?
+ end
+
+ def trial_button_class(user)
+ case (user.active_until.to_date - Time.current.to_date).to_i
+ when 5..8
+ 'btn-info'
+ when 2...5
+ 'btn-warning'
+ when 0...2
+ 'btn-error'
+ else
+ 'btn-success'
+ end
+ end
end
diff --git a/app/helpers/user_helper.rb b/app/helpers/user_helper.rb
new file mode 100644
index 00000000..b28f55b9
--- /dev/null
+++ b/app/helpers/user_helper.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module UserHelper
+ def api_key_qr_code(user)
+ qrcode = RQRCode::QRCode.new(user.api_key)
+ svg = qrcode.as_svg(
+ color: "000",
+ fill: "fff",
+ shape_rendering: "crispEdges",
+ module_size: 11,
+ standalone: true,
+ use_path: true,
+ offset: 5
+ )
+ svg.html_safe
+ end
+end
diff --git a/app/jobs/users/mailer_sending_job.rb b/app/jobs/users/mailer_sending_job.rb
new file mode 100644
index 00000000..4b7db707
--- /dev/null
+++ b/app/jobs/users/mailer_sending_job.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Users::MailerSendingJob < ApplicationJob
+ queue_as :mailers
+
+ def perform(user_id, email_type, **options)
+ user = User.find(user_id)
+
+ params = { user: user }.merge(options)
+
+ UsersMailer.with(params).public_send(email_type).deliver_later
+ end
+end
diff --git a/app/jobs/users/trial_webhook_job.rb b/app/jobs/users/trial_webhook_job.rb
new file mode 100644
index 00000000..d908d8c5
--- /dev/null
+++ b/app/jobs/users/trial_webhook_job.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class Users::TrialWebhookJob < ApplicationJob
+ queue_as :default
+
+ def perform(user_id)
+ user = User.find(user_id)
+
+ token = Subscription::EncodeJwtToken.new(
+ { user_id: user.id, email: user.email, action: 'create_user' },
+ ENV['JWT_SECRET_KEY']
+ ).call
+
+ request_url = "#{ENV['MANAGER_URL']}/api/v1/users"
+ headers = {
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json'
+ }
+
+ HTTParty.post(request_url, headers: headers, body: { token: token }.to_json)
+ end
+end
diff --git a/app/mailers/users_mailer.rb b/app/mailers/users_mailer.rb
new file mode 100644
index 00000000..111a4247
--- /dev/null
+++ b/app/mailers/users_mailer.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class UsersMailer < ApplicationMailer
+ def welcome
+ @user = params[:user]
+
+ mail(to: @user.email, subject: 'Welcome to Dawarich')
+ end
+
+ def explore_features
+ @user = params[:user]
+
+ mail(to: @user.email, subject: 'Explore Dawarich features')
+ end
+
+ def trial_expires_soon
+ @user = params[:user]
+
+ mail(to: @user.email, subject: 'Your Dawarich trial expires in 2 days')
+ end
+
+ def trial_expired
+ @user = params[:user]
+
+ mail(to: @user.email, subject: 'Your Dawarich trial expired')
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 4c61d98e..36b1633f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class User < ApplicationRecord
+class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable
@@ -18,6 +18,8 @@ class User < ApplicationRecord
after_create :create_api_key
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
+ after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? }
+ after_commit :schedule_welcome_emails, on: :create, if: -> { !DawarichSettings.self_hosted? }
before_save :sanitize_input
validates :email, presence: true
@@ -26,7 +28,7 @@ class User < ApplicationRecord
attribute :admin, :boolean, default: false
- enum :status, { inactive: 0, active: 1 }
+ enum :status, { inactive: 0, active: 1, trial: 3 }
def safe_settings
Users::SafeSettings.new(settings)
@@ -115,6 +117,10 @@ class User < ApplicationRecord
Users::ExportDataJob.perform_later(id)
end
+ def trial_state?
+ tracked_points.none? && trial?
+ end
+
private
def create_api_key
@@ -133,4 +139,17 @@ class User < ApplicationRecord
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
settings.try(:[], 'maps')&.try(:[], 'url')&.strip!
end
+
+ def start_trial
+ update(status: :trial, active_until: 7.days.from_now)
+
+ Users::TrialWebhookJob.perform_later(id)
+ end
+
+ def schedule_welcome_emails
+ Users::MailerSendingJob.perform_later(id, 'welcome')
+ 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')
+ end
end
diff --git a/app/services/subscription/encode_jwt_token.rb b/app/services/subscription/encode_jwt_token.rb
new file mode 100644
index 00000000..77c9e898
--- /dev/null
+++ b/app/services/subscription/encode_jwt_token.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class Subscription::EncodeJwtToken
+ def initialize(payload, secret_key)
+ @payload = payload
+ @secret_key = secret_key
+ end
+
+ def call
+ JWT.encode(
+ @payload,
+ @secret_key,
+ 'HS256'
+ )
+ end
+end
diff --git a/app/views/devise/registrations/_api_key.html.erb b/app/views/devise/registrations/_api_key.html.erb
index c04b7b85..a1230a2e 100644
--- a/app/views/devise/registrations/_api_key.html.erb
+++ b/app/views/devise/registrations/_api_key.html.erb
@@ -1,6 +1,14 @@
Use this API key to authenticate your requests.
<%= current_user.api_key %>
+
+ <%# if ENV['QR_CODE_ENABLED'] == 'true' %>
+
+ Or you can scan it in your Dawarich iOS app:
+ <%= api_key_qr_code(current_user) %>
+
+ <%# end %>
+
Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %>
@@ -20,7 +28,6 @@
OR
Overland
<%= api_v1_overland_batches_url(api_key: current_user.api_key) %>
-
<%= link_to "Generate new API key", generate_api_key_path, data: { confirm: "Are you sure? This will invalidate the current API key.", turbo_confirm: "Are you sure?", turbo_method: :post }, class: 'btn btn-primary' %>
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb
index 5fb84f95..23be077a 100644
--- a/app/views/devise/registrations/edit.html.erb
+++ b/app/views/devise/registrations/edit.html.erb
@@ -4,7 +4,13 @@
Edit your account!
- <%= render 'devise/registrations/api_key' %>
+ <% if current_user.active? %>
+ <%= render 'devise/registrations/api_key' %>
+ <% else %>
+
+ <%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success glass' %> to access your API key and start tracking your location.
+
+ <% end %>
<% if !DawarichSettings.self_hosted? %>
<%= render 'devise/registrations/points_usage' %>
<% end %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index e7b97017..1036f84d 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -31,5 +31,7 @@
<%= render SELF_HOSTED ? 'shared/footer' : 'shared/legal_footer' %>
+
+ <%= render 'map/onboarding_modal' %>