diff --git a/.app_version b/.app_version index 473f1fb3..bca57db5 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.30.8 +0.30.10 diff --git a/CHANGELOG.md b/CHANGELOG.md index e505d48b..e8741ecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -# [0.30.9] - 2025-08-10 + +# [0.30.10] - 2025-08-19 + ## Added @@ -13,6 +15,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - [ ] Add tracker_id index to points table + +# [0.30.9] - 2025-08-19 + +## Changed + +- Countries, visited during a trip, are now being calculated from points to improve performance. + +## Added + +- QR code for API key is implemented but hidden under feature flag until the iOS app supports it. +- X-Dawarich-Response and X-Dawarich-Version headers are now returned for all API responses. +- Trial version for cloud users is now available. + + + # [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..74af4a35 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,29 +10,29 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + actioncable (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actionmailbox (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) mail (>= 2.8.0) - actionmailer (8.0.2) - actionpack (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activesupport (= 8.0.2) + actionmailer (8.0.2.1) + actionpack (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activesupport (= 8.0.2.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2) - actionview (= 8.0.2) - activesupport (= 8.0.2) + actionpack (8.0.2.1) + actionview (= 8.0.2.1) + activesupport (= 8.0.2.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -40,38 +40,38 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2) - actionpack (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actiontext (8.0.2.1) + actionpack (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2) - activesupport (= 8.0.2) + actionview (8.0.2.1) + activesupport (= 8.0.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.2) - activesupport (= 8.0.2) + activejob (8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.3.6) - activemodel (8.0.2) - activesupport (= 8.0.2) - activerecord (8.0.2) - activemodel (= 8.0.2) - activesupport (= 8.0.2) + activemodel (8.0.2.1) + activesupport (= 8.0.2.1) + activerecord (8.0.2.1) + activemodel (= 8.0.2.1) + activesupport (= 8.0.2.1) timeout (>= 0.4.0) activerecord-postgis-adapter (11.0.0) activerecord (~> 8.0.0) rgeo-activerecord (~> 8.0.0) - activestorage (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activesupport (= 8.0.2) + activestorage (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activesupport (= 8.0.2.1) marcel (~> 1.0) - activesupport (8.0.2) + activesupport (8.0.2.1) base64 benchmark (>= 0.3) bigdecimal @@ -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) @@ -297,7 +298,7 @@ GEM date stringio public_suffix (6.0.1) - puma (6.6.0) + puma (6.6.1) nio4r (~> 2.0) pundit (2.5.0) activesupport (>= 3.0.0) @@ -311,20 +312,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.2) - actioncable (= 8.0.2) - actionmailbox (= 8.0.2) - actionmailer (= 8.0.2) - actionpack (= 8.0.2) - actiontext (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activemodel (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + rails (8.0.2.1) + actioncable (= 8.0.2.1) + actionmailbox (= 8.0.2.1) + actionmailer (= 8.0.2.1) + actionpack (= 8.0.2.1) + actiontext (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activemodel (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) bundler (>= 1.15.0) - railties (= 8.0.2) + railties (= 8.0.2.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -332,9 +333,9 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + railties (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -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/README.md b/README.md index 789bd889..8ee904bf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🌍 Dawarich: Your Self-Hosted Location History Tracker +# 🌍 Dawarich: Your Self-Hostable Location History Tracker [![Discord](https://dcbadge.limes.pink/api/server/pHsBjpt5J8)](https://discord.gg/pHsBjpt5J8) | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/H2H3IDYDD) | [![Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3Dfreika%26type%3Dpatrons&style=for-the-badge)](https://www.patreon.com/freika) @@ -21,9 +21,14 @@ ## πŸ—ΊοΈ About Dawarich -**Dawarich** is a self-hosted web app designed to replace Google Timeline (aka Google Location History). It enables you to: +If you're looking for Dawarich Cloud, where everything is managed for you, check out [Dawarich Cloud](https://dawarich.app). + +**Dawarich** is a self-hostable web app designed to replace Google Timeline (aka Google Location History). +It enables you to: + +- Track your location history. - Visualize your data on an interactive map. -- Import your location history from Google Maps Timeline and Owntracks. +- Import your location history from Google Maps Timeline, OwnTracks, GPX, GeoJSON and some other sources - Explore statistics like the number of countries and cities visited, total distance traveled, and more! πŸ“„ **Changelog**: Find the latest updates [here](CHANGELOG.md). @@ -62,7 +67,7 @@ Simply install one of the supported apps on your device and configure it to send 1. Clone the repository. 2. Run the following command to start the app: ```bash - docker-compose up + docker-compose -f docker/docker-compose.yml up ``` 3. Access the app at `http://localhost:3000`. diff --git a/app/controllers/api/v1/health_controller.rb b/app/controllers/api/v1/health_controller.rb index 8e13d165..1e5ab2f1 100644 --- a/app/controllers/api/v1/health_controller.rb +++ b/app/controllers/api/v1/health_controller.rb @@ -4,14 +4,6 @@ class Api::V1::HealthController < ApiController skip_before_action :authenticate_api_key def index - if current_api_user - response.set_header('X-Dawarich-Response', 'Hey, I\'m alive and authenticated!') - else - response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!') - end - - response.set_header('X-Dawarich-Version', APP_VERSION) - render json: { status: 'ok' } end end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 4d13bdaf..d53f57ae 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -2,10 +2,18 @@ class ApiController < ApplicationController skip_before_action :verify_authenticity_token + before_action :set_version_header before_action :authenticate_api_key private + def set_version_header + message = "Hey, I\'m alive#{current_api_user ? ' and authenticated' : ''}!" + + response.set_header('X-Dawarich-Response', message) + response.set_header('X-Dawarich-Version', APP_VERSION) + end + def authenticate_api_key return head :unauthorized unless current_api_user diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index 4bff870e..710f9b60 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -59,11 +59,11 @@ class StatsController < ApplicationController @stats.each do |year, stats| stats_by_month = stats.index_by(&:month) - + year_distances[year] = (1..12).map do |month| month_name = Date::MONTHNAMES[month] distance = stats_by_month[month]&.distance || 0 - + [month_name, distance] end end 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/javascript/controllers/direct_upload_controller.js b/app/javascript/controllers/direct_upload_controller.js index 5be5b921..cc58436e 100644 --- a/app/javascript/controllers/direct_upload_controller.js +++ b/app/javascript/controllers/direct_upload_controller.js @@ -5,7 +5,8 @@ import { showFlashMessage } from "../maps/helpers" export default class extends Controller { static targets = ["input", "progress", "progressBar", "submit", "form"] static values = { - url: String + url: String, + userTrial: Boolean } connect() { @@ -50,6 +51,22 @@ export default class extends Controller { const files = this.inputTarget.files if (files.length === 0) return + // Check file size limits for trial users + if (this.userTrialValue) { + const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes + const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE) + + if (oversizedFiles.length > 0) { + const fileNames = oversizedFiles.map(f => f.name).join(', ') + const message = `File size limit exceeded. Trial users can only upload files up to 10MB. Oversized files: ${fileNames}` + showFlashMessage('error', message) + + // Clear the file input + this.inputTarget.value = '' + return + } + } + console.log(`Uploading ${files.length} files`) this.isUploading = true diff --git a/app/javascript/controllers/onboarding_modal_controller.js b/app/javascript/controllers/onboarding_modal_controller.js new file mode 100644 index 00000000..5a20e1c2 --- /dev/null +++ b/app/javascript/controllers/onboarding_modal_controller.js @@ -0,0 +1,42 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["modal"] + static values = { showable: Boolean } + + connect() { + if (this.showableValue) { + // Listen for Turbo page load events to show modal after navigation completes + document.addEventListener('turbo:load', this.handleTurboLoad.bind(this)) + } + } + + disconnect() { + // Clean up event listener when controller is removed + document.removeEventListener('turbo:load', this.handleTurboLoad.bind(this)) + } + + handleTurboLoad() { + if (this.showableValue) { + this.checkAndShowModal() + } + } + + checkAndShowModal() { + const MODAL_STORAGE_KEY = 'dawarich_onboarding_shown' + const hasShownModal = localStorage.getItem(MODAL_STORAGE_KEY) + + if (!hasShownModal && this.hasModalTarget) { + // Show the modal + this.modalTarget.showModal() + + // Mark as shown in local storage + localStorage.setItem(MODAL_STORAGE_KEY, 'true') + + // Add event listener to handle when modal is closed + this.modalTarget.addEventListener('close', () => { + // Modal closed - state already saved + }) + } + } +} diff --git a/app/jobs/users/mailer_sending_job.rb b/app/jobs/users/mailer_sending_job.rb new file mode 100644 index 00000000..bbce993f --- /dev/null +++ b/app/jobs/users/mailer_sending_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Users::MailerSendingJob < ApplicationJob + queue_as :mailers + + 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" + return + end + + params = { user: user }.merge(options) + + UsersMailer.with(params).public_send(email_type).deliver_later + end + + private + + def trial_related_email?(email_type) + %w[trial_expires_soon trial_expired].include?(email_type.to_s) + 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..512dd075 --- /dev/null +++ b/app/jobs/users/trial_webhook_job.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Users::TrialWebhookJob < ApplicationJob + queue_as :default + + def perform(user_id) + user = User.find(user_id) + + payload = { + user_id: user.id, + email: user.email, + active_until: user.active_until, + status: user.status, + action: 'create_user' + } + + token = Subscription::EncodeJwtToken.new(payload, 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..c7293a75 --- /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/concerns/calculateable.rb b/app/models/concerns/calculateable.rb index 31e4ff53..12caeac2 100644 --- a/app/models/concerns/calculateable.rb +++ b/app/models/concerns/calculateable.rb @@ -10,6 +10,7 @@ module Calculateable def calculate_distance calculated_distance_meters = calculate_distance_from_coordinates + self.distance = convert_distance_for_storage(calculated_distance_meters) end diff --git a/app/models/import.rb b/app/models/import.rb index d22d5174..74024798 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -13,6 +13,7 @@ class Import < ApplicationRecord after_commit :remove_attached_file, on: :destroy validates :name, presence: true, uniqueness: { scope: :user_id } + validate :file_size_within_limit, if: -> { user.trial? } enum :status, { created: 0, processing: 1, completed: 2, failed: 3 } @@ -58,4 +59,12 @@ class Import < ApplicationRecord def remove_attached_file file.purge_later end + + def file_size_within_limit + return unless file.attached? + + if file.blob.byte_size > 11.megabytes + errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.') + end + end end diff --git a/app/models/trip.rb b/app/models/trip.rb index 7ba14ad5..e409a47b 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -21,12 +21,6 @@ class Trip < ApplicationRecord user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp) end - def countries - return points.pluck(:country).uniq.compact if DawarichSettings.store_geodata? - - visited_countries - end - def photo_previews @photo_previews ||= select_dominant_orientation(photos).sample(12) end @@ -35,13 +29,8 @@ class Trip < ApplicationRecord @photo_sources ||= photos.map { _1[:source] }.uniq end - - def calculate_countries - countries = - Country.where(id: points.pluck(:country_id).compact.uniq).pluck(:name) - - self.visited_countries = countries + self.visited_countries = points.pluck(:country_name).uniq.compact end private diff --git a/app/models/user.rb b/app/models/user.rb index 13c5cee6..accfc486 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 @@ -19,6 +19,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 @@ -27,7 +29,7 @@ class User < ApplicationRecord attribute :admin, :boolean, default: false - enum :status, { inactive: 0, active: 1 } + enum :status, { inactive: 0, active: 1, trial: 2 } def safe_settings Users::SafeSettings.new(settings) @@ -97,7 +99,7 @@ class User < ApplicationRecord end def can_subscribe? - (active_until.nil? || active_until&.past?) && !DawarichSettings.self_hosted? + (trial? || !active_until&.future?) && !DawarichSettings.self_hosted? end def generate_subscription_token @@ -116,6 +118,10 @@ class User < ApplicationRecord Users::ExportDataJob.perform_later(id) end + def trial_state? + tracked_points.none? && trial? + end + private def create_api_key @@ -125,7 +131,6 @@ class User < ApplicationRecord end def activate - # TODO: Remove the `status` column in the future. update(status: :active, active_until: 1000.years.from_now) end @@ -134,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/policies/import_policy.rb b/app/policies/import_policy.rb index 0d1ceddf..fcaa2347 100644 --- a/app/policies/import_policy.rb +++ b/app/policies/import_policy.rb @@ -11,13 +11,13 @@ class ImportPolicy < ApplicationPolicy user.present? && record.user == user end - # Users can create new imports if they are active + # Users can create new imports if they are active or trial def new? create? end def create? - user.present? && user.active? + user.present? && (user.active? || user.trial?) end # Users can only edit their own imports diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb index d86fe337..d7ad2323 100644 --- a/app/services/imports/create.rb +++ b/app/services/imports/create.rb @@ -56,7 +56,12 @@ class Imports::Create end def schedule_visit_suggesting(user_id, import) + return unless user.safe_settings.visits_suggestions_enabled? + points = import.points.order(:timestamp) + + return if points.none? + start_at = Time.zone.at(points.first.timestamp) end_at = Time.zone.at(points.last.timestamp) 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..aeba5bfd 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/imports/_form.html.erb b/app/views/imports/_form.html.erb index 35d2ec34..3f2857fb 100644 --- a/app/views/imports/_form.html.erb +++ b/app/views/imports/_form.html.erb @@ -1,6 +1,7 @@ <%= form_with model: import, class: "contents", data: { controller: "direct-upload", direct_upload_url_value: rails_direct_uploads_url, + direct_upload_user_trial_value: current_user.trial?, direct_upload_target: "form" } do |form| %>
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..c1d69b36 --- /dev/null +++ b/app/views/map/_onboarding_modal.html.erb @@ -0,0 +1,21 @@ +<% if user_signed_in? %> +
+ + + +
+<% end %> diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 5140faf5..cf7ac463 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 %>
    @@ -71,7 +73,15 @@ @@ -137,4 +158,3 @@ - diff --git a/app/views/trips/_countries.html.erb b/app/views/trips/_countries.html.erb index 0ae8f7e5..ce6f3c7c 100644 --- a/app/views/trips/_countries.html.erb +++ b/app/views/trips/_countries.html.erb @@ -15,12 +15,9 @@
    Countries
    - <% if trip.countries.any? %> - <%= trip.countries.join(', ') %> - <% elsif trip.visited_countries.present? %> + <% if trip.visited_countries.any? %> <%= trip.visited_countries.join(', ') %> <% else %> - Countries are being calculated... <% end %>
    diff --git a/app/views/users_mailer/explore_features.html.erb b/app/views/users_mailer/explore_features.html.erb new file mode 100644 index 00000000..9d8c64c0 --- /dev/null +++ b/app/views/users_mailer/explore_features.html.erb @@ -0,0 +1,55 @@ + + + + + + + +
    +
    +

    Explore Dawarich Features

    +
    +
    +

    Hi <%= @user.email %>,

    + +

    You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.

    + +

    Here are some powerful features you might want to explore:

    + +
    +

    πŸ“Š Statistics & Analytics

    +

    View detailed insights about distances traveled and time spent in different locations.

    +
    + +
    +

    πŸ—ΊοΈ Interactive Maps

    +

    Visualize your tracks on beautiful maps with different layers and styling options.

    +
    + +
    +

    πŸ“ Places & Visits

    +

    Discover the places you've visited and get automatic visit detection for frequently visited locations.

    +
    + +
    +

    πŸ“€ Data Export

    +

    Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications.

    +
    + + Continue Exploring + +

    You have 5 days left in your trial. Make the most of it!

    + +

    Best regards,
    + Evgenii from Dawarich

    +
    +
    + + diff --git a/app/views/users_mailer/explore_features.text.erb b/app/views/users_mailer/explore_features.text.erb new file mode 100644 index 00000000..0ffa8e99 --- /dev/null +++ b/app/views/users_mailer/explore_features.text.erb @@ -0,0 +1,26 @@ +Explore Dawarich Features + +Hi <%= @user.email %>, + +You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data. + +Here are some powerful features you might want to explore: + +πŸ“Š Statistics & Analytics +View detailed insights about distances traveled and time spent in different locations. + +πŸ—ΊοΈ Interactive Maps +Visualize your tracks on beautiful maps with different layers and styling options. + +πŸ“ Places & Visits +Discover the places you've visited and get automatic visit detection for frequently visited locations. + +πŸ“€ Data Export +Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications. + +Continue exploring: https://my.dawarich.app + +You have 5 days left in your trial. Make the most of it! + +Best regards, +Evgenii from Dawarich diff --git a/app/views/users_mailer/trial_expired.html.erb b/app/views/users_mailer/trial_expired.html.erb new file mode 100644 index 00000000..3294b88b --- /dev/null +++ b/app/views/users_mailer/trial_expired.html.erb @@ -0,0 +1,50 @@ + + + + + + + +
    +
    +

    πŸ”’ Your Trial Has Expired

    +
    +
    +

    Hi <%= @user.email %>,

    + +
    +

    Your 7-day Dawarich trial has ended.

    +
    + +

    Thank you for trying Dawarich! We 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.

    + +

    πŸ”“ Restore full access with a subscription:

    +
      +
    • Resume location tracking
    • +
    • Access all your historical data
    • +
    • Use travel analytics and insights
    • +
    • Export data in multiple formats
    • +
    • Enjoy beautiful interactive maps
    • +
    + + Subscribe to Continue + +

    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.

    + +

    Best regards,
    + Evgenii from Dawarich

    +
    +
    + + diff --git a/app/views/users_mailer/trial_expired.text.erb b/app/views/users_mailer/trial_expired.text.erb new file mode 100644 index 00000000..d43178f3 --- /dev/null +++ b/app/views/users_mailer/trial_expired.text.erb @@ -0,0 +1,25 @@ +πŸ”’ Your Trial Has Expired + +Hi <%= @user.email %>, + +Your 7-day Dawarich trial has ended. + +Thank you for trying Dawarich! We 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. + +πŸ”“ Restore full access with a subscription: +- Resume location tracking +- Access all your historical data +- Use travel analytics and insights +- Export data in multiple formats +- Enjoy beautiful interactive maps + +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. + +Best regards, +Evgenii from Dawarich diff --git a/app/views/users_mailer/trial_expires_soon.html.erb b/app/views/users_mailer/trial_expires_soon.html.erb new file mode 100644 index 00000000..c1e5ff6e --- /dev/null +++ b/app/views/users_mailer/trial_expires_soon.html.erb @@ -0,0 +1,50 @@ + + + + + + + +
    +
    +

    ⏰ Your Trial Expires Soon

    +
    +
    +

    Hi <%= @user.email %>,

    + +
    +

    ⚠️ 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.

    + +

    To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.

    + +

    ✨ What you'll keep with a subscription:

    +
      +
    • Location tracking and data storage
    • +
    • Travel analytics and insights
    • +
    • Data export in multiple formats
    • +
    • Beautiful interactive maps
    • +
    • Visit detection and places management
    • +
    + + Subscribe Now + +

    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 or just reply to this email.

    + +

    Best regards,
    + Evgenii from Dawarich

    +
    +
    + + diff --git a/app/views/users_mailer/trial_expires_soon.text.erb b/app/views/users_mailer/trial_expires_soon.text.erb new file mode 100644 index 00000000..c5f7352e --- /dev/null +++ b/app/views/users_mailer/trial_expires_soon.text.erb @@ -0,0 +1,25 @@ +⏰ Your Trial Expires Soon + +Hi <%= @user.email %>, + +⚠️ 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. + +To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan. + +✨ What you'll keep with a subscription: +- Location tracking and data storage +- Travel analytics and insights +- Data export in multiple formats +- Beautiful interactive maps +- Visit detection and places management + +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 + +Best regards, +Evgenii from Dawarich diff --git a/app/views/users_mailer/welcome.html.erb b/app/views/users_mailer/welcome.html.erb new file mode 100644 index 00000000..07f80721 --- /dev/null +++ b/app/views/users_mailer/welcome.html.erb @@ -0,0 +1,40 @@ + + + + + + + +
    +
    +

    Welcome to Dawarich!

    +
    +
    +

    Hi <%= @user.email %>,

    + +

    Welcome to Dawarich! We're excited to have you on board.

    + +

    Your 7-day free trial has started. During this time, you can:

    +
      +
    • Track your location data
    • +
    • View your movement patterns on beautiful maps
    • +
    • Analyze your travel statistics
    • +
    • Export your data in various formats
    • +
    + + Start Exploring Dawarich + +

    If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.

    + +

    Happy tracking!
    + Evgenii from Dawarich

    +
    +
    + + diff --git a/app/views/users_mailer/welcome.text.erb b/app/views/users_mailer/welcome.text.erb new file mode 100644 index 00000000..8cbf42d2 --- /dev/null +++ b/app/views/users_mailer/welcome.text.erb @@ -0,0 +1,18 @@ +Welcome to Dawarich! + +Hi <%= @user.email %>, + +Welcome to Dawarich! We're excited to have you on board. + +Your 7-day free trial has started. During this time, you can: +- Track your location data +- View your movement patterns on beautiful maps +- Analyze your travel statistics +- Export your data in various formats + +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. + +Happy tracking! +Evgenii from Dawarich diff --git a/config/application.rb b/config/application.rb index 3d2dd0be..58530149 100644 --- a/config/application.rb +++ b/config/application.rb @@ -36,5 +36,7 @@ module Dawarich end config.active_job.queue_adapter = :sidekiq + + config.action_mailer.preview_paths << "#{Rails.root}/spec/mailers/previews" end end diff --git a/config/sidekiq.yml b/config/sidekiq.yml index ef963573..780bbc1c 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -4,6 +4,7 @@ - data_migrations - points - default + - mailers - imports - exports - stats diff --git a/spec/factories/users.rb b/spec/factories/users.rb index c9eb856e..3e27ad70 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -34,6 +34,11 @@ FactoryBot.define do active_until { 1.day.ago } end + trait :trial do + status { :trial } + active_until { 7.days.from_now } + end + trait :with_immich_integration do settings do { diff --git a/spec/fixtures/users/welcome b/spec/fixtures/users/welcome new file mode 100644 index 00000000..f6f72ecf --- /dev/null +++ b/spec/fixtures/users/welcome @@ -0,0 +1,3 @@ +Users#welcome + +Hi, find me in app/views/users/welcome diff --git a/spec/jobs/trips/calculate_countries_job_spec.rb b/spec/jobs/trips/calculate_countries_job_spec.rb new file mode 100644 index 00000000..d6d8abaa --- /dev/null +++ b/spec/jobs/trips/calculate_countries_job_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Trips::CalculateCountriesJob, type: :job do + describe '#perform' do + let(:user) { create(:user) } + let(:trip) { create(:trip, user: user) } + let(:distance_unit) { 'km' } + let(:points) do + [ + create(:point, user: user, country_name: 'Germany', timestamp: trip.started_at.to_i + 1.hour), + create(:point, user: user, country_name: 'France', timestamp: trip.started_at.to_i + 2.hours), + create(:point, user: user, country_name: 'Germany', timestamp: trip.started_at.to_i + 3.hours), + create(:point, user: user, country_name: 'Italy', timestamp: trip.started_at.to_i + 4.hours) + ] + end + + before do + points # Create the points + end + + it 'finds the trip and calculates countries' do + expect(Trip).to receive(:find).with(trip.id).and_return(trip) + expect(trip).to receive(:calculate_countries) + expect(trip).to receive(:save!) + + described_class.perform_now(trip.id, distance_unit) + end + + it 'calculates unique countries from trip points' do + described_class.perform_now(trip.id, distance_unit) + + trip.reload + expect(trip.visited_countries).to contain_exactly('Germany', 'France', 'Italy') + end + + it 'broadcasts the update with correct parameters' do + expect(Turbo::StreamsChannel).to receive(:broadcast_update_to).with( + "trip_#{trip.id}", + target: "trip_countries", + partial: "trips/countries", + locals: { trip: trip, distance_unit: distance_unit } + ) + + described_class.perform_now(trip.id, distance_unit) + end + + context 'when trip has no points' do + let(:trip_without_points) { create(:trip, user: user) } + + it 'sets visited_countries to empty array' do + trip_without_points.points.destroy_all + described_class.perform_now(trip_without_points.id, distance_unit) + + trip_without_points.reload + + expect(trip_without_points.visited_countries).to eq([]) + end + end + + context 'when points have nil country names' do + let(:points_with_nil_countries) do + [ + create(:point, user: user, country_name: 'Germany', timestamp: trip.started_at.to_i + 1.hour), + create(:point, user: user, country_name: nil, timestamp: trip.started_at.to_i + 2.hours), + create(:point, user: user, country_name: 'France', timestamp: trip.started_at.to_i + 3.hours) + ] + end + + before do + # Remove existing points and create new ones with nil countries + Point.where(user: user).destroy_all + points_with_nil_countries + end + + it 'filters out nil country names' do + described_class.perform_now(trip.id, distance_unit) + + trip.reload + expect(trip.visited_countries).to contain_exactly('Germany', 'France') + end + end + + context 'when trip is not found' do + it 'raises ActiveRecord::RecordNotFound' do + expect { + described_class.perform_now(999999, distance_unit) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when distance_unit is different' do + let(:distance_unit) { 'mi' } + + it 'passes the correct distance_unit to broadcast' do + expect(Turbo::StreamsChannel).to receive(:broadcast_update_to).with( + "trip_#{trip.id}", + target: "trip_countries", + partial: "trips/countries", + locals: { trip: trip, distance_unit: 'mi' } + ) + + described_class.perform_now(trip.id, distance_unit) + end + end + + describe 'queue configuration' do + it 'uses the trips queue' do + expect(described_class.queue_name).to eq('trips') + end + end + end +end diff --git a/spec/jobs/users/mailer_sending_job_spec.rb b/spec/jobs/users/mailer_sending_job_spec.rb new file mode 100644 index 00000000..ba4b1de9 --- /dev/null +++ b/spec/jobs/users/mailer_sending_job_spec.rb @@ -0,0 +1,144 @@ +require 'rails_helper' + +RSpec.describe Users::MailerSendingJob, type: :job do + let(:user) { create(:user, :trial) } + let(:mailer_double) { double('mailer', deliver_later: true) } + + before do + allow(UsersMailer).to receive(:with).and_return(UsersMailer) + allow(DawarichSettings).to receive(:self_hosted?).and_return(false) + end + + describe '#perform' do + context 'when email_type is welcome' do + it 'sends welcome email to trial user' do + expect(UsersMailer).to receive(:with).with({ user: user }) + expect(UsersMailer).to receive(:welcome).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'welcome') + end + + it 'sends welcome email to active user' do + active_user = create(:user) + expect(UsersMailer).to receive(:with).with({ user: active_user }) + expect(UsersMailer).to receive(:welcome).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(active_user.id, 'welcome') + end + end + + context 'when email_type is explore_features' do + it 'sends explore_features email to trial user' do + expect(UsersMailer).to receive(:with).with({ user: user }) + expect(UsersMailer).to receive(:explore_features).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'explore_features') + end + + it 'sends explore_features email to active user' do + active_user = create(:user) + expect(UsersMailer).to receive(:with).with({ user: active_user }) + expect(UsersMailer).to receive(:explore_features).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(active_user.id, 'explore_features') + end + end + + context 'when email_type is trial_expires_soon' do + context 'with trial user' do + it 'sends trial_expires_soon email' do + expect(UsersMailer).to receive(:with).with({ user: user }) + expect(UsersMailer).to receive(:trial_expires_soon).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'trial_expires_soon') + end + end + + context 'with active user' do + let(:active_user) { create(:user).tap { |u| u.update!(status: :active) } } + + it 'skips sending trial_expires_soon email' do + expect(UsersMailer).not_to receive(:with) + expect(UsersMailer).not_to receive(:trial_expires_soon) + + described_class.perform_now(active_user.id, 'trial_expires_soon') + end + end + end + + context 'when email_type is trial_expired' do + context 'with trial user' do + it 'sends trial_expired email' do + expect(UsersMailer).to receive(:with).with({ user: user }) + expect(UsersMailer).to receive(:trial_expired).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'trial_expired') + end + end + + context 'with active user' do + let(:active_user) { create(:user).tap { |u| u.update!(status: :active) } } + + it 'skips sending trial_expired email' do + expect(UsersMailer).not_to receive(:with) + expect(UsersMailer).not_to receive(:trial_expired) + + described_class.perform_now(active_user.id, 'trial_expired') + end + end + end + + context 'with additional options' do + it 'merges options with user params' do + custom_options = { custom_data: 'test', priority: :high } + expected_params = { user: user, custom_data: 'test', priority: :high } + + expect(UsersMailer).to receive(:with).with(expected_params) + expect(UsersMailer).to receive(:welcome).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + described_class.perform_now(user.id, 'welcome', **custom_options) + end + end + + context 'when user is deleted' do + it 'raises ActiveRecord::RecordNotFound' do + user.destroy + + expect { + described_class.perform_now(user.id, 'welcome') + }.to raise_error(ActiveRecord::RecordNotFound) + 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 diff --git a/spec/jobs/users/trial_webhook_job_spec.rb b/spec/jobs/users/trial_webhook_job_spec.rb new file mode 100644 index 00000000..94a9e581 --- /dev/null +++ b/spec/jobs/users/trial_webhook_job_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe Users::TrialWebhookJob, type: :job do + let(:user) { create(:user, :trial) } + let(:jwt_token) { 'encoded.jwt.token' } + let(:manager_url) { 'https://manager.example.com' } + let(:request_url) { "#{manager_url}/api/v1/users" } + let(:jwt_service) { instance_double(Subscription::EncodeJwtToken, call: jwt_token) } + + before do + stub_const('ENV', ENV.to_hash.merge('MANAGER_URL' => manager_url, 'JWT_SECRET_KEY' => 'secret')) + allow(Subscription::EncodeJwtToken).to receive(:new).and_return(jwt_service) + allow(HTTParty).to receive(:post) + end + + describe '#perform' do + it 'encodes JWT with correct payload' do + expected_payload = { + user_id: user.id, + email: user.email, + active_until: user.active_until, + status: user.status, + action: 'create_user' + } + + expect(Subscription::EncodeJwtToken).to receive(:new) + .with(expected_payload, 'secret') + .and_return(jwt_service) + + described_class.perform_now(user.id) + end + + it 'makes HTTP POST request to Manager API' do + expected_headers = { + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + } + expected_body = { token: jwt_token }.to_json + + expect(HTTParty).to receive(:post) + .with(request_url, headers: expected_headers, body: expected_body) + + described_class.perform_now(user.id) + end + + context 'when user is deleted' do + it 'raises ActiveRecord::RecordNotFound' do + user.destroy + + expect { + described_class.perform_now(user.id) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/mailers/previews/users_mailer_preview.rb b/spec/mailers/previews/users_mailer_preview.rb new file mode 100644 index 00000000..464549dc --- /dev/null +++ b/spec/mailers/previews/users_mailer_preview.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class UsersMailerPreview < ActionMailer::Preview + def welcome + UsersMailer.with(user: User.last).welcome + end + + def explore_features + UsersMailer.with(user: User.last).explore_features + end + + def trial_expires_soon + UsersMailer.with(user: User.last).trial_expires_soon + end + + def trial_expired + UsersMailer.with(user: User.last).trial_expired + end +end diff --git a/spec/mailers/users_mailer_spec.rb b/spec/mailers/users_mailer_spec.rb new file mode 100644 index 00000000..11789e2b --- /dev/null +++ b/spec/mailers/users_mailer_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsersMailer, type: :mailer do + let(:user) { create(:user, email: 'test@example.com') } + + before do + stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app')) + end + + 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"]) + end + + it "renders the body" do + expect(mail.body.encoded).to match("test@example.com") + end + end + + 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"]) + end + end + + 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"]) + end + end + + 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"]) + end + end +end diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index 88f06f02..50034082 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -3,16 +3,69 @@ require 'rails_helper' RSpec.describe Import, type: :model do + let(:user) { create(:user) } + subject(:import) { create(:import, user:) } + describe 'associations' do it { is_expected.to have_many(:points).dependent(:destroy) } - it { is_expected.to belong_to(:user) } + it 'belongs to a user' do + expect(user).to be_present + expect(import.user).to eq(user) + end end describe 'validations' do - subject { build(:import, name: 'test import') } - it { is_expected.to validate_presence_of(:name) } - it { is_expected.to validate_uniqueness_of(:name).scoped_to(:user_id) } + + it 'validates uniqueness of name scoped to user_id' do + create(:import, name: 'test_name', user: user) + + duplicate_import = build(:import, name: 'test_name', user: user) + expect(duplicate_import).not_to be_valid + expect(duplicate_import.errors[:name]).to include('has already been taken') + + other_user = create(:user) + different_user_import = build(:import, name: 'test_name', user: other_user) + expect(different_user_import).to be_valid + end + + describe 'file size validation' do + context 'when user is a trial user' do + let(:user) do + user = create(:user) + user.update!(status: :trial) + user + end + + it 'validates file size limit for large files' do + import = build(:import, user: user) + mock_file = double(attached?: true, blob: double(byte_size: 12.megabytes)) + allow(import).to receive(:file).and_return(mock_file) + + expect(import).not_to be_valid + expect(import.errors[:file]).to include('is too large. Trial users can only upload files up to 10MB.') + end + + it 'allows files under the size limit' do + import = build(:import, user: user) + mock_file = double(attached?: true, blob: double(byte_size: 5.megabytes)) + allow(import).to receive(:file).and_return(mock_file) + + expect(import).to be_valid + end + end + + context 'when user is a paid user' do + let(:user) { create(:user, status: :active) } + let(:import) { build(:import, user: user) } + + it 'does not validate file size limit' do + allow(import).to receive(:file).and_return(double(attached?: true, blob: double(byte_size: 12.megabytes))) + + expect(import).to be_valid + end + end + end end describe 'enums' do diff --git a/spec/models/trip_spec.rb b/spec/models/trip_spec.rb index 20bb5ba3..8c46a65a 100644 --- a/spec/models/trip_spec.rb +++ b/spec/models/trip_spec.rb @@ -26,34 +26,6 @@ RSpec.describe Trip, type: :model do trip.save end end - - context 'when DawarichSettings.store_geodata? is enabled' do - before do - allow(DawarichSettings).to receive(:store_geodata?).and_return(true) - end - - it 'sets the countries' do - expect(trip.countries).to eq(trip.points.pluck(:country).uniq.compact) - end - end - end - - describe '#countries' do - let(:user) { create(:user) } - let(:trip) { create(:trip, user:) } - let(:points) do - create_list( - :point, - 25, - :reverse_geocoded, - user:, - timestamp: (trip.started_at.to_i..trip.ended_at.to_i).to_a.sample - ) - end - - it 'returns the unique countries of the points' do - expect(trip.countries).to eq(trip.points.pluck(:country).uniq.compact) - end end describe '#photo_previews' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 21410e7f..e8bb25d7 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -19,7 +19,7 @@ RSpec.describe User, type: :model do end describe 'enums' do - it { is_expected.to define_enum_for(:status).with_values(inactive: 0, active: 1) } + it { is_expected.to define_enum_for(:status).with_values(inactive: 0, active: 1, trial: 2) } end describe 'callbacks' do @@ -50,19 +50,108 @@ RSpec.describe User, type: :model do allow(DawarichSettings).to receive(:self_hosted?).and_return(false) end - it 'does not activate user' do + it 'sets user to trial instead of active' do user = create(:user, :inactive) - expect(user.active?).to be_falsey - expect(user.active_until).to be_within(1.minute).of(1.day.ago) + expect(user.trial?).to be_truthy + expect(user.active_until).to be_within(1.minute).of(7.days.from_now) end end end + + describe '#start_trial' do + let(:user) { create(:user, :inactive) } + + before do + allow(Users::TrialWebhookJob).to receive(:perform_later) + end + + it 'sets trial status and active_until to 7 days from now' do + user.send(:start_trial) + + expect(user.reload.trial?).to be_truthy + expect(user.active_until).to be_within(1.minute).of(7.days.from_now) + end + + it 'enqueues trial webhook job' do + expect(Users::TrialWebhookJob).to receive(:perform_later).with(user.id) + user.send(:start_trial) + end + end + + describe '#schedule_welcome_emails' do + let(:user) { create(:user, :inactive) } + + before do + allow(Users::MailerSendingJob).to receive(:perform_later) + allow(Users::MailerSendingJob).to receive(:set).and_return(Users::MailerSendingJob) + end + + it 'schedules welcome email immediately' do + expect(Users::MailerSendingJob).to receive(:perform_later).with(user.id, 'welcome') + user.send(:schedule_welcome_emails) + end + + it 'schedules explore_features email for day 2' do + expect(Users::MailerSendingJob).to receive(:set).with(wait: 2.days).and_return(Users::MailerSendingJob) + expect(Users::MailerSendingJob).to receive(:perform_later).with(user.id, 'explore_features') + user.send(:schedule_welcome_emails) + end + + it 'schedules trial_expires_soon email for day 5' do + expect(Users::MailerSendingJob).to receive(:set).with(wait: 5.days).and_return(Users::MailerSendingJob) + expect(Users::MailerSendingJob).to receive(:perform_later).with(user.id, 'trial_expires_soon') + user.send(:schedule_welcome_emails) + end + + it 'schedules trial_expired email for day 7' do + expect(Users::MailerSendingJob).to receive(:set).with(wait: 7.days).and_return(Users::MailerSendingJob) + expect(Users::MailerSendingJob).to receive(:perform_later).with(user.id, 'trial_expired') + user.send(:schedule_welcome_emails) + end + end end describe 'methods' do let(:user) { create(:user) } + describe '#trial_state?' do + context 'when user has trial status and no tracked points' do + let(:user) do + user = build(:user, :trial) + user.save!(validate: false) + user.update_column(:status, 'trial') + user + end + + it 'returns true' do + user.tracked_points.destroy_all + + expect(user.trial_state?).to be_truthy + end + end + + context 'when user has trial status but has tracked points' do + let(:user) { create(:user, :trial) } + + before do + create(:point, user: user) + end + + it 'returns false' do + expect(user.trial_state?).to be_falsey + end + end + + context 'when user is not on trial' do + let(:user) { create(:user, :active) } + + it 'returns false' do + expect(user.trial_state?).to be_falsey + end + end + end + describe '#countries_visited' do subject { user.countries_visited } @@ -200,12 +289,27 @@ RSpec.describe User, type: :model do let(:user) { create(:user, status: :active, active_until: 1000.years.from_now) } it 'returns false' do + user.update(status: :active) + expect(user.can_subscribe?).to be_falsey end end context 'when user is inactive' do - let(:user) { create(:user, :inactive) } + let(:user) do + user = build(:user, :inactive) + user.save!(validate: false) + user.update_columns(status: 'inactive', active_until: 1.day.ago) + user + end + + it 'returns true' do + expect(user.can_subscribe?).to be_truthy + end + end + + context 'when user is on trial' do + let(:user) { create(:user, :trial, active_until: 1.week.from_now) } it 'returns true' do expect(user.can_subscribe?).to be_truthy diff --git a/spec/requests/api/v1/countries/borders_spec.rb b/spec/requests/api/v1/countries/borders_spec.rb index bb34831b..4279fa25 100644 --- a/spec/requests/api/v1/countries/borders_spec.rb +++ b/spec/requests/api/v1/countries/borders_spec.rb @@ -10,6 +10,13 @@ RSpec.describe 'Api::V1::Countries::Borders', type: :request do expect(response).to have_http_status(:unauthorized) end + + it 'returns X-Dawarich-Response header' do + get '/api/v1/countries/borders' + + expect(response.headers['X-Dawarich-Response']).to eq('Hey, I\'m alive!') + expect(response.headers['X-Dawarich-Version']).to eq(APP_VERSION) + end end context 'when user is authenticated' do @@ -22,6 +29,13 @@ RSpec.describe 'Api::V1::Countries::Borders', type: :request do expect(response.body).to include('AF') expect(response.body).to include('ZW') end + + it 'returns X-Dawarich-Response header' do + get '/api/v1/countries/borders', headers: { 'Authorization' => "Bearer #{user.api_key}" } + + expect(response.headers['X-Dawarich-Response']).to eq('Hey, I\'m alive and authenticated!') + expect(response.headers['X-Dawarich-Version']).to eq(APP_VERSION) + end end end end diff --git a/spec/requests/imports_spec.rb b/spec/requests/imports_spec.rb index 0d1852de..56eb3333 100644 --- a/spec/requests/imports_spec.rb +++ b/spec/requests/imports_spec.rb @@ -203,6 +203,16 @@ RSpec.describe 'Imports', type: :request do expect(response).to have_http_status(200) end + + context 'when user is a trial user' do + let(:user) { create(:user, status: :trial) } + + it 'returns http success' do + get new_import_path + + expect(response).to have_http_status(200) + end + end end end diff --git a/spec/services/subscription/encode_jwt_token_spec.rb b/spec/services/subscription/encode_jwt_token_spec.rb new file mode 100644 index 00000000..9d25c143 --- /dev/null +++ b/spec/services/subscription/encode_jwt_token_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Subscription::EncodeJwtToken do + let(:payload) { { user_id: 123, email: 'test@example.com', action: 'create_user' } } + let(:secret_key) { 'secret_key' } + let(:service) { described_class.new(payload, secret_key) } + + describe '#call' do + it 'encodes JWT with correct algorithm' do + expect(JWT).to receive(:encode) + .with(payload, secret_key, 'HS256') + .and_return('encoded.jwt.token') + + result = service.call + expect(result).to eq('encoded.jwt.token') + end + + it 'returns encoded JWT token' do + token = service.call + + decoded_payload = JWT.decode(token, secret_key, 'HS256').first + + expect(decoded_payload['user_id']).to eq(123) + expect(decoded_payload['email']).to eq('test@example.com') + expect(decoded_payload['action']).to eq('create_user') + end + end +end diff --git a/tests/system/test_scenarios.md b/tests/system/test_scenarios.md deleted file mode 100644 index 4b4177ff..00000000 --- a/tests/system/test_scenarios.md +++ /dev/null @@ -1,352 +0,0 @@ -# Dawarich System Test Scenarios - -This document tracks all system test scenarios for the Dawarich application. Completed scenarios are marked with `[x]` and pending scenarios with `[ ]`. - -## 1. Authentication & User Management - -### Sign In/Out -- [x] User can sign in with valid credentials -- [x] User is redirected to map page after successful sign in -- [x] User cannot sign in with invalid credentials -- [x] User can sign out successfully -- [x] User is redirected to sign in page when accessing protected routes while signed out - -### User Registration -- [ ] New user can register with valid information -- [ ] Registration fails with invalid email format -- [ ] Registration fails with weak password -- [ ] Registration fails with mismatched password confirmation -- [ ] Email confirmation process works correctly - -### Password Management -- [ ] User can request password reset -- [ ] Password reset email is sent -- [ ] User can reset password with valid token -- [ ] Password reset fails with expired token -- [ ] User can change password when signed in - -## 2. Map Functionality - -### Basic Map Operations -- [x] Leaflet map initializes correctly -- [x] Map displays with proper container and panes -- [x] Map tiles load successfully -- [x] Zoom in/out functionality works -- [x] Map controls are present and functional - -### Map Layers -- [x] Base layer switching (OpenStreetMap ↔ OpenTopo) -- [x] Layer control expands and collapses -- [x] Overlay layers can be toggled (Points, Routes, Fog of War, Heatmap, etc.) -- [x] Layer states persist after settings updates -- [ ] Fallback map layer when preferred layer fails -- [ ] Custom tile layer configuration -- [ ] Layer loading error handling - -### Map Data Display -- [x] Route data loads and displays -- [x] Point markers appear on map -- [x] Map statistics display (distance, points count) -- [x] Map scale control shows correctly -- [x] Map attributions are present - -## 3. Route Management - -### Route Display -- [x] Routes render as polylines -- [x] Route opacity can be adjusted -- [x] Speed-colored routes toggle works -- [x] Route splitting settings can be configured - -### Route Interaction -- [x] Route popup displays on hover/click (basic structure) -- [x] Popup shows start/end times, duration, distance, speed -- [x] Distance units convert properly (km ↔ miles) -- [x] Speed units convert properly (km/h ↔ mph) -- [ ] Route deletion with confirmation (not implemented yet) -- [ ] Route merging/splitting operations (not implemented yet) -- [ ] Route export functionality (not implemented yet) - -## 4. Point Management - -### Point Display -- [x] Points display as markers -- [x] Point popups show detailed information -- [x] Point rendering mode can be toggled (raw/simplified) - -### Point Operations -- [x] Point deletion link is present and functional -- [ ] Point deletion confirmation dialog -- [ ] Point editing (coordinates via drag and drop) -- [ ] Point filtering by date/time - -## 5. Settings Panel - -### Map Settings -- [x] Settings panel opens and closes -- [x] Route opacity updates -- [x] Fog of war settings (radius, threshold) -- [x] Route splitting configuration (meters, minutes) -- [x] Points rendering mode toggle -- [x] Live map functionality toggle -- [x] Speed-colored routes toggle -- [x] Speed color scale updates -- [x] Gradient editor modal interaction - -### Settings Validation -- [ ] Invalid settings values are rejected -- [ ] Settings form validation messages -- [ ] Settings reset to defaults -- [ ] Settings import/export functionality - -## 6. Calendar Panel - -### Calendar Display -- [x] Calendar button is functional -- [x] Calendar panel opens and displays correctly -- [ ] Year selection works -- [ ] Month navigation functions -- [ ] Visited cities information displays - -### Calendar Interaction -- [ ] Date selection filters map data -- [x] Calendar state persists in localStorage -- [ ] Calendar navigation with keyboard shortcuts (not implemented yet) - -## 7. Data Import/Export - -### Import Functionality -- [ ] GPX file import -- [ ] JSON data import -- [ ] .rec file import -- [ ] Import validation and error handling -- [ ] Import progress indication -- [ ] Duplicate data handling during import - -### Export Functionality -- [ ] GPX file export -- [ ] JSON data export -- [ ] Date range export filtering -- [ ] Export progress indication - -## 8. Statistics & Analytics - -### Statistics Display -- [x] Map statistics show distance and points -- [ ] Detailed statistics page -- [ ] Distance traveled by time period -- [ ] Speed analytics -- [ ] Location frequency analysis -- [ ] Activity patterns visualization - -### Charts & Visualizations -- [ ] Distance over time charts -- [ ] Speed distribution charts -- [ ] Heatmap visualization -- [ ] Activity timeline -- [ ] Geographic distribution charts - -## 9. Photos & Media - -### Photo Management -- [ ] Photo display on map -- [ ] Photo popup with details - -## 10. Areas & Geofencing - -### Area Management -- [ ] Create new areas -- [ ] Edit existing areas -- [ ] Delete areas -- [ ] Area visualization on map - -### Area Functionality -- [ ] Time spent in areas calculation -- [ ] Area visit history -- [ ] Area-based filtering - -## 11. Performance & Error Handling - -### Performance Testing -- [x] Large dataset handling without crashes -- [x] Memory cleanup on page navigation -- [ ] Tile monitoring functionality -- [ ] Map rendering performance with many points -- [ ] Data loading optimization - -### Error Handling -- [x] Empty markers array handling -- [x] Missing user settings gracefully handled -- [ ] Network connectivity issues -- [ ] Failed API calls handling -- [ ] Invalid coordinates handling -- [ ] Database connection errors -- [ ] File upload errors - -## 12. User Preferences & Persistence - -### Preference Management -- [x] Distance unit preferences (km/miles) -- [ ] Preferred map layer persistence -- [x] Panel state persistence (basic) -- [ ] Theme preferences (light/dark mode) -- [ ] Timezone settings (not implemented yet) - -### Data Persistence -- [ ] Map view state persistence (zoom, center) -- [ ] Filter preferences persistence - -## 13. API Integration - -### External APIs -- [x] GitHub API integration (version checking) -- [ ] Reverse geocoding functionality - -### API Error Handling -- [x] GitHub API stub for testing -- [ ] API rate limiting handling -- [ ] API timeout handling -- [ ] Fallback when APIs are unavailable - -## 14. Mobile Responsiveness - -### Mobile Layout -- [ ] Map displays correctly on mobile devices -- [ ] Touch gestures work (pinch to zoom, pan) -- [ ] Mobile-optimized controls -- [ ] Responsive navigation menu - -## 15. Security & Privacy - -### Data Security -- [ ] User data isolation (users only see their own data) -- [ ] Secure file upload validation -- [ ] XSS protection in user inputs -- [ ] CSRF protection on forms - -### Privacy Features -- [ ] Data anonymization options -- [ ] Location data privacy settings -- [ ] Data deletion functionality -- [ ] Privacy policy compliance - -## 16. Accessibility - -### WCAG Compliance -- [ ] Keyboard navigation support -- [ ] Screen reader compatibility -- [ ] High contrast mode support -- [ ] Focus indicators on interactive elements - -### Usability -- [ ] Tooltips and help text -- [ ] Error message clarity -- [ ] Loading states and progress indicators -- [ ] Consistent UI patterns - -## 17. Integration Testing - -### Database Operations -- [ ] Data migration testing -- [ ] Backup and restore functionality -- [ ] Database performance with large datasets -- [ ] Concurrent user operations - -## 18. Navigation & UI - -### Main Navigation -- [ ] Navigation menu functionality -- [ ] Page transitions work smoothly -- [ ] Back/forward browser navigation - -## 19. Trips & Journey Management - -### Trip Creation -- [ ] Automatic trip detection (not implemented yet) -- [ ] Manual trip creation -- [ ] Trip editing (name, description, dates) -- [ ] Trip deletion with confirmation - -### Trip Display -- [ ] Trip list view -- [ ] Trip detail view -- [ ] Trip statistics -- [ ] Trip sharing functionality (not implemented yet) - -## 21. Notifications & Alerts - -### System Notifications -- [x] Success message display -- [ ] Error message display -- [ ] Warning notifications -- [ ] Info notifications - -### User Notifications -- [ ] Email notifications for important events - -## 20. Search & Filtering - -### Search Functionality -- [ ] Global search across all data -- [ ] Location-based search -- [ ] Date range search -- [ ] Advanced search filters - -### Data Filtering -- [ ] Filter by date range -- [ ] Filter by location/area -- [ ] Filter by activity type -- [ ] Filter by speed/distance - -## 21. Backup & Data Management - -### Data Backup -- [ ] Manual data backup -- [ ] Backup verification -- [ ] Backup restoration - -### Data Cleanup -- [ ] Duplicate data detection -- [ ] Data archiving -- [ ] Data purging (old data) -- [ ] Storage optimization - ---- - -## Test Execution Summary - -**Total Scenarios:** 180+ -**Completed:** 51 βœ… -**Pending:** 129+ ⏳ -**Coverage:** ~28% - -### Priority for Next Implementation: -1. **Authentication flows** (sign out, invalid credentials, registration) -2. **Error handling** (network issues, invalid data, API failures) -3. **Calendar panel JavaScript interactions** -4. **Data import/export functionality** -5. **Mobile responsiveness testing** -6. **Security & privacy features** -7. **Performance optimization tests** -8. **Navigation & UI consistency** - -### High-Impact Areas to Focus On: -- **User Authentication & Security** - Critical for production use -- **Data Import/Export** - Core functionality for user data management -- **Error Handling** - Essential for robust application behavior -- **Mobile Experience** - Important for modern web applications -- **Performance** - Critical for user experience with large datasets - -### Testing Strategy Notes: -- **System Tests**: Focus on user workflows and integration -- **Unit Tests**: Cover individual components and business logic -- **API Tests**: Ensure robust API behavior and error handling -- **Performance Tests**: Validate application behavior under load -- **Security Tests**: Verify data protection and access controls - -### Tools & Frameworks: -- **RSpec + Capybara**: System/integration testing -- **Selenium WebDriver**: Browser automation -- **WebMock**: External API mocking -- **FactoryBot**: Test data generation -- **SimpleCov**: Code coverage analysis