Merge remote-tracking branch 'origin' into feature/multi-device

This commit is contained in:
Eugene Burmakin
2025-08-19 22:33:58 +02:00
53 changed files with 1300 additions and 493 deletions

View File

@@ -1 +1 @@
0.30.8
0.30.10

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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`.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
})
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -1,6 +1,14 @@
<p class="py-6">
<p class='py-2'>Use this API key to authenticate your requests.</p>
<code><%= current_user.api_key %></code>
<% if ENV['QR_CODE_ENABLED'] == 'true' %>
<p class='py-2'>
Or you can scan it in your Dawarich iOS app:
<%= api_key_qr_code(current_user) %>
</p>
<% end %>
<p class='py-2'>
<p>Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %></p>
@@ -20,7 +28,6 @@
<div class='divider'>OR</div>
<h3 class='text-lg font-bold mt-4'>Overland</h3>
<p><code><%= api_v1_overland_batches_url(api_key: current_user.api_key) %></code></p>
</p>
<p class='py-2'>
<%= 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' %>

View File

@@ -4,7 +4,13 @@
<div class="hero-content flex-col lg:flex-row-reverse w-full my-5">
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold mb-5">Edit your account!</h1>
<%= render 'devise/registrations/api_key' %>
<% if current_user.active? %>
<%= render 'devise/registrations/api_key' %>
<% else %>
<p>
<%= 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.
</p>
<% end %>
<% if !DawarichSettings.self_hosted? %>
<%= render 'devise/registrations/points_usage' %>
<% end %>

View File

@@ -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| %>
<div class="form-control w-full">

View File

@@ -31,5 +31,7 @@
</div>
<%= render SELF_HOSTED ? 'shared/footer' : 'shared/legal_footer' %>
</div>
<%= render 'map/onboarding_modal' %>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<% if user_signed_in? %>
<div data-controller="onboarding-modal"
data-onboarding-modal-showable-value="<%= onboarding_modal_showable?(current_user) %>">
<dialog id="getting_started" class="modal" data-onboarding-modal-target="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Start tracking your location!</h3>
<p class="py-4">
To start tracking your location and putting it on the map, you need to configure your mobile application.
</p>
<p>
To do so, grab the API key from <%= link_to 'here', settings_path, class: 'link' %> and follow the instructions in the <%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding', class: 'link' %>.
</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
</form>
</div>
</div>
</dialog>
</div>
<% end %>

View File

@@ -20,7 +20,9 @@
</details>
</li>
<% if user_signed_in? && current_user.can_subscribe? %>
<li><%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %></li>
<li>
<%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %>
</li>
<% end %>
</ul>
</div>
@@ -71,7 +73,15 @@
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
<% if user_signed_in? %>
<% if current_user.can_subscribe? %>
<li><%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %></li>
<div class="join">
<%= link_to "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" do %>
<span class="join-item btn btn-sm <%= trial_button_class(current_user) %>">
<span class="tooltip tooltip-bottom" data-tip="Your trial will end in <%= distance_of_time_in_words(current_user.active_until, Time.current) %>"><%= (current_user.active_until.to_date - Time.current.to_date).to_i %> days remaining</span>
</span><span class="join-item btn btn-sm btn-success">
Subscribe
</span>
<% end %>
</div>
<% end %>
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover"
@@ -113,6 +123,9 @@
<details>
<summary>
<%= "#{current_user.email}" %>
<% if onboarding_modal_showable?(current_user) %>
<span class="indicator-item badge badge-secondary badge-xs"></span>
<% end %>
<% if current_user.admin? %>
<span class='tooltip tooltip-bottom' data-tip="You're an admin, Harry!">⭐️</span>
<% end %>
@@ -123,6 +136,14 @@
<% if !DawarichSettings.self_hosted? %>
<li><%= link_to 'Subscription', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" %></li>
<% end %>
<li>
<a onclick="getting_started.showModal()" class="relative">
Get started
<% if onboarding_modal_showable?(current_user) %>
<span class="indicator-item badge badge-secondary badge-xs"></span>
<% end %>
</a>
</li>
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo: false } %></li>
</ul>
@@ -137,4 +158,3 @@
</ul>
</div>
</div>

View File

@@ -15,12 +15,9 @@
<div class="card-body p-4">
<div class="stat-title text-xs">Countries</div>
<div class="stat-value text-lg">
<% if trip.countries.any? %>
<%= trip.countries.join(', ') %>
<% elsif trip.visited_countries.present? %>
<% if trip.visited_countries.any? %>
<%= trip.visited_countries.join(', ') %>
<% else %>
<span class="text-xs">Countries are being calculated...</span>
<span class="loading loading-dots loading-sm"></span>
<% end %>
</div>

View File

@@ -0,0 +1,55 @@
<!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: #16a34a; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.cta { background: #16a34a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.feature { margin: 15px 0; padding: 15px; background: white; border-left: 4px solid #16a34a; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Explore Dawarich Features</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>,</p>
<p>You're now 2 days into your Dawarich trial! We 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>📊 Statistics & Analytics</h3>
<p>View detailed insights about distances traveled and time spent in different locations.</p>
</div>
<div class="feature">
<h3>🗺️ Interactive Maps</h3>
<p>Visualize your tracks on beautiful maps with different layers and styling options.</p>
</div>
<div class="feature">
<h3>📍 Places & Visits</h3>
<p>Discover the places you've visited and get automatic visit detection for frequently visited locations.</p>
</div>
<div class="feature">
<h3>📤 Data Export</h3>
<p>Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications.</p>
</div>
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=explore_features&utm_content=continue_exploring" class="cta">Continue Exploring</a>
<p>You have <strong>5 days</strong> left in your trial. Make the most of it!</p>
<p>Best regards,<br>
Evgenii from Dawarich</p>
</div>
</div>
</body>
</html>

View File

@@ -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

View File

@@ -0,0 +1,50 @@
<!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: #dc2626; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.cta { background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.expired { background: #fee2e2; border: 1px solid #dc2626; padding: 15px; border-radius: 6px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔒 Your Trial Has Expired</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>,</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>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>
<h3>🔓 Restore full access with a subscription:</h3>
<ul>
<li>Resume location tracking</li>
<li>Access all your historical data</li>
<li>Use travel analytics and insights</li>
<li>Export data in multiple formats</li>
<li>Enjoy beautiful interactive maps</li>
</ul>
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=trial_expired&utm_content=subscribe_to_continue" class="cta">Subscribe to Continue</a>
<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>Best regards,<br>
Evgenii from Dawarich</p>
</div>
</div>
</body>
</html>

View File

@@ -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

View File

@@ -0,0 +1,50 @@
<!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: #f59e0b; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.cta { background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.urgent { 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 Trial Expires Soon</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>,</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>To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.</p>
<h3>✨ What you'll keep with a subscription:</h3>
<ul>
<li>Location tracking and data storage</li>
<li>Travel analytics and insights</li>
<li>Data export in multiple formats</li>
<li>Beautiful interactive maps</li>
<li>Visit detection and places management</li>
</ul>
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=trial_expires_soon&utm_content=subscribe_now" class="cta">Subscribe Now</a>
<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>Best regards,<br>
Evgenii from Dawarich</p>
</div>
</div>
</body>
</html>

View File

@@ -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

View File

@@ -0,0 +1,40 @@
<!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; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Dawarich!</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>,</p>
<p>Welcome to Dawarich! We're excited to have you on board.</p>
<p>Your 7-day free trial has started. During this time, you can:</p>
<ul>
<li>Track your location data</li>
<li>View your movement patterns on beautiful maps</li>
<li>Analyze your travel statistics</li>
<li>Export your data in various formats</li>
</ul>
<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>Happy tracking!<br>
Evgenii from Dawarich</p>
</div>
</div>
</body>
</html>

View File

@@ -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

View File

@@ -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

View File

@@ -4,6 +4,7 @@
- data_migrations
- points
- default
- mailers
- imports
- exports
- stats

View File

@@ -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
{

3
spec/fixtures/users/welcome vendored Normal file
View File

@@ -0,0 +1,3 @@
Users#welcome
Hi, find me in app/views/users/welcome

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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