mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 06:28:06 -05:00
Merge remote-tracking branch 'origin' into feature/multi-device
This commit is contained in:
@@ -1 +1 @@
|
||||
0.30.8
|
||||
0.30.10
|
||||
|
||||
19
CHANGELOG.md
19
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
|
||||
|
||||
3
Gemfile
3
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'
|
||||
|
||||
114
Gemfile.lock
114
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
|
||||
|
||||
13
README.md
13
README.md
@@ -1,4 +1,4 @@
|
||||
# 🌍 Dawarich: Your Self-Hosted Location History Tracker
|
||||
# 🌍 Dawarich: Your Self-Hostable Location History Tracker
|
||||
|
||||
[](https://discord.gg/pHsBjpt5J8) | [](https://ko-fi.com/H2H3IDYDD) | [](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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
17
app/helpers/user_helper.rb
Normal file
17
app/helpers/user_helper.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
42
app/javascript/controllers/onboarding_modal_controller.js
Normal file
42
app/javascript/controllers/onboarding_modal_controller.js
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/jobs/users/mailer_sending_job.rb
Normal file
24
app/jobs/users/mailer_sending_job.rb
Normal 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
|
||||
27
app/jobs/users/trial_webhook_job.rb
Normal file
27
app/jobs/users/trial_webhook_job.rb
Normal 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
|
||||
27
app/mailers/users_mailer.rb
Normal file
27
app/mailers/users_mailer.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
16
app/services/subscription/encode_jwt_token.rb
Normal file
16
app/services/subscription/encode_jwt_token.rb
Normal 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
|
||||
@@ -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' %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -31,5 +31,7 @@
|
||||
</div>
|
||||
<%= render SELF_HOSTED ? 'shared/footer' : 'shared/legal_footer' %>
|
||||
</div>
|
||||
|
||||
<%= render 'map/onboarding_modal' %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
21
app/views/map/_onboarding_modal.html.erb
Normal file
21
app/views/map/_onboarding_modal.html.erb
Normal 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 %>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
55
app/views/users_mailer/explore_features.html.erb
Normal file
55
app/views/users_mailer/explore_features.html.erb
Normal 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>
|
||||
26
app/views/users_mailer/explore_features.text.erb
Normal file
26
app/views/users_mailer/explore_features.text.erb
Normal 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
|
||||
50
app/views/users_mailer/trial_expired.html.erb
Normal file
50
app/views/users_mailer/trial_expired.html.erb
Normal 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>
|
||||
25
app/views/users_mailer/trial_expired.text.erb
Normal file
25
app/views/users_mailer/trial_expired.text.erb
Normal 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
|
||||
50
app/views/users_mailer/trial_expires_soon.html.erb
Normal file
50
app/views/users_mailer/trial_expires_soon.html.erb
Normal 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>
|
||||
25
app/views/users_mailer/trial_expires_soon.text.erb
Normal file
25
app/views/users_mailer/trial_expires_soon.text.erb
Normal 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
|
||||
40
app/views/users_mailer/welcome.html.erb
Normal file
40
app/views/users_mailer/welcome.html.erb
Normal 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>
|
||||
18
app/views/users_mailer/welcome.text.erb
Normal file
18
app/views/users_mailer/welcome.text.erb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
- data_migrations
|
||||
- points
|
||||
- default
|
||||
- mailers
|
||||
- imports
|
||||
- exports
|
||||
- stats
|
||||
|
||||
@@ -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
3
spec/fixtures/users/welcome
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
Users#welcome
|
||||
|
||||
Hi, find me in app/views/users/welcome
|
||||
114
spec/jobs/trips/calculate_countries_job_spec.rb
Normal file
114
spec/jobs/trips/calculate_countries_job_spec.rb
Normal 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
|
||||
144
spec/jobs/users/mailer_sending_job_spec.rb
Normal file
144
spec/jobs/users/mailer_sending_job_spec.rb
Normal 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
|
||||
56
spec/jobs/users/trial_webhook_job_spec.rb
Normal file
56
spec/jobs/users/trial_webhook_job_spec.rb
Normal 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
|
||||
19
spec/mailers/previews/users_mailer_preview.rb
Normal file
19
spec/mailers/previews/users_mailer_preview.rb
Normal 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
|
||||
51
spec/mailers/users_mailer_spec.rb
Normal file
51
spec/mailers/users_mailer_spec.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
30
spec/services/subscription/encode_jwt_token_spec.rb
Normal file
30
spec/services/subscription/encode_jwt_token_spec.rb
Normal 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user