mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Merge remote-tracking branch 'origin' into feature/multi-device
This commit is contained in:
commit
8f24fd89ab
53 changed files with 1300 additions and 493 deletions
|
|
@ -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/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
# [0.30.9] - 2025-08-10
|
|
||||||
|
# [0.30.10] - 2025-08-19
|
||||||
|
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
|
|
@ -13,6 +15,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
- [ ] Add tracker_id index to points table
|
- [ ] 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
|
# [0.30.8] - 2025-08-01
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
|
||||||
3
Gemfile
3
Gemfile
|
|
@ -5,6 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
||||||
|
|
||||||
ruby File.read('.ruby-version').strip
|
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
|
# 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-s3', '~> 1.177.0', require: false
|
||||||
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
||||||
|
|
@ -24,7 +25,7 @@ gem 'oj'
|
||||||
gem 'parallel'
|
gem 'parallel'
|
||||||
gem 'pg'
|
gem 'pg'
|
||||||
gem 'prometheus_exporter'
|
gem 'prometheus_exporter'
|
||||||
gem 'activerecord-postgis-adapter'
|
gem 'rqrcode', '~> 3.0'
|
||||||
gem 'puma'
|
gem 'puma'
|
||||||
gem 'pundit'
|
gem 'pundit'
|
||||||
gem 'rails', '~> 8.0'
|
gem 'rails', '~> 8.0'
|
||||||
|
|
|
||||||
114
Gemfile.lock
114
Gemfile.lock
|
|
@ -10,29 +10,29 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (8.0.2)
|
actioncable (8.0.2.1)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.0.2)
|
actionmailbox (8.0.2.1)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.2.1)
|
||||||
activejob (= 8.0.2)
|
activejob (= 8.0.2.1)
|
||||||
activerecord (= 8.0.2)
|
activerecord (= 8.0.2.1)
|
||||||
activestorage (= 8.0.2)
|
activestorage (= 8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.0.2)
|
actionmailer (8.0.2.1)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.2.1)
|
||||||
actionview (= 8.0.2)
|
actionview (= 8.0.2.1)
|
||||||
activejob (= 8.0.2)
|
activejob (= 8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.0.2)
|
actionpack (8.0.2.1)
|
||||||
actionview (= 8.0.2)
|
actionview (= 8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
|
|
@ -40,38 +40,38 @@ GEM
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.0.2)
|
actiontext (8.0.2.1)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.2.1)
|
||||||
activerecord (= 8.0.2)
|
activerecord (= 8.0.2.1)
|
||||||
activestorage (= 8.0.2)
|
activestorage (= 8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.0.2)
|
actionview (8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.0.2)
|
activejob (8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.0.2)
|
activemodel (8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
activerecord (8.0.2)
|
activerecord (8.0.2.1)
|
||||||
activemodel (= 8.0.2)
|
activemodel (= 8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activerecord-postgis-adapter (11.0.0)
|
activerecord-postgis-adapter (11.0.0)
|
||||||
activerecord (~> 8.0.0)
|
activerecord (~> 8.0.0)
|
||||||
rgeo-activerecord (~> 8.0.0)
|
rgeo-activerecord (~> 8.0.0)
|
||||||
activestorage (8.0.2)
|
activestorage (8.0.2.1)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.2.1)
|
||||||
activejob (= 8.0.2)
|
activejob (= 8.0.2.1)
|
||||||
activerecord (= 8.0.2)
|
activerecord (= 8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.0.2)
|
activesupport (8.0.2.1)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
|
|
@ -127,6 +127,7 @@ GEM
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
chartkick (5.2.0)
|
chartkick (5.2.0)
|
||||||
|
chunky_png (1.4.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.3)
|
connection_pool (2.5.3)
|
||||||
|
|
@ -297,7 +298,7 @@ GEM
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.1)
|
public_suffix (6.0.1)
|
||||||
puma (6.6.0)
|
puma (6.6.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.0)
|
pundit (2.5.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
|
|
@ -311,20 +312,20 @@ GEM
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.2.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.0.2)
|
rails (8.0.2.1)
|
||||||
actioncable (= 8.0.2)
|
actioncable (= 8.0.2.1)
|
||||||
actionmailbox (= 8.0.2)
|
actionmailbox (= 8.0.2.1)
|
||||||
actionmailer (= 8.0.2)
|
actionmailer (= 8.0.2.1)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.2.1)
|
||||||
actiontext (= 8.0.2)
|
actiontext (= 8.0.2.1)
|
||||||
actionview (= 8.0.2)
|
actionview (= 8.0.2.1)
|
||||||
activejob (= 8.0.2)
|
activejob (= 8.0.2.1)
|
||||||
activemodel (= 8.0.2)
|
activemodel (= 8.0.2.1)
|
||||||
activerecord (= 8.0.2)
|
activerecord (= 8.0.2.1)
|
||||||
activestorage (= 8.0.2)
|
activestorage (= 8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.2)
|
railties (= 8.0.2.1)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
|
|
@ -332,9 +333,9 @@ GEM
|
||||||
rails-html-sanitizer (1.6.2)
|
rails-html-sanitizer (1.6.2)
|
||||||
loofah (~> 2.21)
|
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)
|
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)
|
railties (8.0.2.1)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.2.1)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2.1)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
|
|
@ -365,6 +366,10 @@ GEM
|
||||||
rgeo-geojson (2.2.0)
|
rgeo-geojson (2.2.0)
|
||||||
multi_json (~> 1.15)
|
multi_json (~> 1.15)
|
||||||
rgeo (>= 1.0.0)
|
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-core (3.13.3)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.4)
|
rspec-expectations (3.13.4)
|
||||||
|
|
@ -553,6 +558,7 @@ DEPENDENCIES
|
||||||
rgeo
|
rgeo
|
||||||
rgeo-activerecord
|
rgeo-activerecord
|
||||||
rgeo-geojson
|
rgeo-geojson
|
||||||
|
rqrcode (~> 3.0)
|
||||||
rspec-rails
|
rspec-rails
|
||||||
rswag-api
|
rswag-api
|
||||||
rswag-specs
|
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)
|
[](https://discord.gg/pHsBjpt5J8) | [](https://ko-fi.com/H2H3IDYDD) | [](https://www.patreon.com/freika)
|
||||||
|
|
||||||
|
|
@ -21,9 +21,14 @@
|
||||||
|
|
||||||
## 🗺️ About Dawarich
|
## 🗺️ 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.
|
- 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!
|
- Explore statistics like the number of countries and cities visited, total distance traveled, and more!
|
||||||
|
|
||||||
📄 **Changelog**: Find the latest updates [here](CHANGELOG.md).
|
📄 **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.
|
1. Clone the repository.
|
||||||
2. Run the following command to start the app:
|
2. Run the following command to start the app:
|
||||||
```bash
|
```bash
|
||||||
docker-compose up
|
docker-compose -f docker/docker-compose.yml up
|
||||||
```
|
```
|
||||||
3. Access the app at `http://localhost:3000`.
|
3. Access the app at `http://localhost:3000`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,6 @@ class Api::V1::HealthController < ApiController
|
||||||
skip_before_action :authenticate_api_key
|
skip_before_action :authenticate_api_key
|
||||||
|
|
||||||
def index
|
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' }
|
render json: { status: 'ok' }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,18 @@
|
||||||
|
|
||||||
class ApiController < ApplicationController
|
class ApiController < ApplicationController
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
before_action :set_version_header
|
||||||
before_action :authenticate_api_key
|
before_action :authenticate_api_key
|
||||||
|
|
||||||
private
|
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
|
def authenticate_api_key
|
||||||
return head :unauthorized unless current_api_user
|
return head :unauthorized unless current_api_user
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,14 +48,14 @@ module ApplicationHelper
|
||||||
|
|
||||||
grouped_by_country[country] ||= []
|
grouped_by_country[country] ||= []
|
||||||
|
|
||||||
if toponym['cities'].present?
|
next unless toponym['cities'].present?
|
||||||
|
|
||||||
toponym['cities'].each do |city_data|
|
toponym['cities'].each do |city_data|
|
||||||
city = city_data['city']
|
city = city_data['city']
|
||||||
grouped_by_country[country] << city if city.present?
|
grouped_by_country[country] << city if city.present?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
grouped_by_country.transform_values!(&:uniq)
|
grouped_by_country.transform_values!(&:uniq)
|
||||||
|
|
||||||
|
|
@ -172,4 +172,21 @@ module ApplicationHelper
|
||||||
data: { tip: "Expires on #{active_until.iso8601}" }
|
data: { tip: "Expires on #{active_until.iso8601}" }
|
||||||
)
|
)
|
||||||
end
|
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
|
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 {
|
export default class extends Controller {
|
||||||
static targets = ["input", "progress", "progressBar", "submit", "form"]
|
static targets = ["input", "progress", "progressBar", "submit", "form"]
|
||||||
static values = {
|
static values = {
|
||||||
url: String
|
url: String,
|
||||||
|
userTrial: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
|
@ -50,6 +51,22 @@ export default class extends Controller {
|
||||||
const files = this.inputTarget.files
|
const files = this.inputTarget.files
|
||||||
if (files.length === 0) return
|
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`)
|
console.log(`Uploading ${files.length} files`)
|
||||||
this.isUploading = true
|
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
|
def calculate_distance
|
||||||
calculated_distance_meters = calculate_distance_from_coordinates
|
calculated_distance_meters = calculate_distance_from_coordinates
|
||||||
|
|
||||||
self.distance = convert_distance_for_storage(calculated_distance_meters)
|
self.distance = convert_distance_for_storage(calculated_distance_meters)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class Import < ApplicationRecord
|
||||||
after_commit :remove_attached_file, on: :destroy
|
after_commit :remove_attached_file, on: :destroy
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
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 }
|
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
|
||||||
|
|
||||||
|
|
@ -58,4 +59,12 @@ class Import < ApplicationRecord
|
||||||
def remove_attached_file
|
def remove_attached_file
|
||||||
file.purge_later
|
file.purge_later
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,6 @@ class Trip < ApplicationRecord
|
||||||
user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
|
user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
|
||||||
end
|
end
|
||||||
|
|
||||||
def countries
|
|
||||||
return points.pluck(:country).uniq.compact if DawarichSettings.store_geodata?
|
|
||||||
|
|
||||||
visited_countries
|
|
||||||
end
|
|
||||||
|
|
||||||
def photo_previews
|
def photo_previews
|
||||||
@photo_previews ||= select_dominant_orientation(photos).sample(12)
|
@photo_previews ||= select_dominant_orientation(photos).sample(12)
|
||||||
end
|
end
|
||||||
|
|
@ -35,13 +29,8 @@ class Trip < ApplicationRecord
|
||||||
@photo_sources ||= photos.map { _1[:source] }.uniq
|
@photo_sources ||= photos.map { _1[:source] }.uniq
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_countries
|
def calculate_countries
|
||||||
countries =
|
self.visited_countries = points.pluck(:country_name).uniq.compact
|
||||||
Country.where(id: points.pluck(:country_id).compact.uniq).pluck(:name)
|
|
||||||
|
|
||||||
self.visited_countries = countries
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class User < ApplicationRecord
|
class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||||
devise :database_authenticatable, :registerable,
|
devise :database_authenticatable, :registerable,
|
||||||
:recoverable, :rememberable, :validatable, :trackable
|
:recoverable, :rememberable, :validatable, :trackable
|
||||||
|
|
||||||
|
|
@ -19,6 +19,8 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
after_create :create_api_key
|
after_create :create_api_key
|
||||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
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
|
before_save :sanitize_input
|
||||||
|
|
||||||
validates :email, presence: true
|
validates :email, presence: true
|
||||||
|
|
@ -27,7 +29,7 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
attribute :admin, :boolean, default: false
|
attribute :admin, :boolean, default: false
|
||||||
|
|
||||||
enum :status, { inactive: 0, active: 1 }
|
enum :status, { inactive: 0, active: 1, trial: 2 }
|
||||||
|
|
||||||
def safe_settings
|
def safe_settings
|
||||||
Users::SafeSettings.new(settings)
|
Users::SafeSettings.new(settings)
|
||||||
|
|
@ -97,7 +99,7 @@ class User < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def can_subscribe?
|
def can_subscribe?
|
||||||
(active_until.nil? || active_until&.past?) && !DawarichSettings.self_hosted?
|
(trial? || !active_until&.future?) && !DawarichSettings.self_hosted?
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_subscription_token
|
def generate_subscription_token
|
||||||
|
|
@ -116,6 +118,10 @@ class User < ApplicationRecord
|
||||||
Users::ExportDataJob.perform_later(id)
|
Users::ExportDataJob.perform_later(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def trial_state?
|
||||||
|
tracked_points.none? && trial?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def create_api_key
|
def create_api_key
|
||||||
|
|
@ -125,7 +131,6 @@ class User < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def activate
|
def activate
|
||||||
# TODO: Remove the `status` column in the future.
|
|
||||||
update(status: :active, active_until: 1000.years.from_now)
|
update(status: :active, active_until: 1000.years.from_now)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -134,4 +139,17 @@ class User < ApplicationRecord
|
||||||
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
|
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
|
||||||
settings.try(:[], 'maps')&.try(:[], 'url')&.strip!
|
settings.try(:[], 'maps')&.try(:[], 'url')&.strip!
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ class ImportPolicy < ApplicationPolicy
|
||||||
user.present? && record.user == user
|
user.present? && record.user == user
|
||||||
end
|
end
|
||||||
|
|
||||||
# Users can create new imports if they are active
|
# Users can create new imports if they are active or trial
|
||||||
def new?
|
def new?
|
||||||
create?
|
create?
|
||||||
end
|
end
|
||||||
|
|
||||||
def create?
|
def create?
|
||||||
user.present? && user.active?
|
user.present? && (user.active? || user.trial?)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Users can only edit their own imports
|
# Users can only edit their own imports
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,12 @@ class Imports::Create
|
||||||
end
|
end
|
||||||
|
|
||||||
def schedule_visit_suggesting(user_id, import)
|
def schedule_visit_suggesting(user_id, import)
|
||||||
|
return unless user.safe_settings.visits_suggestions_enabled?
|
||||||
|
|
||||||
points = import.points.order(:timestamp)
|
points = import.points.order(:timestamp)
|
||||||
|
|
||||||
|
return if points.none?
|
||||||
|
|
||||||
start_at = Time.zone.at(points.first.timestamp)
|
start_at = Time.zone.at(points.first.timestamp)
|
||||||
end_at = Time.zone.at(points.last.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-6">
|
||||||
<p class='py-2'>Use this API key to authenticate your requests.</p>
|
<p class='py-2'>Use this API key to authenticate your requests.</p>
|
||||||
<code><%= current_user.api_key %></code>
|
<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 class='py-2'>
|
||||||
<p>Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %></p>
|
<p>Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %></p>
|
||||||
|
|
||||||
|
|
@ -20,7 +28,6 @@
|
||||||
<div class='divider'>OR</div>
|
<div class='divider'>OR</div>
|
||||||
<h3 class='text-lg font-bold mt-4'>Overland</h3>
|
<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><code><%= api_v1_overland_batches_url(api_key: current_user.api_key) %></code></p>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<p class='py-2'>
|
<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' %>
|
<%= 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="hero-content flex-col lg:flex-row-reverse w-full my-5">
|
||||||
<div class="text-center lg:text-left">
|
<div class="text-center lg:text-left">
|
||||||
<h1 class="text-5xl font-bold mb-5">Edit your account!</h1>
|
<h1 class="text-5xl font-bold mb-5">Edit your account!</h1>
|
||||||
|
<% if current_user.active? %>
|
||||||
<%= render 'devise/registrations/api_key' %>
|
<%= 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? %>
|
<% if !DawarichSettings.self_hosted? %>
|
||||||
<%= render 'devise/registrations/points_usage' %>
|
<%= render 'devise/registrations/points_usage' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<%= form_with model: import, class: "contents", data: {
|
<%= form_with model: import, class: "contents", data: {
|
||||||
controller: "direct-upload",
|
controller: "direct-upload",
|
||||||
direct_upload_url_value: rails_direct_uploads_url,
|
direct_upload_url_value: rails_direct_uploads_url,
|
||||||
|
direct_upload_user_trial_value: current_user.trial?,
|
||||||
direct_upload_target: "form"
|
direct_upload_target: "form"
|
||||||
} do |form| %>
|
} do |form| %>
|
||||||
<div class="form-control w-full">
|
<div class="form-control w-full">
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,7 @@
|
||||||
</div>
|
</div>
|
||||||
<%= render SELF_HOSTED ? 'shared/footer' : 'shared/legal_footer' %>
|
<%= render SELF_HOSTED ? 'shared/footer' : 'shared/legal_footer' %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= render 'map/onboarding_modal' %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
<% if user_signed_in? && current_user.can_subscribe? %>
|
<% 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 %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -71,7 +73,15 @@
|
||||||
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
|
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
|
||||||
<% if user_signed_in? %>
|
<% if user_signed_in? %>
|
||||||
<% if current_user.can_subscribe? %>
|
<% 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 %>
|
<% end %>
|
||||||
|
|
||||||
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover"
|
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover"
|
||||||
|
|
@ -113,6 +123,9 @@
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
<%= "#{current_user.email}" %>
|
<%= "#{current_user.email}" %>
|
||||||
|
<% if onboarding_modal_showable?(current_user) %>
|
||||||
|
<span class="indicator-item badge badge-secondary badge-xs"></span>
|
||||||
|
<% end %>
|
||||||
<% if current_user.admin? %>
|
<% if current_user.admin? %>
|
||||||
<span class='tooltip tooltip-bottom' data-tip="You're an admin, Harry!">⭐️</span>
|
<span class='tooltip tooltip-bottom' data-tip="You're an admin, Harry!">⭐️</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -123,6 +136,14 @@
|
||||||
<% if !DawarichSettings.self_hosted? %>
|
<% if !DawarichSettings.self_hosted? %>
|
||||||
<li><%= link_to 'Subscription', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" %></li>
|
<li><%= link_to 'Subscription', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" %></li>
|
||||||
<% end %>
|
<% 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>
|
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo: false } %></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -137,4 +158,3 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,9 @@
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="stat-title text-xs">Countries</div>
|
<div class="stat-title text-xs">Countries</div>
|
||||||
<div class="stat-value text-lg">
|
<div class="stat-value text-lg">
|
||||||
<% if trip.countries.any? %>
|
<% if trip.visited_countries.any? %>
|
||||||
<%= trip.countries.join(', ') %>
|
|
||||||
<% elsif trip.visited_countries.present? %>
|
|
||||||
<%= trip.visited_countries.join(', ') %>
|
<%= trip.visited_countries.join(', ') %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-xs">Countries are being calculated...</span>
|
|
||||||
<span class="loading loading-dots loading-sm"></span>
|
<span class="loading loading-dots loading-sm"></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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
|
end
|
||||||
|
|
||||||
config.active_job.queue_adapter = :sidekiq
|
config.active_job.queue_adapter = :sidekiq
|
||||||
|
|
||||||
|
config.action_mailer.preview_paths << "#{Rails.root}/spec/mailers/previews"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
- data_migrations
|
- data_migrations
|
||||||
- points
|
- points
|
||||||
- default
|
- default
|
||||||
|
- mailers
|
||||||
- imports
|
- imports
|
||||||
- exports
|
- exports
|
||||||
- stats
|
- stats
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ FactoryBot.define do
|
||||||
active_until { 1.day.ago }
|
active_until { 1.day.ago }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :trial do
|
||||||
|
status { :trial }
|
||||||
|
active_until { 7.days.from_now }
|
||||||
|
end
|
||||||
|
|
||||||
trait :with_immich_integration do
|
trait :with_immich_integration do
|
||||||
settings 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'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Import, type: :model do
|
RSpec.describe Import, type: :model do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
subject(:import) { create(:import, user:) }
|
||||||
|
|
||||||
describe 'associations' do
|
describe 'associations' do
|
||||||
it { is_expected.to have_many(:points).dependent(:destroy) }
|
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
|
end
|
||||||
|
|
||||||
describe 'validations' do
|
describe 'validations' do
|
||||||
subject { build(:import, name: 'test import') }
|
|
||||||
|
|
||||||
it { is_expected.to validate_presence_of(:name) }
|
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
|
end
|
||||||
|
|
||||||
describe 'enums' do
|
describe 'enums' do
|
||||||
|
|
|
||||||
|
|
@ -26,34 +26,6 @@ RSpec.describe Trip, type: :model do
|
||||||
trip.save
|
trip.save
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
describe '#photo_previews' do
|
describe '#photo_previews' do
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ RSpec.describe User, type: :model do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'enums' do
|
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
|
end
|
||||||
|
|
||||||
describe 'callbacks' do
|
describe 'callbacks' do
|
||||||
|
|
@ -50,19 +50,108 @@ RSpec.describe User, type: :model do
|
||||||
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
|
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not activate user' do
|
it 'sets user to trial instead of active' do
|
||||||
user = create(:user, :inactive)
|
user = create(:user, :inactive)
|
||||||
|
|
||||||
expect(user.active?).to be_falsey
|
expect(user.trial?).to be_truthy
|
||||||
expect(user.active_until).to be_within(1.minute).of(1.day.ago)
|
expect(user.active_until).to be_within(1.minute).of(7.days.from_now)
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe 'methods' do
|
describe 'methods' do
|
||||||
let(:user) { create(:user) }
|
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
|
describe '#countries_visited' do
|
||||||
subject { user.countries_visited }
|
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) }
|
let(:user) { create(:user, status: :active, active_until: 1000.years.from_now) }
|
||||||
|
|
||||||
it 'returns false' do
|
it 'returns false' do
|
||||||
|
user.update(status: :active)
|
||||||
|
|
||||||
expect(user.can_subscribe?).to be_falsey
|
expect(user.can_subscribe?).to be_falsey
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when user is inactive' do
|
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
|
it 'returns true' do
|
||||||
expect(user.can_subscribe?).to be_truthy
|
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)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
context 'when user is authenticated' do
|
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('AF')
|
||||||
expect(response.body).to include('ZW')
|
expect(response.body).to include('ZW')
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,16 @@ RSpec.describe 'Imports', type: :request do
|
||||||
|
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
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
|
||||||
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
|
|
||||||
Loading…
Reference in a new issue