From f223feb854503b0bd46d33f4fb2ae988a4c7443b Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 20 Sep 2025 15:53:27 +0200 Subject: [PATCH 01/15] Add safety_assured block for index creation --- db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb index 78e4f3d2..fb6966c7 100644 --- a/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb +++ b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb @@ -5,8 +5,10 @@ class AddH3HexIdsToStats < ActiveRecord::Migration[8.0] def change add_column :stats, :h3_hex_ids, :jsonb, default: {} - add_index :stats, :h3_hex_ids, using: :gin, - where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)", - algorithm: :concurrently + safety_assured do + add_index :stats, :h3_hex_ids, using: :gin, + where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)", + algorithm: :concurrently + end end end From d6a32006323fc6aceada4e4a382d242eede3702b Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 20 Sep 2025 15:55:10 +0200 Subject: [PATCH 02/15] Update migration --- db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb index fb6966c7..cdb627e9 100644 --- a/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb +++ b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb @@ -4,11 +4,11 @@ class AddH3HexIdsToStats < ActiveRecord::Migration[8.0] disable_ddl_transaction! def change - add_column :stats, :h3_hex_ids, :jsonb, default: {} + add_column :stats, :h3_hex_ids, :jsonb, default: {}, if_not_exists: true safety_assured do add_index :stats, :h3_hex_ids, using: :gin, where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)", - algorithm: :concurrently + algorithm: :concurrently, if_not_exists: true end end end From c0e756d0853c1d492ef1be08700447c58777cfc1 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 12:46:59 +0200 Subject: [PATCH 03/15] Introduce iOS authentication flow with JWT token generation --- app/controllers/application_controller.rb | 15 ++++ app/controllers/auth/ios_controller.rb | 14 ++++ config/routes.rb | 3 + public/.well-known/apple-app-site-association | 3 +- spec/requests/authentication_spec.rb | 78 ++++++++++++++++++- 5 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 app/controllers/auth/ios_controller.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 29062343..96485374 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -39,6 +39,21 @@ class ApplicationController < ActionController::Base user_not_authorized end + def after_sign_in_path_for(resource) + payload = { api_key: resource.api_key, exp: 5.minutes.from_now.to_i } + + token = Subscription::EncodeJwtToken.new( + payload, ENV['AUTH_JWT_SECRET_KEY'] + ).call + + case request.headers['X-Dawarich-Client'] + when 'ios' + ios_success_path(token:) + else + super + end + end + private def set_self_hosted_status diff --git a/app/controllers/auth/ios_controller.rb b/app/controllers/auth/ios_controller.rb new file mode 100644 index 00000000..a3df4f5a --- /dev/null +++ b/app/controllers/auth/ios_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Auth + class IosController < ApplicationController + def success + render json: { + success: true, + message: 'iOS authentication successful', + token: params[:token], + redirect_url: root_url + }, status: :ok + end + end +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 1a592e5a..4424f062 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -85,6 +85,9 @@ Rails.application.routes.draw do root to: 'home#index' + # iOS mobile auth success endpoint + get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success + if SELF_HOSTED devise_for :users, skip: [:registrations] as :user do diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association index fbf3900a..b32ab0f3 100644 --- a/public/.well-known/apple-app-site-association +++ b/public/.well-known/apple-app-site-association @@ -1,7 +1,8 @@ { "webcredentials": { "apps": [ - "2A275P77DQ.app.dawarich.Dawarich" + "2A275P77DQ.app.dawarich.Dawarich", + "3DJN84WAS8.app.dawarich.Dawarich" ] } } diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb index eab3f9a0..f98efab8 100644 --- a/spec/requests/authentication_spec.rb +++ b/spec/requests/authentication_spec.rb @@ -6,9 +6,9 @@ RSpec.describe 'Authentication', type: :request do let(:user) { create(:user, password: 'password123') } before do - stub_request(:get, "https://api.github.com/repos/Freika/dawarich/tags") - .with(headers: { 'Accept'=>'*/*', 'Accept-Encoding'=>/.*/, - 'Host'=>'api.github.com', 'User-Agent'=>/.*/}) + stub_request(:get, 'https://api.github.com/repos/Freika/dawarich/tags') + .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => /.*/, + 'Host' => 'api.github.com', 'User-Agent' => /.*/ }) .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) end @@ -66,4 +66,76 @@ RSpec.describe 'Authentication', type: :request do expect(response).to redirect_to(new_user_session_path) end end + + describe 'Mobile iOS Authentication' do + it 'redirects to iOS success path when signing in with iOS client header' do + # Sign in with iOS client header + sign_in user + + # Mock the after_sign_in_path_for redirect behavior + allow_any_instance_of(ApplicationController).to receive(:after_sign_in_path_for).and_return(ios_success_path) + + # Make a request with the iOS client header + post user_session_path, params: { + user: { email: user.email, password: 'password123' } + }, headers: { 'X-Dawarich-Client' => 'ios' } + + # Should redirect to iOS success endpoint after successful login + expect(response).to redirect_to(ios_success_path) + end + + it 'returns JSON response with JWT token for iOS success endpoint' do + # Generate a test JWT token using the same service as the controller + payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i } + test_token = Subscription::EncodeJwtToken.new( + payload, ENV['AUTH_JWT_SECRET_KEY'] + ).call + + get ios_success_path, params: { token: test_token } + + expect(response).to be_successful + expect(response.content_type).to include('application/json') + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be true + expect(json_response['message']).to eq('iOS authentication successful') + expect(json_response['token']).to eq(test_token) + expect(json_response['redirect_url']).to eq(root_url) + end + + it 'generates JWT token with correct payload for iOS authentication' do + # Test JWT token generation directly using the same logic as after_sign_in_path_for + payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i } + + # Create JWT token using the same service + token = Subscription::EncodeJwtToken.new( + payload, ENV['AUTH_JWT_SECRET_KEY'] + ).call + + expect(token).to be_present + + # Decode the token to verify the payload + decoded_payload = JWT.decode( + token, + ENV['AUTH_JWT_SECRET_KEY'], + true, + { algorithm: 'HS256' } + ).first + + expect(decoded_payload['api_key']).to eq(user.api_key) + expect(decoded_payload['exp']).to be_present + end + + it 'uses default path for non-iOS clients' do + sign_in user + + # Make a request without iOS client header + post user_session_path, params: { + user: { email: user.email, password: 'password123' } + } + + # Should redirect to default path (not iOS success) + expect(response).not_to redirect_to(ios_success_path) + end + end end From 53472323767d48022ec8f9e1d19071b6a2532209 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 13:22:07 +0200 Subject: [PATCH 04/15] Update spec/requests/authentication_spec.rb --- spec/requests/authentication_spec.rb | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb index f98efab8..99b46959 100644 --- a/spec/requests/authentication_spec.rb +++ b/spec/requests/authentication_spec.rb @@ -69,19 +69,15 @@ RSpec.describe 'Authentication', type: :request do describe 'Mobile iOS Authentication' do it 'redirects to iOS success path when signing in with iOS client header' do - # Sign in with iOS client header - sign_in user - - # Mock the after_sign_in_path_for redirect behavior - allow_any_instance_of(ApplicationController).to receive(:after_sign_in_path_for).and_return(ios_success_path) - - # Make a request with the iOS client header + # Make a login request with the iOS client header (user NOT pre-signed in) post user_session_path, params: { user: { email: user.email, password: 'password123' } }, headers: { 'X-Dawarich-Client' => 'ios' } # Should redirect to iOS success endpoint after successful login - expect(response).to redirect_to(ios_success_path) + # The redirect will include a token parameter generated by after_sign_in_path_for + expect(response).to redirect_to(%r{auth/ios/success\?token=}) + expect(response.location).to include('token=') end it 'returns JSON response with JWT token for iOS success endpoint' do @@ -127,15 +123,14 @@ RSpec.describe 'Authentication', type: :request do end it 'uses default path for non-iOS clients' do - sign_in user - - # Make a request without iOS client header + # Make a login request without iOS client header (user NOT pre-signed in) post user_session_path, params: { user: { email: user.email, password: 'password123' } } # Should redirect to default path (not iOS success) - expect(response).not_to redirect_to(ios_success_path) + expect(response).not_to redirect_to(%r{auth/ios/success}) + expect(response.location).not_to include('auth/ios/success') end end end From 20c2bc34cdc7db25370cce37c3a61b955a500bb5 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 13:51:26 +0200 Subject: [PATCH 05/15] Store client header in session to persist across redirects --- app/controllers/application_controller.rb | 23 ++++++++++++++++------- spec/requests/authentication_spec.rb | 21 ++++++++++++++++++++- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 96485374..ba20b793 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,7 +5,7 @@ class ApplicationController < ActionController::Base rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized - before_action :unread_notifications, :set_self_hosted_status + before_action :unread_notifications, :set_self_hosted_status, :store_client_header protected @@ -40,14 +40,17 @@ class ApplicationController < ActionController::Base end def after_sign_in_path_for(resource) - payload = { api_key: resource.api_key, exp: 5.minutes.from_now.to_i } + # Check both current request header and stored session value + client_type = request.headers['X-Dawarich-Client'] || session[:dawarich_client] - token = Subscription::EncodeJwtToken.new( - payload, ENV['AUTH_JWT_SECRET_KEY'] - ).call - - case request.headers['X-Dawarich-Client'] + case client_type when 'ios' + payload = { api_key: resource.api_key, exp: 5.minutes.from_now.to_i } + + token = Subscription::EncodeJwtToken.new( + payload, ENV['AUTH_JWT_SECRET_KEY'] + ).call + ios_success_path(token:) else super @@ -60,6 +63,12 @@ class ApplicationController < ActionController::Base @self_hosted = DawarichSettings.self_hosted? end + def store_client_header + return unless request.headers['X-Dawarich-Client'] + + session[:dawarich_client] = request.headers['X-Dawarich-Client'] + end + def user_not_authorized redirect_back fallback_location: root_path, alert: 'You are not authorized to perform this action.', diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb index 99b46959..1494bca7 100644 --- a/spec/requests/authentication_spec.rb +++ b/spec/requests/authentication_spec.rb @@ -72,7 +72,10 @@ RSpec.describe 'Authentication', type: :request do # Make a login request with the iOS client header (user NOT pre-signed in) post user_session_path, params: { user: { email: user.email, password: 'password123' } - }, headers: { 'X-Dawarich-Client' => 'ios' } + }, headers: { + 'X-Dawarich-Client' => 'ios', + 'Accept' => 'text/html' + } # Should redirect to iOS success endpoint after successful login # The redirect will include a token parameter generated by after_sign_in_path_for @@ -80,6 +83,22 @@ RSpec.describe 'Authentication', type: :request do expect(response.location).to include('token=') end + it 'does not redirect to iOS success path when using turbo_stream format' do + # This test demonstrates the issue: when iOS app sends turbo_stream format, + # it doesn't get the iOS-specific redirect behavior + post user_session_path, params: { + user: { email: user.email, password: 'password123' } + }, headers: { + 'X-Dawarich-Client' => 'ios', + 'Accept' => 'text/vnd.turbo-stream.html' + } + + # With turbo_stream format, it doesn't redirect at all (no location header) + # This demonstrates why iOS authentication fails when using turbo_stream + expect(response.location).to be_nil + expect(response.status).to eq(200) # Returns turbo_stream response instead of redirect + end + it 'returns JSON response with JWT token for iOS success endpoint' do # Generate a test JWT token using the same service as the controller payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i } From 69cae258c99ebb292056cf52b61cdfa719aa42d3 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 14:00:24 +0200 Subject: [PATCH 06/15] Save html format if iOS client header is present --- app/controllers/application_controller.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ba20b793..b793156a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -67,6 +67,9 @@ class ApplicationController < ActionController::Base return unless request.headers['X-Dawarich-Client'] session[:dawarich_client] = request.headers['X-Dawarich-Client'] + + # Force HTML format for iOS clients to ensure proper authentication flow + request.format = :html if request.headers['X-Dawarich-Client'] == 'ios' end def user_not_authorized From c8d54f0ed668440b73e4d2b8d9cc9865066094cb Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 14:12:18 +0200 Subject: [PATCH 07/15] Update application_controller to store client header in session --- app/controllers/auth/ios_controller.rb | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/controllers/auth/ios_controller.rb b/app/controllers/auth/ios_controller.rb index a3df4f5a..d03a0e2f 100644 --- a/app/controllers/auth/ios_controller.rb +++ b/app/controllers/auth/ios_controller.rb @@ -3,12 +3,19 @@ module Auth class IosController < ApplicationController def success - render json: { - success: true, - message: 'iOS authentication successful', - token: params[:token], - redirect_url: root_url - }, status: :ok + # If token is provided, this is the final callback for ASWebAuthenticationSession + if params[:token].present? + # ASWebAuthenticationSession will capture this URL and extract the token + render plain: "Authentication successful! You can close this window.", status: :ok + else + # This should not happen with our current flow, but keeping for safety + render json: { + success: true, + message: 'iOS authentication successful', + redirect_url: root_url + }, status: :ok + end end + end end \ No newline at end of file From 550b405398c8353524ea0c8a71978367a125b896 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 15:18:11 +0200 Subject: [PATCH 08/15] Update form --- app/views/devise/sessions/new.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index d8cb0cde..1afd05ae 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -10,7 +10,7 @@ <% end %>
- <%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body ') do |f| %> + <%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
<%= f.label :email, class: 'label' do %> Email From e3795981e316fdeecc453d59bc523e5c0d8238bc Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 15:27:17 +0200 Subject: [PATCH 09/15] Update registratuion_controller.rb to handle turbo_stream format for iOS auth --- app/views/devise/registrations/new.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 1b0e0d85..bf654561 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -5,7 +5,7 @@

