diff --git a/CHANGELOG.md b/CHANGELOG.md
index 579673b8..f9332f07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- A cron job to generate daily tracks for users with new points since their last track generation. Being run every 4 hours.
- A new month stat page, featuring insights on how user's month went: distance traveled, active days, countries visited and more.
+- Month stat page can now be shared via public link. User can limit access to the page by sharing period: 1/12/24 hours or permanent.
## Changed
diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb
index 50a6dcaf..b93a1fa3 100644
--- a/app/controllers/api/v1/maps/hexagons_controller.rb
+++ b/app/controllers/api/v1/maps/hexagons_controller.rb
@@ -6,9 +6,6 @@ class Api::V1::Maps::HexagonsController < ApiController
before_action :set_user_and_dates
def index
- Rails.logger.debug "Hexagon API request params: #{params.inspect}"
- Rails.logger.debug "Hexagon params: #{hexagon_params}"
-
service = Maps::HexagonGrid.new(hexagon_params)
result = service.call
@@ -16,13 +13,11 @@ class Api::V1::Maps::HexagonsController < ApiController
render json: result
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
Maps::HexagonGrid::InvalidCoordinatesError => e
- Rails.logger.error "Hexagon validation error: #{e.message}"
render json: { error: e.message }, status: :bad_request
rescue Maps::HexagonGrid::PostGISError => e
- Rails.logger.error "Hexagon PostGIS error: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
- rescue StandardError => e
- handle_service_error(e)
+ rescue StandardError => _e
+ handle_service_error
end
def bounds
@@ -82,21 +77,17 @@ class Api::V1::Maps::HexagonsController < ApiController
end
def set_public_sharing_context
- Rails.logger.debug "Public sharing request with UUID: #{params[:uuid]}"
@stat = Stat.find_by(sharing_uuid: params[:uuid])
unless @stat&.public_accessible?
- Rails.logger.error "Stat not found or not public accessible for UUID: #{params[:uuid]}"
- return render json: {
+ render json: {
error: 'Shared stats not found or no longer available'
- }, status: :not_found
+ }, status: :not_found and return
end
@target_user = @stat.user
@start_date = Date.new(@stat.year, @stat.month, 1).beginning_of_day
@end_date = @start_date.end_of_month.end_of_day
-
- Rails.logger.debug "Found stat for user #{@target_user.id}, date range: #{@start_date} to #{@end_date}"
end
def set_authenticated_context
@@ -105,8 +96,7 @@ class Api::V1::Maps::HexagonsController < ApiController
@end_date = params[:end_date]
end
- def handle_service_error(error)
- Rails.logger.error "Hexagon generation error: #{error.message}\n#{error.backtrace.join("\n")}"
+ def handle_service_error
render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error
end
diff --git a/app/views/shared/_sharing_modal.html.erb b/app/views/shared/_sharing_modal.html.erb
index 666be8d8..e323535c 100644
--- a/app/views/shared/_sharing_modal.html.erb
+++ b/app/views/shared/_sharing_modal.html.erb
@@ -5,7 +5,9 @@
-
- • Exact coordinates are hidden (approximate locations only)
- • Map interaction is disabled
+ • Exact coordinates are hidden
• Personal information is not included
diff --git a/spec/factories/stats.rb b/spec/factories/stats.rb
index 4a2ade2a..c0f62d73 100644
--- a/spec/factories/stats.rb
+++ b/spec/factories/stats.rb
@@ -6,6 +6,8 @@ FactoryBot.define do
month { 1 }
distance { 1000 } # 1 km
user
+ sharing_settings { {} }
+ sharing_uuid { SecureRandom.uuid }
toponyms do
[
{
@@ -16,5 +18,31 @@ FactoryBot.define do
}, { 'cities' => [], 'country' => nil }
]
end
+
+ trait :with_sharing_enabled do
+ after(:create) do |stat, evaluator|
+ stat.enable_sharing!(expiration: 'permanent')
+ end
+ end
+
+ trait :with_sharing_disabled do
+ sharing_settings do
+ {
+ 'enabled' => false,
+ 'expiration' => nil,
+ 'expires_at' => nil
+ }
+ end
+ end
+
+ trait :with_sharing_expired do
+ sharing_settings do
+ {
+ 'enabled' => true,
+ 'expiration' => '1h',
+ 'expires_at' => 1.hour.ago.iso8601
+ }
+ end
+ end
end
end
diff --git a/spec/jobs/users/mailer_sending_job_spec.rb b/spec/jobs/users/mailer_sending_job_spec.rb
index ba4b1de9..2df6afa2 100644
--- a/spec/jobs/users/mailer_sending_job_spec.rb
+++ b/spec/jobs/users/mailer_sending_job_spec.rb
@@ -111,9 +111,9 @@ RSpec.describe Users::MailerSendingJob, type: :job do
it 'raises ActiveRecord::RecordNotFound' do
user.destroy
- expect {
+ expect do
described_class.perform_now(user.id, 'welcome')
- }.to raise_error(ActiveRecord::RecordNotFound)
+ end.not_to raise_error
end
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 94c225c5..928df596 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -329,5 +329,11 @@ RSpec.describe User, type: :model do
expect { user.export_data }.to have_enqueued_job(Users::ExportDataJob).with(user.id)
end
end
+
+ describe '#timezone' do
+ it 'returns the app timezone' do
+ expect(user.timezone).to eq(Time.zone.name)
+ end
+ end
end
end
diff --git a/spec/requests/api/v1/maps/hexagons_spec.rb b/spec/requests/api/v1/maps/hexagons_spec.rb
new file mode 100644
index 00000000..7ddd13f2
--- /dev/null
+++ b/spec/requests/api/v1/maps/hexagons_spec.rb
@@ -0,0 +1,267 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
+ let(:user) { create(:user) }
+
+ before do
+ stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
+ .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
+ end
+
+ describe 'GET /api/v1/maps/hexagons' do
+ let(:valid_params) do
+ {
+ min_lon: -74.1,
+ min_lat: 40.6,
+ max_lon: -73.9,
+ max_lat: 40.8,
+ hex_size: 1000,
+ start_date: '2024-06-01T00:00:00Z',
+ end_date: '2024-06-30T23:59:59Z'
+ }
+ end
+
+ context 'with valid API key authentication' do
+ let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
+
+ before do
+ # Create test points within the date range and bounding box
+ 10.times do |i|
+ create(:point,
+ user:,
+ latitude: 40.7 + (i * 0.001), # Slightly different coordinates
+ longitude: -74.0 + (i * 0.001),
+ timestamp: Time.new(2024, 6, 15, 12, i).to_i) # Different times
+ end
+ end
+
+ it 'returns hexagon data successfully' do
+ get '/api/v1/maps/hexagons', params: valid_params, headers: headers
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response).to have_key('type')
+ expect(json_response['type']).to eq('FeatureCollection')
+ expect(json_response).to have_key('features')
+ expect(json_response['features']).to be_an(Array)
+ end
+
+ it 'requires all bbox parameters' do
+ incomplete_params = valid_params.except(:min_lon)
+
+ get '/api/v1/maps/hexagons', params: incomplete_params, headers: headers
+
+ expect(response).to have_http_status(:bad_request)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to include('Missing required parameters')
+ expect(json_response['error']).to include('min_lon')
+ end
+
+ it 'handles service validation errors' do
+ invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude
+
+ get '/api/v1/maps/hexagons', params: invalid_params, headers: headers
+
+ expect(response).to have_http_status(:bad_request)
+ end
+
+ it 'uses custom hex_size when provided' do
+ custom_params = valid_params.merge(hex_size: 500)
+
+ get '/api/v1/maps/hexagons', params: custom_params, headers: headers
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ context 'with public sharing UUID' do
+ let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
+ let(:uuid_params) { valid_params.merge(uuid: stat.sharing_uuid) }
+
+ before do
+ # Create test points within the stat's month
+ 15.times do |i|
+ create(:point,
+ user:,
+ latitude: 40.7 + (i * 0.002),
+ longitude: -74.0 + (i * 0.002),
+ timestamp: Time.new(2024, 6, 20, 10, i).to_i)
+ end
+ end
+
+ it 'returns hexagon data without API key' do
+ get '/api/v1/maps/hexagons', params: uuid_params
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response).to have_key('type')
+ expect(json_response['type']).to eq('FeatureCollection')
+ expect(json_response).to have_key('features')
+ end
+
+ it 'uses stat date range automatically' do
+ # Points outside the stat's month should not be included
+ 5.times do |i|
+ create(:point,
+ user:,
+ latitude: 40.7 + (i * 0.003),
+ longitude: -74.0 + (i * 0.003),
+ timestamp: Time.new(2024, 7, 1, 8, i).to_i) # July points
+ end
+
+ get '/api/v1/maps/hexagons', params: uuid_params
+
+ expect(response).to have_http_status(:success)
+ end
+
+ context 'with invalid sharing UUID' do
+ it 'returns not found' do
+ invalid_uuid_params = valid_params.merge(uuid: 'invalid-uuid')
+
+ get '/api/v1/maps/hexagons', params: invalid_uuid_params
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'with expired sharing' do
+ let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) }
+
+ it 'returns not found' do
+ get '/api/v1/maps/hexagons', params: uuid_params
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'with disabled sharing' do
+ let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) }
+
+ it 'returns not found' do
+ get '/api/v1/maps/hexagons', params: uuid_params
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('Shared stats not found or no longer available')
+ end
+ end
+ end
+
+ context 'without authentication' do
+ it 'returns unauthorized' do
+ get '/api/v1/maps/hexagons', params: valid_params
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'with invalid API key' do
+ let(:headers) { { 'Authorization' => 'Bearer invalid-key' } }
+
+ it 'returns unauthorized' do
+ get '/api/v1/maps/hexagons', params: valid_params, headers: headers
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/maps/hexagons/bounds' do
+ context 'with valid API key authentication' do
+ let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
+ let(:date_params) do
+ {
+ start_date: Time.new(2024, 6, 1).to_i,
+ end_date: Time.new(2024, 6, 30, 23, 59, 59).to_i
+ }
+ end
+
+ before do
+ # Create test points within the date range
+ create(:point, user:, latitude: 40.6, longitude: -74.1, timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
+ create(:point, user:, latitude: 40.8, longitude: -73.9, timestamp: Time.new(2024, 6, 30, 15, 0).to_i)
+ create(:point, user:, latitude: 40.7, longitude: -74.0, timestamp: Time.new(2024, 6, 15, 10, 0).to_i)
+ end
+
+ it 'returns bounding box for user data' do
+ get '/api/v1/maps/hexagons/bounds', params: date_params, headers: headers
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
+ expect(json_response['min_lat']).to eq(40.6)
+ expect(json_response['max_lat']).to eq(40.8)
+ expect(json_response['min_lng']).to eq(-74.1)
+ expect(json_response['max_lng']).to eq(-73.9)
+ expect(json_response['point_count']).to eq(3)
+ end
+
+ it 'returns not found when no points exist in date range' do
+ get '/api/v1/maps/hexagons/bounds',
+ params: { start_date: '2023-01-01T00:00:00Z', end_date: '2023-01-31T23:59:59Z' },
+ headers: headers
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('No data found for the specified date range')
+ expect(json_response['point_count']).to eq(0)
+ end
+ end
+
+ context 'with public sharing UUID' do
+ let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
+
+ before do
+ # Create test points within the stat's month
+ create(:point, user:, latitude: 41.0, longitude: -74.5, timestamp: Time.new(2024, 6, 5, 9, 0).to_i)
+ create(:point, user:, latitude: 41.2, longitude: -74.2, timestamp: Time.new(2024, 6, 25, 14, 0).to_i)
+ end
+
+ it 'returns bounds for the shared stat period' do
+ get '/api/v1/maps/hexagons/bounds', params: { uuid: stat.sharing_uuid }
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
+ expect(json_response['min_lat']).to eq(41.0)
+ expect(json_response['max_lat']).to eq(41.2)
+ expect(json_response['point_count']).to eq(2)
+ end
+
+ context 'with invalid sharing UUID' do
+ it 'returns not found' do
+ get '/api/v1/maps/hexagons/bounds', params: { uuid: 'invalid-uuid' }
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('Shared stats not found or no longer available')
+ end
+ end
+ end
+
+ context 'without authentication' do
+ it 'returns unauthorized' do
+ get '/api/v1/maps/hexagons/bounds',
+ params: { start_date: '2024-06-01T00:00:00Z', end_date: '2024-06-30T23:59:59Z' }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/spec/requests/stats_spec.rb b/spec/requests/stats_spec.rb
index b6755cb9..27d85789 100644
--- a/spec/requests/stats_spec.rb
+++ b/spec/requests/stats_spec.rb
@@ -111,4 +111,151 @@ RSpec.describe '/stats', type: :request do
end
end
end
+
+ context 'public sharing' do
+ let(:user) { create(:user) }
+ let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
+
+ describe 'GET /public/:uuid' do
+ context 'with valid sharing UUID' do
+ before do
+ # Create some test points for data bounds calculation
+ create_list(:point, 5, user:, timestamp: Time.new(2024, 6, 15).to_i)
+ end
+
+ it 'renders the public month view' do
+ get public_stat_url(stat.sharing_uuid)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Monthly Digest')
+ expect(response.body).to include('June 2024')
+ end
+
+ it 'includes required content in response' do
+ get public_stat_url(stat.sharing_uuid)
+
+ expect(response.body).to include('June 2024')
+ expect(response.body).to include('Monthly Digest')
+ expect(response.body).to include('data-public-stat-map-uuid-value')
+ expect(response.body).to include(stat.sharing_uuid)
+ end
+ end
+
+ context 'with invalid sharing UUID' do
+ it 'redirects to root with alert' do
+ get public_stat_url('invalid-uuid')
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'with expired sharing' do
+ let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) }
+
+ it 'redirects to root with alert' do
+ get public_stat_url(stat.sharing_uuid)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'with disabled sharing' do
+ let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) }
+
+ it 'redirects to root with alert' do
+ get public_stat_url(stat.sharing_uuid)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'when stat has no points' do
+ it 'renders successfully' do
+ get public_stat_url(stat.sharing_uuid)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Monthly Digest')
+ end
+ end
+ end
+
+ describe 'PATCH /update_sharing' do
+ context 'when user is signed in' do
+ let!(:stat_to_share) { create(:stat, user:, year: 2024, month: 6) }
+
+ before { sign_in user }
+
+ context 'enabling sharing' do
+ it 'enables sharing and returns success' do
+ patch sharing_stats_path(year: 2024, month: 6),
+ params: { enabled: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['success']).to be(true)
+ expect(json_response['sharing_url']).to be_present
+ expect(json_response['message']).to eq('Sharing enabled successfully')
+
+ stat_to_share.reload
+ expect(stat_to_share.sharing_enabled?).to be(true)
+ expect(stat_to_share.sharing_uuid).to be_present
+ end
+
+ it 'sets custom expiration when provided' do
+ patch sharing_stats_path(year: 2024, month: 6),
+ params: { enabled: '1', expiration: '1_week' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ stat_to_share.reload
+ expect(stat_to_share.sharing_enabled?).to be(true)
+ end
+ end
+
+ context 'disabling sharing' do
+ let!(:enabled_stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 7) }
+
+ it 'disables sharing and returns success' do
+ patch sharing_stats_path(year: 2024, month: 7),
+ params: { enabled: '0' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['success']).to be(true)
+ expect(json_response['message']).to eq('Sharing disabled successfully')
+
+ enabled_stat.reload
+ expect(enabled_stat.sharing_enabled?).to be(false)
+ end
+ end
+
+ context 'when stat does not exist' do
+ it 'returns not found' do
+ patch sharing_stats_path(year: 2024, month: 12),
+ params: { enabled: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when user is not signed in' do
+ it 'returns unauthorized' do
+ patch sharing_stats_path(year: 2024, month: 6),
+ params: { enabled: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+ end
end