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' %> diff --git a/app/views/map/_onboarding_modal.html.erb b/app/views/map/_onboarding_modal.html.erb new file mode 100644 index 00000000..c6445fd6 --- /dev/null +++ b/app/views/map/_onboarding_modal.html.erb @@ -0,0 +1,16 @@ + + + diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 5140faf5..f29463b7 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -20,7 +20,9 @@ <% if user_signed_in? && current_user.can_subscribe? %> -
  • <%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %>
  • +
  • + <%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %> +
  • <% end %> @@ -70,9 +72,18 @@