and take control over your location data.

- <%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body ') do |f| %> + <%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
<%= f.label :email, class: 'label' do %> Email From ce4fcc29c36a5caa1e94c35b4bbce76b44aa7610 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 15:41:07 +0200 Subject: [PATCH 10/15] Add data on subscription status to user serializer unless self-hosted --- app/serializers/api/user_serializer.rb | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/serializers/api/user_serializer.rb b/app/serializers/api/user_serializer.rb index d3e89dfe..9d54ec32 100644 --- a/app/serializers/api/user_serializer.rb +++ b/app/serializers/api/user_serializer.rb @@ -6,15 +6,19 @@ class Api::UserSerializer end def call - { + data = { user: { email: user.email, theme: user.theme, created_at: user.created_at, updated_at: user.updated_at, - settings: settings, + settings: settings } } + + data.merge!(subscription: subscription) unless DawarichSettings.self_hosted? + + data end private @@ -41,4 +45,11 @@ class Api::UserSerializer fog_of_war_threshold: user.safe_settings.fog_of_war_threshold } end + + def subscription + { + status: user.status, + active_until: user.active_until + } + end end From 6d97ecff3ce7f9e6a6ff4500ee546848f825d5f2 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 15:41:51 +0200 Subject: [PATCH 11/15] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa48f62..dc4da0a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Onboarding modal window now features a link to the App Store and a QR code to configure the Dawarich iOS app. - A permanent option was removed from stats sharing options. Now, stats can be shared for 1, 12 or 24 hours only. +## Added + +- Added foundation for upcoming authentication from iOS app. + # [0.32.0] - 2025-09-13 From 14f6f4dcc18b4becd6104c5d28bf4f5a3f77586c Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 16:27:43 +0200 Subject: [PATCH 12/15] Add new tests to cover ios auth --- app/controllers/application_controller.rb | 3 -- spec/requests/authentication_spec.rb | 36 ++++++++++++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b793156a..ba20b793 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -67,9 +67,6 @@ class ApplicationController < ActionController::Base return unless request.headers['X-Dawarich-Client'] session[:dawarich_client] = request.headers['X-Dawarich-Client'] - - # Force HTML format for iOS clients to ensure proper authentication flow - request.format = :html if request.headers['X-Dawarich-Client'] == 'ios' end def user_not_authorized diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb index 1494bca7..621a86cc 100644 --- a/spec/requests/authentication_spec.rb +++ b/spec/requests/authentication_spec.rb @@ -83,23 +83,30 @@ RSpec.describe 'Authentication', type: :request do expect(response.location).to include('token=') end - it 'does not redirect to iOS success path when using turbo_stream format' do - # This test demonstrates the issue: when iOS app sends turbo_stream format, - # it doesn't get the iOS-specific redirect behavior + it 'stores iOS client header in session' do + # Test that the header gets stored when accessing sign-in page + get new_user_session_path, headers: { 'X-Dawarich-Client' => 'ios' } + + expect(session[:dawarich_client]).to eq('ios') + end + + it 'redirects to iOS success path using stored session value' do + # Simulate iOS app accessing sign-in page first (stores header in session) + get new_user_session_path, headers: { 'X-Dawarich-Client' => 'ios' } + + # Then sign-in POST request without header (relies on session) post user_session_path, params: { user: { email: user.email, password: 'password123' } }, headers: { - 'X-Dawarich-Client' => 'ios', - 'Accept' => 'text/vnd.turbo-stream.html' + 'Accept' => 'text/html' } - # With turbo_stream format, it doesn't redirect at all (no location header) - # This demonstrates why iOS authentication fails when using turbo_stream - expect(response.location).to be_nil - expect(response.status).to eq(200) # Returns turbo_stream response instead of redirect + # Should still redirect to iOS success endpoint using session value + expect(response).to redirect_to(%r{auth/ios/success\?token=}) + expect(response.location).to include('token=') end - it 'returns JSON response with JWT token for iOS success endpoint' do + it 'returns plain text response for iOS success endpoint with token' do # Generate a test JWT token using the same service as the controller payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i } test_token = Subscription::EncodeJwtToken.new( @@ -108,13 +115,20 @@ RSpec.describe 'Authentication', type: :request do get ios_success_path, params: { token: test_token } + expect(response).to be_successful + expect(response.content_type).to include('text/plain') + expect(response.body).to eq('Authentication successful! You can close this window.') + end + + it 'returns JSON response when no token is provided to iOS success endpoint' do + get ios_success_path + expect(response).to be_successful expect(response.content_type).to include('application/json') json_response = JSON.parse(response.body) expect(json_response['success']).to be true expect(json_response['message']).to eq('iOS authentication successful') - expect(json_response['token']).to eq(test_token) expect(json_response['redirect_url']).to eq(root_url) end From 2af1aab787cae242b91963356270a98d132030bd Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 21 Sep 2025 16:33:45 +0200 Subject: [PATCH 13/15] Add specs for updated user serializer --- spec/serializers/api/user_serializer_spec.rb | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/spec/serializers/api/user_serializer_spec.rb b/spec/serializers/api/user_serializer_spec.rb index d4612fe9..d215f1e4 100644 --- a/spec/serializers/api/user_serializer_spec.rb +++ b/spec/serializers/api/user_serializer_spec.rb @@ -81,5 +81,61 @@ RSpec.describe Api::UserSerializer do expect(settings[:maps]).to eq({ 'distance_unit' => 'mi' }) end end + + context 'subscription data' do + context 'when not self-hosted (hosted instance)' do + before do + allow(DawarichSettings).to receive(:self_hosted?).and_return(false) + end + + it 'includes subscription data' do + expect(serializer).to have_key(:subscription) + expect(serializer[:subscription]).to include(:status, :active_until) + end + + it 'returns correct subscription values' do + subscription = serializer[:subscription] + expect(subscription[:status]).to eq(user.status) + expect(subscription[:active_until]).to eq(user.active_until) + end + + context 'with specific subscription values' do + it 'serializes trial user status correctly' do + # When not self-hosted, users start with trial status via start_trial callback + test_user = create(:user) + serializer_result = described_class.new(test_user).call + subscription = serializer_result[:subscription] + + expect(subscription[:status]).to eq('trial') + expect(subscription[:active_until]).to be_within(1.second).of(7.days.from_now) + end + + it 'serializes subscription data with all expected fields' do + test_user = create(:user) + serializer_result = described_class.new(test_user).call + subscription = serializer_result[:subscription] + + expect(subscription).to include(:status, :active_until) + expect(subscription[:status]).to be_a(String) + expect(subscription[:active_until]).to be_a(ActiveSupport::TimeWithZone) + end + end + end + + context 'when self-hosted' do + before do + allow(DawarichSettings).to receive(:self_hosted?).and_return(true) + end + + it 'does not include subscription data' do + expect(serializer).not_to have_key(:subscription) + end + + it 'still includes user and settings data' do + expect(serializer).to have_key(:user) + expect(serializer[:user]).to include(:email, :theme, :settings) + end + end + end end end From f8a05e68e3d2125ca7f5c2e310f1b678b87a1b31 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 22 Sep 2025 20:01:58 +0200 Subject: [PATCH 14/15] Fix api point serializer to return correct latitude and longitude values --- CHANGELOG.md | 1 + app/serializers/api/point_serializer.rb | 23 ++++++++++++++++--- .../location_search/result_aggregator.rb | 17 +++++++------- app/views/shared/_navbar.html.erb | 6 ++++- spec/serializers/api/point_serializer_spec.rb | 16 +++++++++++-- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc4da0a6..7d661d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Hexagons for the stats page are now being calculated a lot faster. - Prometheus exporter is now not being started when console is being run. - Stats will now properly reflect countries and cities visited after importing new points. +- `GET /api/v1/points will now return correct latitude and longitude values. #1502 ## Changed diff --git a/app/serializers/api/point_serializer.rb b/app/serializers/api/point_serializer.rb index e8484d38..1f5e3a0d 100644 --- a/app/serializers/api/point_serializer.rb +++ b/app/serializers/api/point_serializer.rb @@ -1,9 +1,26 @@ # frozen_string_literal: true -class Api::PointSerializer < PointSerializer - EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id import_id user_id raw_data country_id].freeze +class Api::PointSerializer + EXCLUDED_ATTRIBUTES = %w[ + created_at updated_at visit_id import_id user_id raw_data + country_id + ].freeze + + def initialize(point) + @point = point + end def call - point.attributes.except(*EXCLUDED_ATTRIBUTES) + point.attributes.except(*EXCLUDED_ATTRIBUTES).tap do |attributes| + lat = point.lat + lon = point.lon + + attributes['latitude'] = lat.nil? ? nil : lat.to_s + attributes['longitude'] = lon.nil? ? nil : lon.to_s + end end + + private + + attr_reader :point end diff --git a/app/services/location_search/result_aggregator.rb b/app/services/location_search/result_aggregator.rb index 0c28000a..52d5d950 100644 --- a/app/services/location_search/result_aggregator.rb +++ b/app/services/location_search/result_aggregator.rb @@ -48,15 +48,16 @@ module LocationSearch last_point = sorted_points.last # Calculate visit duration - duration_minutes = if sorted_points.length > 1 - ((last_point[:timestamp] - first_point[:timestamp]) / 60.0).round - else - # Single point visit - estimate based on typical stay time - 15 # minutes - end + duration_minutes = + if sorted_points.any? + ((last_point[:timestamp] - first_point[:timestamp]) / 60.0).round + else + # Single point visit - estimate based on typical stay time + 15 # minutes + end # Find the most accurate point (lowest accuracy value means higher precision) - most_accurate_point = points.min_by { |p| p[:accuracy] || 999999 } + most_accurate_point = points.min_by { |p| p[:accuracy] || 999_999 } # Calculate average distance from search center average_distance = (points.sum { |p| p[:distance_meters] } / points.length).round(2) @@ -86,7 +87,7 @@ module LocationSearch hours = minutes / 60 remaining_minutes = minutes % 60 - if remaining_minutes == 0 + if remaining_minutes.zero? "~#{pluralize(hours, 'hour')}" else "~#{pluralize(hours, 'hour')} #{pluralize(remaining_minutes, 'minute')}" diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index c00c405f..9778627c 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -76,7 +76,11 @@
<%= link_to "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" do %> - <%= (current_user.active_until.to_date - Time.current.to_date).to_i %> days remaining + <% if current_user.active_until.past? %> + Trial expired 🥺 + <% else %> + <%= (current_user.active_until.to_date - Time.current.to_date).to_i %> days remaining + <% end %> Subscribe diff --git a/spec/serializers/api/point_serializer_spec.rb b/spec/serializers/api/point_serializer_spec.rb index 8e7b51e5..4e4453e2 100644 --- a/spec/serializers/api/point_serializer_spec.rb +++ b/spec/serializers/api/point_serializer_spec.rb @@ -7,14 +7,26 @@ RSpec.describe Api::PointSerializer do subject(:serializer) { described_class.new(point).call } let(:point) { create(:point) } - let(:expected_json) { point.attributes.except(*Api::PointSerializer::EXCLUDED_ATTRIBUTES) } + let(:all_excluded) { PointSerializer::EXCLUDED_ATTRIBUTES + Api::PointSerializer::ADDITIONAL_EXCLUDED_ATTRIBUTES } + let(:expected_json) do + point.attributes.except(*all_excluded).tap do |attributes| + # API serializer extracts coordinates from PostGIS geometry + attributes['latitude'] = point.lat.to_s + attributes['longitude'] = point.lon.to_s + end + end it 'returns JSON with correct attributes' do expect(serializer.to_json).to eq(expected_json.to_json) end it 'does not include excluded attributes' do - expect(serializer).not_to include(*Api::PointSerializer::EXCLUDED_ATTRIBUTES) + expect(serializer).not_to include(*all_excluded) + end + + it 'extracts coordinates from PostGIS geometry' do + expect(serializer['latitude']).to eq(point.lat.to_s) + expect(serializer['longitude']).to eq(point.lon.to_s) end end end From 6e44425e4e71502209e3769779dcf8dc8eb814ce Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 22 Sep 2025 20:30:10 +0200 Subject: [PATCH 15/15] Recalculate stats when an import is deleted. --- CHANGELOG.md | 1 + app/models/import.rb | 11 +++++++++-- spec/models/import_spec.rb | 26 +++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d661d1f..e35e26e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Prometheus exporter is now not being started when console is being run. - Stats will now properly reflect countries and cities visited after importing new points. - `GET /api/v1/points will now return correct latitude and longitude values. #1502 +- Deleting an import will now trigger stats recalculation for affected months. #1789 ## Changed diff --git a/app/models/import.rb b/app/models/import.rb index 8635f2a9..b1abde92 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -11,6 +11,7 @@ class Import < ApplicationRecord after_commit -> { Import::ProcessJob.perform_later(id) unless skip_background_processing }, on: :create after_commit :remove_attached_file, on: :destroy + before_commit :recalculate_stats, on: :destroy, if: -> { points.exists? } validates :name, presence: true, uniqueness: { scope: :user_id } validate :file_size_within_limit, if: -> { user.trial? } @@ -63,8 +64,14 @@ class Import < ApplicationRecord 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.') + return unless file.blob.byte_size > 11.megabytes + + errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.') + end + + def recalculate_stats + years_and_months_tracked.each do |year, month| + Stats::CalculatingJob.perform_later(user.id, year, month) end end end diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index 50034082..fec9ad1f 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Import, type: :model do describe 'file size validation' do context 'when user is a trial user' do - let(:user) do + let(:user) do user = create(:user) user.update!(status: :trial) user @@ -116,4 +116,28 @@ RSpec.describe Import, type: :model do end end end + + describe '#recalculate_stats' do + let(:import) { create(:import, user:) } + let!(:point1) { create(:point, import:, user:, timestamp: Time.zone.local(2024, 11, 15).to_i) } + let!(:point2) { create(:point, import:, user:, timestamp: Time.zone.local(2024, 12, 5).to_i) } + + it 'enqueues stats calculation jobs for each tracked month' do + expect do + import.send(:recalculate_stats) + end.to have_enqueued_job(Stats::CalculatingJob) + .with(user.id, 2024, 11) + .and have_enqueued_job(Stats::CalculatingJob).with(user.id, 2024, 12) + end + + context 'when import has no points' do + let(:empty_import) { create(:import, user:) } + + it 'does not enqueue any jobs' do + expect do + empty_import.send(:recalculate_stats) + end.not_to have_enqueued_job(Stats::CalculatingJob) + end + end + end end