├── .app_version ├── .circleci └── config.yml ├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── .env.development ├── .env.template ├── .env.test ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ ├── build_and_push.yml │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── Procfile.dev ├── Procfile.prometheus.dev ├── README.md ├── Rakefile ├── app.json ├── app ├── assets │ ├── builds │ │ ├── .keep │ │ └── tailwind.css │ ├── config │ │ └── manifest.js │ ├── images │ │ ├── .keep │ │ ├── favicon.jpeg │ │ └── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── browserconfig.xml.erb │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── mstile-150x150.png │ │ │ ├── safari-pinned-tab.svg │ │ │ └── site.webmanifest.erb │ └── stylesheets │ │ ├── actiontext.css │ │ ├── application.css │ │ └── application.tailwind.css ├── channels │ ├── application_cable │ │ ├── channel.rb │ │ └── connection.rb │ ├── imports_channel.rb │ ├── notifications_channel.rb │ └── points_channel.rb ├── controllers │ ├── api │ │ └── v1 │ │ │ ├── areas_controller.rb │ │ │ ├── countries │ │ │ ├── borders_controller.rb │ │ │ └── visited_cities_controller.rb │ │ │ ├── health_controller.rb │ │ │ ├── maps │ │ │ └── tile_usage_controller.rb │ │ │ ├── overland │ │ │ └── batches_controller.rb │ │ │ ├── owntracks │ │ │ └── points_controller.rb │ │ │ ├── photos_controller.rb │ │ │ ├── points │ │ │ └── tracked_months_controller.rb │ │ │ ├── points_controller.rb │ │ │ ├── settings_controller.rb │ │ │ ├── stats_controller.rb │ │ │ ├── subscriptions_controller.rb │ │ │ ├── users_controller.rb │ │ │ ├── visits │ │ │ └── possible_places_controller.rb │ │ │ └── visits_controller.rb │ ├── api_controller.rb │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ ├── exports_controller.rb │ ├── home_controller.rb │ ├── imports_controller.rb │ ├── map_controller.rb │ ├── notifications_controller.rb │ ├── places_controller.rb │ ├── points_controller.rb │ ├── settings │ │ ├── background_jobs_controller.rb │ │ ├── maps_controller.rb │ │ └── users_controller.rb │ ├── settings_controller.rb │ ├── stats_controller.rb │ ├── trips_controller.rb │ └── visits_controller.rb ├── helpers │ ├── application_helper.rb │ ├── country_flag_helper.rb │ ├── points_helper.rb │ └── trips_helper.rb ├── javascript │ ├── application.js │ ├── channels │ │ ├── consumer.js │ │ ├── imports_channel.js │ │ ├── index.js │ │ ├── notifications_channel.js │ │ └── points_channel.js │ ├── controllers │ │ ├── application.js │ │ ├── base_controller.js │ │ ├── checkbox_select_all_controller.js │ │ ├── datetime_controller.js │ │ ├── direct_upload_controller.js │ │ ├── imports_controller.js │ │ ├── index.js │ │ ├── map_preview_controller.js │ │ ├── maps_controller.js │ │ ├── notifications_controller.js │ │ ├── removals_controller.js │ │ ├── trip_map_controller.js │ │ ├── trips_controller.js │ │ ├── visit_modal_map_controller.js │ │ ├── visit_modal_places_controller.js │ │ ├── visit_name_controller.js │ │ └── visits_map_controller.js │ ├── maps │ │ ├── areas.js │ │ ├── country_codes.js │ │ ├── fog_of_war.js │ │ ├── helpers.js │ │ ├── layers.js │ │ ├── markers.js │ │ ├── polylines.js │ │ ├── popups.js │ │ ├── raster_maps_config.js │ │ ├── tile_monitor.js │ │ ├── vector_maps_config.js │ │ └── visits.js │ └── styles │ │ └── visits.css ├── jobs │ ├── app_version_checking_job.rb │ ├── application_job.rb │ ├── area_visits_calculating_job.rb │ ├── area_visits_calculation_scheduling_job.rb │ ├── bulk_stats_calculating_job.rb │ ├── bulk_visits_suggesting_job.rb │ ├── cache │ │ ├── cleaning_job.rb │ │ └── preheating_job.rb │ ├── data_migrations │ │ ├── migrate_places_lonlat_job.rb │ │ ├── migrate_points_latlon_job.rb │ │ ├── set_points_country_ids_job.rb │ │ ├── set_reverse_geocoded_at_for_points_job.rb │ │ └── start_settings_points_country_ids_job.rb │ ├── enqueue_background_job.rb │ ├── export_job.rb │ ├── import │ │ ├── google_takeout_job.rb │ │ ├── immich_geodata_job.rb │ │ ├── photoprism_geodata_job.rb │ │ ├── process_job.rb │ │ ├── update_points_count_job.rb │ │ └── watcher_job.rb │ ├── jobs │ │ └── clean_finished_job.rb │ ├── overland │ │ └── batch_creating_job.rb │ ├── owntracks │ │ └── point_creating_job.rb │ ├── points │ │ └── create_job.rb │ ├── reverse_geocoding_job.rb │ ├── stats │ │ └── calculating_job.rb │ ├── trips │ │ ├── calculate_all_job.rb │ │ ├── calculate_countries_job.rb │ │ ├── calculate_distance_job.rb │ │ └── calculate_path_job.rb │ └── visit_suggesting_job.rb ├── mailers │ └── application_mailer.rb ├── models │ ├── application_record.rb │ ├── area.rb │ ├── concerns │ │ ├── .keep │ │ ├── distanceable.rb │ │ ├── nearable.rb │ │ └── point_validation.rb │ ├── country.rb │ ├── export.rb │ ├── import.rb │ ├── notification.rb │ ├── place.rb │ ├── place_visit.rb │ ├── point.rb │ ├── stat.rb │ ├── trip.rb │ ├── user.rb │ ├── visit.rb │ └── visit_draft.rb ├── policies │ └── application_policy.rb ├── serializers │ ├── api │ │ ├── photo_serializer.rb │ │ ├── place_serializer.rb │ │ ├── point_serializer.rb │ │ ├── slim_point_serializer.rb │ │ └── visit_serializer.rb │ ├── export_serializer.rb │ ├── point_serializer.rb │ ├── points │ │ ├── geojson_serializer.rb │ │ └── gpx_serializer.rb │ └── stats_serializer.rb ├── services │ ├── areas │ │ └── visits │ │ │ └── create.rb │ ├── cache │ │ └── clean.rb │ ├── check_app_version.rb │ ├── countries_and_cities.rb │ ├── exception_reporter.rb │ ├── exports │ │ └── create.rb │ ├── geojson │ │ ├── importer.rb │ │ └── params.rb │ ├── google_maps │ │ ├── phone_takeout_importer.rb │ │ ├── records_importer.rb │ │ ├── records_storage_importer.rb │ │ └── semantic_history_importer.rb │ ├── gpx │ │ └── track_importer.rb │ ├── immich │ │ ├── import_geodata.rb │ │ └── request_photos.rb │ ├── imports │ │ ├── broadcaster.rb │ │ ├── create.rb │ │ ├── destroy.rb │ │ ├── secure_file_downloader.rb │ │ └── watcher.rb │ ├── jobs │ │ └── create.rb │ ├── metrics │ │ └── maps │ │ │ └── tile_usage │ │ │ └── track.rb │ ├── notifications │ │ └── create.rb │ ├── overland │ │ └── params.rb │ ├── own_tracks │ │ ├── importer.rb │ │ ├── params.rb │ │ └── rec_parser.rb │ ├── photoprism │ │ ├── cache_preview_token.rb │ │ ├── import_geodata.rb │ │ └── request_photos.rb │ ├── photos │ │ ├── importer.rb │ │ ├── search.rb │ │ └── thumbnail.rb │ ├── points │ │ ├── create.rb │ │ ├── params.rb │ │ └── raw_data_lonlat_extractor.rb │ ├── points_limit_exceeded.rb │ ├── reverse_geocoding │ │ ├── places │ │ │ └── fetch_data.rb │ │ └── points │ │ │ └── fetch_data.rb │ ├── stats │ │ ├── bulk_calculator.rb │ │ └── calculate_month.rb │ ├── subscription │ │ └── decode_jwt_token.rb │ ├── tasks │ │ └── imports │ │ │ └── google_records.rb │ ├── tracks │ │ └── build_path.rb │ ├── trips │ │ └── photos.rb │ ├── users │ │ └── safe_settings.rb │ └── visits │ │ ├── bulk_update.rb │ │ ├── creator.rb │ │ ├── detector.rb │ │ ├── find_in_time.rb │ │ ├── find_within_bounding_box.rb │ │ ├── finder.rb │ │ ├── group.rb │ │ ├── merge_service.rb │ │ ├── merger.rb │ │ ├── names │ │ ├── builder.rb │ │ ├── fetcher.rb │ │ └── suggester.rb │ │ ├── place_finder.rb │ │ ├── smart_detect.rb │ │ ├── suggest.rb │ │ └── time_chunks.rb └── views │ ├── active_storage │ └── blobs │ │ └── _blob.html.erb │ ├── application │ └── _favicon.html.erb │ ├── devise │ ├── confirmations │ │ └── new.html.erb │ ├── mailer │ │ ├── confirmation_instructions.html.erb │ │ ├── email_changed.html.erb │ │ ├── password_change.html.erb │ │ ├── reset_password_instructions.html.erb │ │ └── unlock_instructions.html.erb │ ├── passwords │ │ ├── edit.html.erb │ │ └── new.html.erb │ ├── registrations │ │ ├── _api_key.html.erb │ │ ├── _points_usage.html.erb │ │ ├── edit.html.erb │ │ └── new.html.erb │ ├── sessions │ │ └── new.html.erb │ ├── shared │ │ ├── _error_messages.html.erb │ │ └── _links.html.erb │ └── unlocks │ │ └── new.html.erb │ ├── exports │ └── index.html.erb │ ├── home │ └── index.html.erb │ ├── imports │ ├── _form.html.erb │ ├── _import.html.erb │ ├── edit.html.erb │ ├── index.html.erb │ ├── new.html.erb │ └── show.html.erb │ ├── layouts │ ├── action_text │ │ └── contents │ │ │ └── _content.html.erb │ ├── application.html.erb │ ├── mailer.html.erb │ └── mailer.text.erb │ ├── map │ ├── _settings_modals.html.erb │ └── index.html.erb │ ├── notifications │ ├── _notification.html.erb │ ├── index.html.erb │ └── show.html.erb │ ├── places │ └── index.html.erb │ ├── points │ ├── _point.html.erb │ └── index.html.erb │ ├── settings │ ├── _navigation.html.erb │ ├── background_jobs │ │ └── index.html.erb │ ├── index.html.erb │ ├── maps │ │ └── index.html.erb │ ├── subscriptions │ │ └── index.html.erb │ └── users │ │ ├── edit.html.erb │ │ └── index.html.erb │ ├── shared │ ├── _flash.html.erb │ ├── _footer.html.erb │ ├── _legal_footer.html.erb │ └── _navbar.html.erb │ ├── stats │ ├── _reverse_geocoding_stats.html.erb │ ├── _stat.html.erb │ ├── _year.html.erb │ ├── index.html.erb │ └── show.html.erb │ ├── trips │ ├── _countries.html.erb │ ├── _distance.html.erb │ ├── _form.html.erb │ ├── _path.html.erb │ ├── _trip.html.erb │ ├── edit.html.erb │ ├── index.html.erb │ ├── new.html.erb │ └── show.html.erb │ └── visits │ ├── _buttons.html.erb │ ├── _modal.html.erb │ ├── _name.html.erb │ ├── _visit.html.erb │ └── index.html.erb ├── bin ├── dev ├── importmap ├── jobs ├── rails ├── rake ├── rubocop └── setup ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── cache.yml ├── database.ci.yml ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── favicon.json ├── importmap.rb ├── initializers │ ├── 00_random.rb │ ├── 01_constants.rb │ ├── 03_dawarich_settings.rb │ ├── assets.rb │ ├── aws.rb │ ├── cache_jobs.rb │ ├── content_security_policy.rb │ ├── devise.rb │ ├── filter_parameter_logging.rb │ ├── geocoder.rb │ ├── httparty.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── new_framework_defaults_8_0.rb │ ├── permissions_policy.rb │ ├── prometheus.rb │ ├── rswag_api.rb │ ├── rswag_ui.rb │ ├── sentry.rb │ ├── sidekiq.rb │ ├── strong_migrations.rb │ └── web_app_manifest.rb ├── locales │ ├── devise.en.yml │ └── en.yml ├── puma.rb ├── queue.yml ├── recurring.yml ├── routes.rb ├── schedule.yml ├── sidekiq.yml ├── storage.yml └── tailwind.config.js ├── db ├── cable_schema.rb ├── cache_schema.rb ├── data │ ├── 20240525110530_bind_existing_points_to_first_user.rb │ ├── 20240610170930_remove_points_without_coordinates.rb │ ├── 20240625201842_add_fog_of_war_meters_to_settings.rb │ ├── 20240713103122_make_first_user_admin.rb │ ├── 20240724141417_add_visit_settings_to_user.rb │ ├── 20240730130922_add_route_opacity_to_settings.rb │ ├── 20240808133112_run_initial_visit_suggestion.rb │ ├── 20240815174852_add_owntracks_points_data.rb │ ├── 20240822094532_add_counter_cache_to_imports.rb │ ├── 20241022100309_add_points_rendering_mode_to_settings.rb │ ├── 20241107112451_add_live_map_enabled_to_settings.rb │ ├── 20241202125248_set_reverse_geocoded_at_for_points.rb │ ├── 20241206163450_create_telemetry_notification.rb │ ├── 20250104204852_create_photon_load_notification.rb │ ├── 20250120154554_remove_duplicate_points.rb │ ├── 20250123151849_create_paths_for_trips.rb │ ├── 20250222213848_migrate_points_latlon.rb │ ├── 20250226192005_activate_selfhosted_users.rb │ ├── 20250303194123_migrate_places_lonlat.rb │ ├── 20250403204658_update_imports_points_count.rb │ ├── 20250404182629_set_active_until_for_selfhosted_users.rb │ ├── 20250516180933_set_points_country_ids.rb │ ├── 20250518173936_fix_france_codes.rb │ └── 20250518174305_set_default_distance_unit_for_user.rb ├── data_schema.rb ├── migrate │ ├── 20220325100310_devise_create_users.rb │ ├── 20231021104256_add_service_name_to_active_storage_blobs.active_storage.rb │ ├── 20231021104257_create_active_storage_variant_records.active_storage.rb │ ├── 20231021104258_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb │ ├── 20240315213523_create_points.rb │ ├── 20240315215423_create_imports.rb │ ├── 20240317171559_add_indicies_to_points_latitude_longitude.rb │ ├── 20240323125126_add_raw_points_and_doubles_to_import.rb │ ├── 20240323160300_create_stats.rb │ ├── 20240323161049_add_index_to_points_timestamp.rb │ ├── 20240323190039_add_user_id_to_stat.rb │ ├── 20240324161309_create_active_storage_tables.active_storage.rb │ ├── 20240324161800_add_processed_to_imports.rb │ ├── 20240324173315_add_daily_distance_to_stat.rb │ ├── 20240404154959_add_api_key_to_users.rb │ ├── 20240425200155_add_raw_data_to_imports.rb │ ├── 20240518095848_add_theme_to_users.rb │ ├── 20240525110244_add_user_id_to_points.rb │ ├── 20240612152451_create_exports.rb │ ├── 20240620205120_add_settings_to_users.rb │ ├── 20240630093005_add_fog_of_war_to_default_settings.rb │ ├── 20240703105734_create_notifications.rb │ ├── 20240712141303_add_geodata_to_points.rb │ ├── 20240713103051_add_admin_to_users.rb │ ├── 20240721165313_create_areas.rb │ ├── 20240721183005_create_visits.rb │ ├── 20240721183116_add_visit_id_to_points.rb │ ├── 20240805150111_create_places.rb │ ├── 20240808102348_add_place_id_to_visits.rb │ ├── 20240808102425_make_area_id_optional_in_visits.rb │ ├── 20240808121027_create_place_visits.rb │ ├── 20240822092405_add_points_count_to_imports.rb │ ├── 20241127161621_create_trips.rb │ ├── 20241128095325_create_action_text_tables.action_text.rb │ ├── 20241202114820_add_reverse_geocoded_at_to_points.rb │ ├── 20241205160055_add_devise_trackable_columns_to_users.rb │ ├── 20241211113119_add_started_at_index_to_visits.rb │ ├── 20241226202204_add_database_users_constraints.rb │ ├── 20241226202831_validate_add_database_users_constraints.rb │ ├── 20250120152014_add_course_and_course_accuracy_to_points.rb │ ├── 20250120152540_add_external_track_id_to_points.rb │ ├── 20250120154555_add_unique_index_to_points.rb │ ├── 20250123145155_enable_postgis_extension.rb │ ├── 20250123151657_add_path_to_trips.rb │ ├── 20250219195822_add_status_to_users.rb │ ├── 20250221181805_add_lonlat_to_points.rb │ ├── 20250221185032_add_lonlat_index.rb │ ├── 20250221194430_remove_points_latitude_longitude_uniqueness_index.rb │ ├── 20250221194509_add_unique_lon_lat_index_to_points.rb │ ├── 20250303194009_add_lonlat_to_places.rb │ ├── 20250303194043_add_lonlat_index_to_places.rb │ ├── 20250324180755_add_format_start_at_end_at_to_exports.rb │ ├── 20250404182437_add_active_until_to_users.rb │ ├── 20250513164521_add_visited_countries_to_trips.rb │ ├── 20250515190752_create_countries.rb │ └── 20250515192211_add_country_id_to_points.rb ├── queue_schema.rb ├── schema.rb └── seeds.rb ├── docker ├── .dockerignore ├── Dockerfile.dev ├── Dockerfile.prod ├── docker-compose.production.yml ├── docker-compose.yml ├── sidekiq-entrypoint.sh └── web-entrypoint.sh ├── docs ├── How_to_extract_geodata_from_photos.md ├── How_to_install_Dawarich_in_k8s.md ├── How_to_install_Dawarich_on_Synology.md ├── How_to_install_Dawarich_using_Docker.md ├── how_to_setup_reverse_proxy.md └── synology │ ├── .env │ ├── docker-compose.yml │ ├── spk.tgz │ └── update.sh ├── lib ├── assets │ ├── .keep │ └── countries.geojson ├── tasks │ ├── .keep │ ├── data_cleanup.rake │ ├── exports.rake │ ├── import.rake │ ├── imports.rake │ ├── points.rake │ ├── rswag.rake │ └── users.rake └── timestamps.rb ├── log └── .keep ├── package-lock.json ├── package.json ├── postgresql.conf.example ├── public ├── .well-known │ └── apple-app-site-association ├── 400.html ├── 404.html ├── 406-unsupported-browser.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── exports │ └── .keep ├── favicon.ico ├── icon.png ├── icon.svg └── robots.txt ├── screenshots ├── imports.jpeg ├── map.jpeg └── stats.jpeg ├── spec ├── channels │ ├── imports_channel_spec.rb │ ├── notifications_channel_spec.rb │ └── points_channel_spec.rb ├── factories │ ├── areas.rb │ ├── countries.rb │ ├── exports.rb │ ├── imports.rb │ ├── notifications.rb │ ├── place_visits.rb │ ├── places.rb │ ├── points.rb │ ├── stats.rb │ ├── trips.rb │ ├── users.rb │ └── visits.rb ├── fixtures │ └── files │ │ ├── geojson │ │ ├── export.json │ │ ├── export_same_points.json │ │ └── gpslogger_example.json │ │ ├── google │ │ ├── location-history.json │ │ ├── location-history │ │ │ ├── with_activitySegment_with_startLocation.json │ │ │ ├── with_activitySegment_with_startLocation_timestampMs.json │ │ │ ├── with_activitySegment_with_startLocation_timestamp_in_milliseconds_format.json │ │ │ ├── with_activitySegment_with_startLocation_timestamp_in_seconds_format.json │ │ │ ├── with_activitySegment_with_startLocation_with_iso_timestamp.json │ │ │ ├── with_activitySegment_without_startLocation.json │ │ │ ├── with_activitySegment_without_startLocation_without_waypointPath.json │ │ │ ├── with_placeVisit_with_location_with_coordinates.json │ │ │ ├── with_placeVisit_with_location_with_coordinates_with_iso_timestamp.json │ │ │ ├── with_placeVisit_with_location_with_coordinates_with_milliseconds_timestamp.json │ │ │ ├── with_placeVisit_with_location_with_coordinates_with_seconds_timestamp.json │ │ │ ├── with_placeVisit_with_location_with_coordinates_with_timestampMs.json │ │ │ ├── with_placeVisit_without_location_with_coordinates.json │ │ │ └── with_placeVisit_without_location_with_coordinates_with_otherCandidateLocations.json │ │ ├── phone-takeout.json │ │ ├── records.json │ │ └── semantic_history.json │ │ ├── gpx │ │ ├── arc_example.gpx │ │ ├── garmin_example.gpx │ │ ├── gpx_track_multiple_segments.gpx │ │ ├── gpx_track_multiple_tracks.gpx │ │ └── gpx_track_single_segment.gpx │ │ ├── immich │ │ ├── geodata.json │ │ └── response.json │ │ ├── overland │ │ └── geodata.json │ │ ├── owntracks │ │ └── 2024-03.rec │ │ ├── points │ │ └── geojson_example.json │ │ └── watched │ │ ├── invalid_user@domain.com │ │ └── location-history.json │ │ └── user@domain.com │ │ ├── 2023_January.json │ │ ├── Records.json │ │ ├── export_same_points.json │ │ ├── gpx_track_single_segment.gpx │ │ ├── location-history.json │ │ └── owntracks.rec ├── jobs │ ├── app_version_checking_job_spec.rb │ ├── area_visits_calculating_job_spec.rb │ ├── area_visits_calculation_scheduling_job_spec.rb │ ├── bulk_stats_calculating_job_spec.rb │ ├── bulk_visits_suggesting_job_spec.rb │ ├── data_migrations │ │ ├── migrate_places_lonlat_job_spec.rb │ │ ├── migrate_points_latlon_job_spec.rb │ │ ├── set_points_country_ids_job_spec.rb │ │ └── start_settings_points_country_ids_job_spec.rb │ ├── enqueue_background_job_spec.rb │ ├── export_job_spec.rb │ ├── import │ │ ├── immich_geodata_job_spec.rb │ │ ├── process_job_spec.rb │ │ └── watcher_job_spec.rb │ ├── overland │ │ └── batch_creating_job_spec.rb │ ├── owntracks │ │ └── point_creating_job_spec.rb │ ├── points │ │ └── create_job_spec.rb │ ├── reverse_geocoding_job_spec.rb │ ├── stats │ │ └── calculating_job_spec.rb │ └── visit_suggesting_job_spec.rb ├── lib │ └── dawarich_settings_spec.rb ├── models │ ├── area_spec.rb │ ├── concerns │ │ └── point_validation_spec.rb │ ├── country_spec.rb │ ├── export_spec.rb │ ├── import_spec.rb │ ├── notification_spec.rb │ ├── place_spec.rb │ ├── place_visit_spec.rb │ ├── point_spec.rb │ ├── stat_spec.rb │ ├── trip_spec.rb │ ├── user_spec.rb │ └── visit_spec.rb ├── rails_helper.rb ├── requests │ ├── api │ │ └── v1 │ │ │ ├── areas_spec.rb │ │ │ ├── countries │ │ │ ├── borders_spec.rb │ │ │ └── visited_cities_spec.rb │ │ │ ├── health_spec.rb │ │ │ ├── maps │ │ │ └── tile_usage_spec.rb │ │ │ ├── overland │ │ │ └── batches_spec.rb │ │ │ ├── owntracks │ │ │ └── points_spec.rb │ │ │ ├── photos_spec.rb │ │ │ ├── points │ │ │ └── tracked_months_spec.rb │ │ │ ├── points_spec.rb │ │ │ ├── settings_spec.rb │ │ │ ├── stats_spec.rb │ │ │ ├── subscriptions_spec.rb │ │ │ ├── users_spec.rb │ │ │ ├── visits │ │ │ └── possible_places_spec.rb │ │ │ └── visits_spec.rb │ ├── authentication_spec.rb │ ├── exports_spec.rb │ ├── home_spec.rb │ ├── imports_spec.rb │ ├── map_spec.rb │ ├── notifications_spec.rb │ ├── places_spec.rb │ ├── points_spec.rb │ ├── settings │ │ ├── background_jobs_spec.rb │ │ ├── maps_spec.rb │ │ └── users_spec.rb │ ├── settings_spec.rb │ ├── sidekiq_spec.rb │ ├── stats_spec.rb │ ├── trips_spec.rb │ ├── users_spec.rb │ └── visits_spec.rb ├── serializers │ ├── api │ │ ├── photo_serializer_spec.rb │ │ ├── place_serializer_spec.rb │ │ ├── point_serializer_spec.rb │ │ ├── slim_point_serializer_spec.rb │ │ └── visit_serializer_spec.rb │ ├── export_serializer_spec.rb │ ├── point_serializer_spec.rb │ ├── points │ │ ├── geojson_serializer_spec.rb │ │ └── gpx_serializer_spec.rb │ └── stats_serializer_spec.rb ├── services │ ├── areas │ │ └── visits │ │ │ └── create_spec.rb │ ├── check_app_version_spec.rb │ ├── countries_and_cities_spec.rb │ ├── exports │ │ └── create_spec.rb │ ├── geojson │ │ ├── importer_spec.rb │ │ └── params_spec.rb │ ├── google_maps │ │ ├── phone_takeout_importer_spec.rb │ │ ├── records_importer_spec.rb │ │ ├── records_storage_importer_spec.rb │ │ └── semantic_history_importer_spec.rb │ ├── gpx │ │ └── track_importer_spec.rb │ ├── immich │ │ ├── import_geodata_spec.rb │ │ └── request_photos_spec.rb │ ├── imports │ │ ├── create_spec.rb │ │ ├── destroy_spec.rb │ │ ├── secure_file_downloader_spec.rb │ │ └── watcher_spec.rb │ ├── jobs │ │ └── create_spec.rb │ ├── metrics │ │ └── maps │ │ │ └── tile_usage │ │ │ └── track_spec.rb │ ├── notifications │ │ └── create_spec.rb │ ├── overland │ │ └── params_spec.rb │ ├── own_tracks │ │ ├── importer_spec.rb │ │ └── params_spec.rb │ ├── photoprism │ │ ├── cache_preview_token_spec.rb │ │ ├── import_geodata_spec.rb │ │ └── request_photos_spec.rb │ ├── photos │ │ ├── importer_spec.rb │ │ ├── search_spec.rb │ │ └── thumbnail_spec.rb │ ├── points │ │ ├── create_spec.rb │ │ ├── params_spec.rb │ │ └── raw_data_lonlat_extractor_spec.rb │ ├── points_limit_exceeded_spec.rb │ ├── reverse_geocoding │ │ ├── places │ │ │ └── fetch_data_spec.rb │ │ └── points │ │ │ └── fetch_data_spec.rb │ ├── stats │ │ └── calculate_month_spec.rb │ ├── tasks │ │ └── imports │ │ │ └── google_records_spec.rb │ ├── tracks │ │ └── build_path_spec.rb │ ├── trips │ │ └── photos_spec.rb │ ├── users │ │ └── safe_settings_spec.rb │ └── visits │ │ ├── bulk_update_spec.rb │ │ ├── creator_spec.rb │ │ ├── detector_spec.rb │ │ ├── find_in_time_spec.rb │ │ ├── find_within_bounding_box_spec.rb │ │ ├── finder_spec.rb │ │ ├── group_spec.rb │ │ ├── merge_service_spec.rb │ │ ├── merger_spec.rb │ │ ├── names │ │ ├── builder_spec.rb │ │ └── suggester_spec.rb │ │ ├── place_finder_spec.rb │ │ ├── smart_detect_spec.rb │ │ ├── suggest_spec.rb │ │ └── time_chunks_spec.rb ├── spec_helper.rb ├── support │ ├── capybara.rb │ ├── devise.rb │ ├── geocoder_stubs.rb │ ├── map_layer_helpers.rb │ ├── polyline_popup_helpers.rb │ ├── pundit_matchers.rb │ ├── shared_examples │ │ └── map_examples.rb │ └── system_helpers.rb ├── swagger │ └── api │ │ └── v1 │ │ ├── areas_controller_spec.rb │ │ ├── countries │ │ └── visited_cities_spec.rb │ │ ├── health_controller_spec.rb │ │ ├── overland │ │ └── batches_controller_spec.rb │ │ ├── owntracks │ │ └── points_controller_spec.rb │ │ ├── photos_controller_spec.rb │ │ ├── points │ │ └── tracked_months_controller_spec.rb │ │ ├── points_controller_spec.rb │ │ ├── settings_controller_spec.rb │ │ ├── stats_controller_spec.rb │ │ └── users_controller_spec.rb ├── swagger_helper.rb ├── system │ ├── README.md │ ├── authentication_spec.rb │ └── map_interaction_spec.rb └── tasks │ └── import_spec.rb ├── storage └── .keep ├── swagger └── v1 │ └── swagger.yaml ├── test ├── application_system_test_case.rb ├── channels │ └── application_cable │ │ └── connection_test.rb ├── controllers │ └── .keep ├── fixtures │ └── files │ │ └── .keep ├── helpers │ └── .keep ├── integration │ └── .keep ├── mailers │ └── .keep ├── models │ └── .keep ├── system │ └── .keep └── test_helper.rb ├── tests └── system │ └── test_scenarios.md ├── tmp ├── .keep ├── imports │ └── watched │ │ ├── .keep │ │ └── put-your-directory-here.txt ├── pids │ └── .keep └── storage │ └── .keep └── vendor ├── .keep └── javascript ├── .keep ├── leaflet-draw.js ├── leaflet-providers.js ├── leaflet.heat.js └── leaflet.js /.app_version: -------------------------------------------------------------------------------- 1 | 0.27.1 2 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ruby and Node DevContainer", 3 | "dockerComposeFile": ["docker-compose.yml"], 4 | "service": "dawarich_dev", 5 | "settings": { 6 | "terminal.integrated.defaultProfile.linux": "bash" 7 | }, 8 | "extensions": [ 9 | "rebornix.ruby", // Ruby-Support 10 | "esbenp.prettier-vscode", // Prettier for JS-Formating 11 | "dbaeumer.vscode-eslint" // ESLint for JavaScript 12 | ], 13 | "postCreateCommand": "yarn install && bundle config set --local path 'vendor/bundle' && bundle install --jobs 20 --retry 5", 14 | "forwardPorts": [3000], // Redirect to Rails-App-Server 15 | "remoteUser": "root", 16 | "workspaceFolder": "/var/app" 17 | } 18 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=localhost 2 | DATABASE_USERNAME=postgres 3 | DATABASE_PASSWORD=password 4 | DATABASE_NAME=dawarich_development 5 | DATABASE_PORT=5432 6 | REDIS_URL=redis://localhost:6379/1 7 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/.env.template -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=localhost 2 | DATABASE_USERNAME=postgres 3 | DATABASE_PASSWORD=password 4 | DATABASE_NAME=dawarich_test 5 | DATABASE_PORT=5432 6 | REDIS_URL=redis://localhost:6379/1 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: freika # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: freika 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **OS & Hardware** 11 | Provide your software and hardware specs 12 | 13 | **Version** 14 | Provide the version of Dawarich you're experiencing the problem on. 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | **Expected behavior** 27 | A clear and concise description of what you expected to happen. 28 | 29 | **Screenshots** 30 | If applicable, add screenshots to help explain your problem. 31 | 32 | **Logs** 33 | If applicable, add logs from containers `dawarich_app` and `dawarich_sidekiq` to help explain your problem. 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'bundler' 9 | directory: '/' 10 | schedule: 11 | interval: 'weekly' 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --profile 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rails 2 | 3 | Style/Documentation: 4 | Enabled: false 5 | 6 | Style/ClassAndModuleChildren: 7 | Enabled: false 8 | 9 | Layout/HashAlignment: 10 | Enabled: false 11 | 12 | Metrics/BlockLength: 13 | Enabled: false 14 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.1 2 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | If you want to develop with dawarich you can use the devcontainer, with your IDE. It is tested with visual studio code. 2 | 3 | Load the directory in Vs-Code and press F1. And Run the command: `Dev Containers: Rebuild Containers` after a while you should see a terminal. 4 | 5 | Now you can create/prepare the Database (this need to be done once): 6 | ```bash 7 | bundle exec rails db:prepare 8 | ``` 9 | 10 | Afterwards you can run sidekiq: 11 | ```bash 12 | bundle exec sidekiq 13 | 14 | ``` 15 | 16 | And in a second terminal the dawarich-app: 17 | ```bash 18 | bundle exec bin/dev 19 | ``` 20 | 21 | You can connect with a web browser to http://127.0.0.l:3000/ and login with the default credentials. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb 2 | worker: bundle exec bin/jobs 3 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bin/rails server -p 3000 -b :: 2 | -------------------------------------------------------------------------------- /Procfile.prometheus.dev: -------------------------------------------------------------------------------- 1 | prometheus_exporter: bundle exec prometheus_exporter -b ANY 2 | web: bin/rails server -p 3000 -b :: 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dawarich", 3 | "description": "Dawarich", 4 | "buildpacks": [ 5 | { "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" }, 6 | { "url": "https://github.com/heroku/heroku-buildpack-ruby.git" } 7 | ], 8 | "scripts": { 9 | "dokku": { 10 | "predeploy": "bundle exec rails db:migrate" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/assets/builds/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/builds/.keep -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_tree ../builds 4 | //= link_tree ../../javascript .js 5 | //= link_tree ../../../vendor/javascript .js 6 | //= link favicon/browserconfig.xml 7 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/images/favicon.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/images/favicon.jpeg -------------------------------------------------------------------------------- /app/assets/images/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/images/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /app/assets/images/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/images/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /app/assets/images/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/images/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /app/assets/images/favicon/browserconfig.xml.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/assets/images/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/images/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /app/assets/images/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/images/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /app/assets/images/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/images/favicon/favicon.ico -------------------------------------------------------------------------------- /app/assets/images/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/assets/images/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /app/assets/images/favicon/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/assets/images/favicon/site.webmanifest.erb: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dawarich", 3 | "short_name": "Dawarich", 4 | "icons": [ 5 | { 6 | "src": "<%= asset_path 'favicon/android-chrome-192x192.png' %>", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "<%= asset_path 'favicon/android-chrome-512x512.png' %>", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Connection < ActionCable::Connection::Base 5 | identified_by :current_user 6 | 7 | def connect 8 | self.current_user = find_verified_user 9 | end 10 | 11 | private 12 | 13 | def find_verified_user 14 | if (verified_user = env['warden'].user) 15 | verified_user 16 | else 17 | reject_unauthorized_connection 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/channels/imports_channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ImportsChannel < ApplicationCable::Channel 4 | def subscribed 5 | stream_for current_user 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/channels/notifications_channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class NotificationsChannel < ApplicationCable::Channel 4 | def subscribed 5 | stream_for current_user 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/channels/points_channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PointsChannel < ApplicationCable::Channel 4 | def subscribed 5 | stream_for current_user 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/api/v1/countries/borders_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::Countries::BordersController < ApplicationController 4 | def index 5 | countries = Rails.cache.fetch('dawarich/countries_codes', expires_in: 1.day) do 6 | Oj.load(File.read(Rails.root.join('lib/assets/countries.geojson'))) 7 | end 8 | 9 | render json: countries 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/api/v1/countries/visited_cities_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::Countries::VisitedCitiesController < ApiController 4 | before_action :validate_params 5 | 6 | def index 7 | start_at = DateTime.parse(params[:start_at]).to_i 8 | end_at = DateTime.parse(params[:end_at]).to_i 9 | 10 | points = current_api_user 11 | .tracked_points 12 | .where(timestamp: start_at..end_at) 13 | 14 | render json: { data: CountriesAndCities.new(points).call } 15 | end 16 | 17 | private 18 | 19 | def required_params 20 | %i[start_at end_at] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/api/v1/health_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::HealthController < ApiController 4 | skip_before_action :authenticate_api_key 5 | 6 | def index 7 | if current_api_user 8 | response.set_header('X-Dawarich-Response', 'Hey, I\'m alive and authenticated!') 9 | else 10 | response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!') 11 | end 12 | 13 | response.set_header('X-Dawarich-Version', APP_VERSION) 14 | 15 | render json: { status: 'ok' } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/api/v1/maps/tile_usage_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::Maps::TileUsageController < ApiController 4 | def create 5 | Metrics::Maps::TileUsage::Track.new(current_api_user.id, tile_usage_params[:count].to_i).call 6 | 7 | head :ok 8 | end 9 | 10 | private 11 | 12 | def tile_usage_params 13 | params.require(:tile_usage).permit(:count) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/controllers/api/v1/overland/batches_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::Overland::BatchesController < ApiController 4 | before_action :authenticate_active_api_user!, only: %i[create] 5 | before_action :validate_points_limit, only: %i[create] 6 | 7 | def create 8 | Overland::BatchCreatingJob.perform_later(batch_params, current_api_user.id) 9 | 10 | render json: { result: 'ok' }, status: :created 11 | end 12 | 13 | private 14 | 15 | def batch_params 16 | params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {}) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/controllers/api/v1/owntracks/points_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::Owntracks::PointsController < ApiController 4 | before_action :authenticate_active_api_user!, only: %i[create] 5 | before_action :validate_points_limit, only: %i[create] 6 | 7 | def create 8 | Owntracks::PointCreatingJob.perform_later(point_params, current_api_user.id) 9 | 10 | render json: {}, status: :ok 11 | end 12 | 13 | private 14 | 15 | def point_params 16 | params.permit! 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/controllers/api/v1/points/tracked_months_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::Points::TrackedMonthsController < ApiController 4 | def index 5 | render json: current_api_user.years_tracked 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/api/v1/stats_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::StatsController < ApiController 4 | def index 5 | render json: StatsSerializer.new(current_api_user).call 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/api/v1/subscriptions_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::SubscriptionsController < ApiController 4 | skip_before_action :authenticate_api_key, only: %i[callback] 5 | 6 | def callback 7 | decoded_token = Subscription::DecodeJwtToken.new(params[:token]).call 8 | 9 | user = User.find(decoded_token[:user_id]) 10 | user.update!(status: decoded_token[:status], active_until: decoded_token[:active_until]) 11 | 12 | render json: { message: 'Subscription updated successfully' } 13 | rescue JWT::DecodeError => e 14 | ExceptionReporter.call(e) 15 | render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized 16 | rescue ArgumentError => e 17 | ExceptionReporter.call(e) 18 | render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/controllers/api/v1/users_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::UsersController < ApiController 4 | def me 5 | render json: { user: current_api_user } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/api/v1/visits/possible_places_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::V1::Visits::PossiblePlacesController < ApiController 4 | def index 5 | visit = current_api_user.visits.find(params[:id]) 6 | possible_places = visit.suggested_places.map do |place| 7 | Api::PlaceSerializer.new(place).call 8 | end 9 | 10 | render json: possible_places 11 | rescue ActiveRecord::RecordNotFound 12 | render json: { error: 'Visit not found' }, status: :not_found 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class HomeController < ApplicationController 4 | def index 5 | # redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED 6 | 7 | redirect_to map_url if current_user 8 | 9 | @points = current_user.tracked_points.without_raw_data if current_user 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/places_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PlacesController < ApplicationController 4 | before_action :authenticate_user! 5 | before_action :set_place, only: :destroy 6 | 7 | def index 8 | @places = current_user.places.page(params[:page]).per(20) 9 | end 10 | 11 | def destroy 12 | @place.destroy! 13 | 14 | redirect_to places_url, notice: 'Place was successfully destroyed.', status: :see_other 15 | end 16 | 17 | private 18 | 19 | def set_place 20 | @place = current_user.places.find(params[:id]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/settings/maps_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Settings::MapsController < ApplicationController 4 | before_action :authenticate_user! 5 | 6 | def index 7 | @maps = current_user.safe_settings.maps 8 | 9 | @tile_usage = 7.days.ago.to_date.upto(Time.zone.today).map do |date| 10 | [ 11 | date.to_s, 12 | Rails.cache.read("dawarich_map_tiles_usage:#{current_user.id}:#{date}") || 0 13 | ] 14 | end 15 | end 16 | 17 | def update 18 | current_user.settings['maps'] = settings_params 19 | current_user.save! 20 | 21 | redirect_to settings_maps_path, notice: 'Settings updated' 22 | end 23 | 24 | private 25 | 26 | def settings_params 27 | params.require(:maps).permit(:name, :url, :distance_unit) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/helpers/country_flag_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CountryFlagHelper 4 | def country_flag(country_name) 5 | country_code = country_to_code(country_name) 6 | return "" unless country_code 7 | 8 | # Convert country code to regional indicator symbols (flag emoji) 9 | country_code.upcase.each_char.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join 10 | end 11 | 12 | 13 | private 14 | 15 | def country_to_code(country_name) 16 | mapping = Country.names_to_iso_a2 17 | 18 | return mapping[country_name] if mapping[country_name] 19 | 20 | mapping.each do |name, code| 21 | return code if country_name.downcase == name.downcase 22 | return code if country_name.downcase.include?(name.downcase) || name.downcase.include?(country_name.downcase) 23 | end 24 | 25 | nil 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/helpers/points_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PointsHelper 4 | def link_to_date(timestamp) 5 | datetime = Time.zone.at(timestamp) 6 | 7 | link_to map_path(start_at: datetime.beginning_of_day, end_at: datetime.end_of_day), \ 8 | class: 'underline hover:no-underline' do 9 | datetime.strftime('%d.%m.%Y') 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | 3 | import "@rails/actioncable" 4 | import "controllers" 5 | import "@hotwired/turbo-rails" 6 | 7 | import "leaflet" 8 | import "leaflet-providers" 9 | import "chartkick" 10 | import "Chart.bundle" 11 | import "./channels" 12 | 13 | import "trix" 14 | import "@rails/actiontext" 15 | -------------------------------------------------------------------------------- /app/javascript/channels/consumer.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. 3 | 4 | import { createConsumer } from "@rails/actioncable" 5 | 6 | export default createConsumer() 7 | -------------------------------------------------------------------------------- /app/javascript/channels/imports_channel.js: -------------------------------------------------------------------------------- 1 | import consumer from "./consumer" 2 | 3 | consumer.subscriptions.create("ImportsChannel", { 4 | connected() { 5 | // console.log("Connected to the imports channel!"); 6 | }, 7 | 8 | disconnected() { 9 | // Called when the subscription has been terminated by the server 10 | }, 11 | 12 | received(data) { 13 | // Called when there's incoming data on the websocket for this channel 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /app/javascript/channels/index.js: -------------------------------------------------------------------------------- 1 | // Import all the channels to be used by Action Cable 2 | import "notifications_channel" 3 | import "points_channel" 4 | import "imports_channel" 5 | -------------------------------------------------------------------------------- /app/javascript/channels/notifications_channel.js: -------------------------------------------------------------------------------- 1 | import consumer from "./consumer" 2 | 3 | consumer.subscriptions.create("NotificationsChannel", { 4 | connected() { 5 | // console.log("Connected to the notifications channel!"); 6 | }, 7 | 8 | disconnected() { 9 | // Called when the subscription has been terminated by the server 10 | }, 11 | 12 | received(data) { 13 | // Called when there's incoming data on the websocket for this channel 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /app/javascript/channels/points_channel.js: -------------------------------------------------------------------------------- 1 | import consumer from "./consumer" 2 | 3 | consumer.subscriptions.create("PointsChannel", { 4 | connected() { 5 | // Called when the subscription is ready for use on the server 6 | }, 7 | 8 | disconnected() { 9 | // Called when the subscription has been terminated by the server 10 | }, 11 | 12 | received(data) { 13 | // Called when there's incoming data on the websocket for this channel 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /app/javascript/controllers/base_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | static values = { 5 | selfHosted: Boolean 6 | } 7 | 8 | // Every controller that extends BaseController and uses initialize() 9 | // should call super.initialize() 10 | // Example: 11 | // export default class extends BaseController { 12 | // initialize() { 13 | // super.initialize() 14 | // } 15 | // } 16 | initialize() { 17 | // Get the self-hosted value from the HTML root element 18 | if (!this.hasSelfHostedValue) { 19 | const selfHosted = document.documentElement.dataset.selfHosted === 'true' 20 | this.selfHostedValue = selfHosted 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/javascript/controllers/checkbox_select_all_controller.js: -------------------------------------------------------------------------------- 1 | import BaseController from "./base_controller" 2 | 3 | // Connects to data-controller="checkbox-select-all" 4 | export default class extends BaseController { 5 | static targets = ["parent", "child"] 6 | 7 | connect() { 8 | this.parentTarget.checked = false 9 | this.childTargets.map(x => x.checked = false) 10 | } 11 | 12 | toggleChildren() { 13 | if (this.parentTarget.checked) { 14 | this.childTargets.map(x => x.checked = true) 15 | } else { 16 | this.childTargets.map(x => x.checked = false) 17 | } 18 | } 19 | 20 | toggleParent() { 21 | if (this.childTargets.map(x => x.checked).includes(false)) { 22 | this.parentTarget.checked = false 23 | } else { 24 | this.parentTarget.checked = true 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | import { application } from "controllers/application" 2 | 3 | // Eager load all controllers defined in the import map under controllers/**/*_controller 4 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 5 | eagerLoadControllersFrom("controllers", application) 6 | -------------------------------------------------------------------------------- /app/javascript/controllers/removals_controller.js: -------------------------------------------------------------------------------- 1 | import BaseController from "./base_controller" 2 | 3 | export default class extends BaseController { 4 | static values = { 5 | timeout: Number 6 | } 7 | 8 | connect() { 9 | if (this.timeoutValue) { 10 | setTimeout(() => { 11 | this.remove() 12 | }, this.timeoutValue) 13 | } 14 | } 15 | 16 | remove() { 17 | this.element.classList.add('fade-out') 18 | setTimeout(() => { 19 | this.element.remove() 20 | 21 | // Remove the container if it's empty 22 | const container = document.getElementById('flash-messages') 23 | if (container && !container.hasChildNodes()) { 24 | container.remove() 25 | } 26 | }, 150) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/javascript/styles/visits.css: -------------------------------------------------------------------------------- 1 | .visit-checkbox-container { 2 | z-index: 10; 3 | opacity: 0; 4 | transition: opacity 0.2s ease-in-out; 5 | } 6 | .visit-item { 7 | position: relative; 8 | } 9 | .visit-item:hover .visit-checkbox-container { 10 | opacity: 1 !important; 11 | } 12 | .leaflet-drawer.open { 13 | transform: translateX(0); 14 | } 15 | .merge-visits-button { 16 | margin: 8px 0; 17 | } 18 | -------------------------------------------------------------------------------- /app/jobs/app_version_checking_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AppVersionCheckingJob < ApplicationJob 4 | queue_as :default 5 | sidekiq_options retry: false 6 | 7 | def perform 8 | Rails.cache.delete(CheckAppVersion::VERSION_CACHE_KEY) 9 | 10 | CheckAppVersion.new.call 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | # Automatically retry jobs that encountered a deadlock 5 | # retry_on ActiveRecord::Deadlocked 6 | 7 | retry_on Exception, wait: :polynomially_longer, attempts: 25 8 | 9 | # Most jobs are safe to ignore if the underlying records are no longer available 10 | # discard_on ActiveJob::DeserializationError 11 | end 12 | -------------------------------------------------------------------------------- /app/jobs/area_visits_calculating_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AreaVisitsCalculatingJob < ApplicationJob 4 | queue_as :default 5 | sidekiq_options retry: false 6 | 7 | def perform(user_id) 8 | user = User.find(user_id) 9 | areas = user.areas 10 | 11 | Areas::Visits::Create.new(user, areas).call 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/jobs/area_visits_calculation_scheduling_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AreaVisitsCalculationSchedulingJob < ApplicationJob 4 | queue_as :default 5 | sidekiq_options retry: false 6 | 7 | def perform 8 | User.find_each { AreaVisitsCalculatingJob.perform_later(_1.id) } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/jobs/bulk_stats_calculating_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BulkStatsCalculatingJob < ApplicationJob 4 | queue_as :stats 5 | 6 | def perform 7 | user_ids = User.pluck(:id) 8 | 9 | user_ids.each do |user_id| 10 | Stats::BulkCalculator.new(user_id).call 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/jobs/cache/cleaning_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Cache::CleaningJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform 7 | Cache::Clean.call 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/jobs/cache/preheating_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Cache::PreheatingJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform 7 | User.find_each do |user| 8 | Rails.cache.write( 9 | "dawarich/user_#{user.id}_years_tracked", 10 | user.years_tracked, 11 | expires_in: 1.day 12 | ) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/jobs/data_migrations/migrate_points_latlon_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DataMigrations::MigratePointsLatlonJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform(user_id) 7 | user = User.find(user_id) 8 | 9 | # rubocop:disable Rails/SkipsModelValidations 10 | user.tracked_points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)') 11 | # rubocop:enable Rails/SkipsModelValidations 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/jobs/data_migrations/set_points_country_ids_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DataMigrations::SetPointsCountryIdsJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform(point_id) 7 | point = Point.find(point_id) 8 | country = Country.containing_point(point.lon, point.lat) 9 | 10 | if country.present? 11 | point.country_id = country.id 12 | point.save! 13 | else 14 | Rails.logger.info("No country found for point #{point.id}") 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/jobs/data_migrations/set_reverse_geocoded_at_for_points_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DataMigrations::SetReverseGeocodedAtForPointsJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform 7 | timestamp = Time.current 8 | 9 | Point.where.not(geodata: {}) 10 | .where(reverse_geocoded_at: nil) 11 | .in_batches(of: 10_000) do |relation| 12 | # rubocop:disable Rails/SkipsModelValidations 13 | relation.update_all(reverse_geocoded_at: timestamp) 14 | # rubocop:enable Rails/SkipsModelValidations 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/jobs/data_migrations/start_settings_points_country_ids_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DataMigrations::StartSettingsPointsCountryIdsJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform 7 | Point.where(country_id: nil).find_each do |point| 8 | DataMigrations::SetPointsCountryIdsJob.perform_later(point.id) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/jobs/enqueue_background_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EnqueueBackgroundJob < ApplicationJob 4 | queue_as :reverse_geocoding 5 | 6 | def perform(job_name, user_id) 7 | case job_name 8 | when 'start_immich_import' 9 | Import::ImmichGeodataJob.perform_later(user_id) 10 | when 'start_photoprism_import' 11 | Import::PhotoprismGeodataJob.perform_later(user_id) 12 | when 'start_reverse_geocoding', 'continue_reverse_geocoding' 13 | Jobs::Create.new(job_name, user_id).call 14 | else 15 | raise ArgumentError, "Unknown job name: #{job_name}" 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/jobs/export_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ExportJob < ApplicationJob 4 | queue_as :exports 5 | 6 | def perform(export_id) 7 | export = Export.find(export_id) 8 | 9 | Exports::Create.new(export:).call 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/jobs/import/google_takeout_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Import::GoogleTakeoutJob < ApplicationJob 4 | queue_as :imports 5 | sidekiq_options retry: false 6 | 7 | def perform(import_id, locations, current_index) 8 | locations_batch = Oj.load(locations) 9 | import = Import.find(import_id) 10 | 11 | GoogleMaps::RecordsImporter.new(import, current_index).call(locations_batch) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/jobs/import/immich_geodata_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Import::ImmichGeodataJob < ApplicationJob 4 | queue_as :imports 5 | sidekiq_options retry: false 6 | 7 | def perform(user_id) 8 | user = User.find(user_id) 9 | 10 | Immich::ImportGeodata.new(user).call 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/jobs/import/photoprism_geodata_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Import::PhotoprismGeodataJob < ApplicationJob 4 | queue_as :imports 5 | sidekiq_options retry: false 6 | 7 | def perform(user_id) 8 | user = User.find(user_id) 9 | 10 | Photoprism::ImportGeodata.new(user).call 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/jobs/import/process_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Import::ProcessJob < ApplicationJob 4 | queue_as :imports 5 | 6 | def perform(import_id) 7 | import = Import.find(import_id) 8 | 9 | import.process! 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/jobs/import/update_points_count_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Import::UpdatePointsCountJob < ApplicationJob 4 | queue_as :imports 5 | 6 | def perform(import_id) 7 | import = Import.find(import_id) 8 | 9 | import.update(processed: import.points.count) 10 | rescue ActiveRecord::RecordNotFound 11 | nil 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/jobs/import/watcher_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Import::WatcherJob < ApplicationJob 4 | queue_as :imports 5 | sidekiq_options retry: false 6 | 7 | def perform 8 | return unless DawarichSettings.self_hosted? 9 | 10 | Imports::Watcher.new.call 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/jobs/jobs/clean_finished_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Jobs::CleanFinishedJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform 7 | SolidQueue::Job.clear_finished_in_batches 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/jobs/overland/batch_creating_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Overland::BatchCreatingJob < ApplicationJob 4 | include PointValidation 5 | 6 | queue_as :points 7 | 8 | def perform(params, user_id) 9 | data = Overland::Params.new(params).call 10 | 11 | data.each do |location| 12 | next if location[:lonlat].nil? 13 | next if point_exists?(location, user_id) 14 | 15 | Point.create!(location.merge(user_id:)) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/jobs/owntracks/point_creating_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Owntracks::PointCreatingJob < ApplicationJob 4 | include PointValidation 5 | 6 | queue_as :points 7 | 8 | def perform(point_params, user_id) 9 | parsed_params = OwnTracks::Params.new(point_params).call 10 | 11 | return if parsed_params[:timestamp].nil? || parsed_params[:lonlat].nil? 12 | return if point_exists?(parsed_params, user_id) 13 | 14 | Point.create!(parsed_params.merge(user_id:)) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/jobs/points/create_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Points::CreateJob < ApplicationJob 4 | queue_as :points 5 | 6 | def perform(params, user_id) 7 | data = Points::Params.new(params, user_id).call 8 | 9 | data.each_slice(1000) do |location_batch| 10 | Point.upsert_all( 11 | location_batch, 12 | unique_by: %i[lonlat timestamp user_id], 13 | returning: false 14 | ) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/jobs/reverse_geocoding_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ReverseGeocodingJob < ApplicationJob 4 | queue_as :reverse_geocoding 5 | 6 | def perform(klass, id) 7 | return unless DawarichSettings.reverse_geocoding_enabled? 8 | 9 | rate_limit_for_photon_api 10 | 11 | data_fetcher(klass, id).call 12 | end 13 | 14 | private 15 | 16 | def data_fetcher(klass, id) 17 | "ReverseGeocoding::#{klass.pluralize.camelize}::FetchData".constantize.new(id) 18 | end 19 | 20 | def rate_limit_for_photon_api 21 | return unless DawarichSettings.photon_enabled? 22 | 23 | sleep 1 if DawarichSettings.photon_uses_komoot_io? 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/jobs/stats/calculating_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Stats::CalculatingJob < ApplicationJob 4 | queue_as :stats 5 | 6 | def perform(user_id, year, month) 7 | Stats::CalculateMonth.new(user_id, year, month).call 8 | rescue StandardError => e 9 | create_stats_update_failed_notification(user_id, e) 10 | end 11 | 12 | private 13 | 14 | def create_stats_update_failed_notification(user_id, error) 15 | user = User.find(user_id) 16 | 17 | Notifications::Create.new( 18 | user:, 19 | kind: :error, 20 | title: 'Stats update failed', 21 | content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}" 22 | ).call 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/jobs/trips/calculate_all_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Trips::CalculateAllJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform(trip_id, distance_unit = 'km') 7 | Trips::CalculatePathJob.perform_later(trip_id) 8 | Trips::CalculateDistanceJob.perform_later(trip_id, distance_unit) 9 | Trips::CalculateCountriesJob.perform_later(trip_id, distance_unit) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/jobs/trips/calculate_countries_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Trips::CalculateCountriesJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform(trip_id, distance_unit) 7 | trip = Trip.find(trip_id) 8 | 9 | trip.calculate_countries 10 | trip.save! 11 | 12 | broadcast_update(trip, distance_unit) 13 | end 14 | 15 | private 16 | 17 | def broadcast_update(trip, distance_unit) 18 | Turbo::StreamsChannel.broadcast_update_to( 19 | "trip_#{trip.id}", 20 | target: "trip_countries", 21 | partial: "trips/countries", 22 | locals: { trip: trip, distance_unit: distance_unit } 23 | ) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/jobs/trips/calculate_distance_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Trips::CalculateDistanceJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform(trip_id, distance_unit) 7 | trip = Trip.find(trip_id) 8 | 9 | trip.calculate_distance 10 | trip.save! 11 | 12 | broadcast_update(trip, distance_unit) 13 | end 14 | 15 | private 16 | 17 | def broadcast_update(trip, distance_unit) 18 | Turbo::StreamsChannel.broadcast_update_to( 19 | "trip_#{trip.id}", 20 | target: "trip_distance", 21 | partial: "trips/distance", 22 | locals: { trip: trip, distance_unit: distance_unit } 23 | ) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/jobs/trips/calculate_path_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Trips::CalculatePathJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform(trip_id) 7 | trip = Trip.find(trip_id) 8 | 9 | trip.calculate_path 10 | trip.save! 11 | 12 | broadcast_update(trip) 13 | end 14 | 15 | private 16 | 17 | def broadcast_update(trip) 18 | Turbo::StreamsChannel.broadcast_update_to( 19 | "trip_#{trip.id}", 20 | target: "trip_path", 21 | partial: "trips/path", 22 | locals: { trip: trip } 23 | ) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/jobs/visit_suggesting_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class VisitSuggestingJob < ApplicationJob 4 | queue_as :visit_suggesting 5 | sidekiq_options retry: false 6 | 7 | # Passing timespan of more than 3 years somehow results in duplicated Places 8 | def perform(user_id:, start_at:, end_at:) 9 | user = User.find(user_id) 10 | 11 | start_time = parse_date(start_at) 12 | end_time = parse_date(end_at) 13 | 14 | # Create one-day chunks 15 | current_time = start_time 16 | while current_time < end_time 17 | chunk_end = [current_time + 1.day, end_time].min 18 | Visits::Suggest.new(user, start_at: current_time, end_at: chunk_end).call 19 | current_time += 1.day 20 | end 21 | end 22 | 23 | private 24 | 25 | def parse_date(date) 26 | date.is_a?(String) ? Time.zone.parse(date) : date.to_datetime 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: ENV['SMTP_FROM'] 5 | layout 'mailer' 6 | end 7 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /app/models/area.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Area < ApplicationRecord 4 | reverse_geocoded_by :latitude, :longitude 5 | 6 | belongs_to :user 7 | has_many :visits, dependent: :destroy 8 | 9 | validates :name, :latitude, :longitude, :radius, presence: true 10 | 11 | alias_attribute :lon, :longitude 12 | alias_attribute :lat, :latitude 13 | 14 | def center = [latitude.to_f, longitude.to_f] 15 | end 16 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/concerns/point_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PointValidation 4 | extend ActiveSupport::Concern 5 | 6 | def point_exists?(params, user_id) 7 | Point.where( 8 | lonlat: params[:lonlat], 9 | timestamp: params[:timestamp].to_i, 10 | user_id: 11 | ).exists? 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/models/country.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Country < ApplicationRecord 4 | validates :name, :iso_a2, :iso_a3, :geom, presence: true 5 | 6 | def self.containing_point(lon, lat) 7 | where("ST_Contains(geom, ST_SetSRID(ST_MakePoint(?, ?), 4326))", lon, lat) 8 | .select(:id, :name, :iso_a2, :iso_a3) 9 | .first 10 | end 11 | 12 | def self.names_to_iso_a2 13 | pluck(:name, :iso_a2).to_h 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/models/notification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Notification < ApplicationRecord 4 | after_create_commit :broadcast_notification 5 | 6 | belongs_to :user 7 | 8 | validates :title, :content, :kind, presence: true 9 | 10 | enum :kind, { info: 0, warning: 1, error: 2 } 11 | 12 | scope :unread, -> { where(read_at: nil).order(created_at: :desc) } 13 | 14 | def read? 15 | read_at.present? 16 | end 17 | 18 | private 19 | 20 | def broadcast_notification 21 | NotificationsChannel.broadcast_to( 22 | user, { 23 | title: title, 24 | id: id, 25 | kind: kind 26 | } 27 | ) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/models/place.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Place < ApplicationRecord 4 | include Nearable 5 | include Distanceable 6 | 7 | DEFAULT_NAME = 'Suggested place' 8 | 9 | validates :name, :lonlat, presence: true 10 | 11 | has_many :visits, dependent: :destroy 12 | has_many :place_visits, dependent: :destroy 13 | has_many :suggested_visits, -> { distinct }, through: :place_visits, source: :visit 14 | 15 | enum :source, { manual: 0, photon: 1 } 16 | 17 | def lon 18 | lonlat.x 19 | end 20 | 21 | def lat 22 | lonlat.y 23 | end 24 | 25 | def osm_id 26 | geodata.dig('properties', 'osm_id') 27 | end 28 | 29 | def osm_key 30 | geodata.dig('properties', 'osm_key') 31 | end 32 | 33 | def osm_value 34 | geodata.dig('properties', 'osm_value') 35 | end 36 | 37 | def osm_type 38 | geodata.dig('properties', 'osm_type') 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/models/place_visit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PlaceVisit < ApplicationRecord 4 | belongs_to :place 5 | belongs_to :visit 6 | end 7 | -------------------------------------------------------------------------------- /app/models/visit_draft.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class VisitDraft 4 | attr_accessor :start_time, :end_time, :points 5 | 6 | def initialize(start_time) 7 | @start_time = start_time 8 | @end_time = start_time 9 | @points = [] 10 | end 11 | 12 | def add_point(point) 13 | @points << point 14 | @end_time = point.timestamp if point.timestamp > @end_time 15 | end 16 | 17 | def duration_in_minutes 18 | (end_time - start_time) / 60.0 19 | end 20 | 21 | def valid? 22 | @points.size > 1 && duration_in_minutes >= 10 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/policies/application_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationPolicy 4 | attr_reader :user, :record 5 | 6 | def initialize(user, record) 7 | @user = user 8 | @record = record 9 | end 10 | 11 | def index? 12 | false 13 | end 14 | 15 | def show? 16 | false 17 | end 18 | 19 | def create? 20 | false 21 | end 22 | 23 | def new? 24 | create? 25 | end 26 | 27 | def update? 28 | false 29 | end 30 | 31 | def edit? 32 | update? 33 | end 34 | 35 | def destroy? 36 | false 37 | end 38 | 39 | class Scope 40 | def initialize(user, scope) 41 | @user = user 42 | @scope = scope 43 | end 44 | 45 | def resolve 46 | raise NotImplementedError, "You must define #resolve in #{self.class}" 47 | end 48 | 49 | private 50 | 51 | attr_reader :user, :scope 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /app/serializers/api/place_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::PlaceSerializer 4 | def initialize(place) 5 | @place = place 6 | end 7 | 8 | def call 9 | { 10 | id: place.id, 11 | name: place.name, 12 | longitude: place.lon, 13 | latitude: place.lat, 14 | city: place.city, 15 | country: place.country, 16 | source: place.source, 17 | geodata: place.geodata, 18 | created_at: place.created_at, 19 | updated_at: place.updated_at, 20 | reverse_geocoded_at: place.reverse_geocoded_at 21 | } 22 | end 23 | 24 | private 25 | 26 | attr_reader :place 27 | end 28 | -------------------------------------------------------------------------------- /app/serializers/api/point_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::PointSerializer < PointSerializer 4 | EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id import_id user_id raw_data country_id].freeze 5 | 6 | def call 7 | point.attributes.except(*EXCLUDED_ATTRIBUTES) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/serializers/api/slim_point_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::SlimPointSerializer 4 | def initialize(point) 5 | @point = point 6 | end 7 | 8 | def call 9 | { 10 | id: point.id, 11 | latitude: point.lat.to_s, 12 | longitude: point.lon.to_s, 13 | timestamp: point.timestamp 14 | } 15 | end 16 | 17 | private 18 | 19 | attr_reader :point 20 | end 21 | -------------------------------------------------------------------------------- /app/serializers/api/visit_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::VisitSerializer 4 | def initialize(visit) 5 | @visit = visit 6 | end 7 | 8 | def call 9 | { 10 | id: visit.id, 11 | area_id: visit.area_id, 12 | user_id: visit.user_id, 13 | started_at: visit.started_at, 14 | ended_at: visit.ended_at, 15 | duration: visit.duration, 16 | name: visit.name, 17 | status: visit.status, 18 | place: { 19 | latitude: visit.place&.lat || visit.area&.latitude, 20 | longitude: visit.place&.lon || visit.area&.longitude, 21 | id: visit.place&.id 22 | } 23 | } 24 | end 25 | 26 | private 27 | 28 | attr_reader :visit 29 | end 30 | -------------------------------------------------------------------------------- /app/serializers/point_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PointSerializer 4 | EXCLUDED_ATTRIBUTES = %w[ 5 | created_at updated_at visit_id id import_id user_id raw_data lonlat 6 | reverse_geocoded_at country_id 7 | ].freeze 8 | 9 | def initialize(point) 10 | @point = point 11 | end 12 | 13 | def call 14 | point.attributes.except(*EXCLUDED_ATTRIBUTES).tap do |attributes| 15 | attributes['latitude'] = point.lat.to_s 16 | attributes['longitude'] = point.lon.to_s 17 | end 18 | end 19 | 20 | private 21 | 22 | attr_reader :point 23 | end 24 | -------------------------------------------------------------------------------- /app/serializers/points/geojson_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Points::GeojsonSerializer 4 | def initialize(points) 5 | @points = points 6 | end 7 | 8 | # rubocop:disable Metrics/MethodLength 9 | def call 10 | { 11 | type: 'FeatureCollection', 12 | features: points.map do |point| 13 | { 14 | type: 'Feature', 15 | geometry: { 16 | type: 'Point', 17 | coordinates: [point.lon, point.lat] 18 | }, 19 | properties: PointSerializer.new(point).call 20 | } 21 | end 22 | }.to_json 23 | end 24 | # rubocop:enable Metrics/MethodLength 25 | 26 | private 27 | 28 | attr_reader :points 29 | end 30 | -------------------------------------------------------------------------------- /app/serializers/points/gpx_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Points::GpxSerializer 4 | def initialize(points, name) 5 | @points = points 6 | @name = name 7 | end 8 | 9 | def call 10 | gpx_file = GPX::GPXFile.new(name: "dawarich_#{name}") 11 | track = GPX::Track.new(name: "dawarich_#{name}") 12 | 13 | gpx_file.tracks << track 14 | 15 | track_segment = GPX::Segment.new 16 | track.segments << track_segment 17 | 18 | points.each do |point| 19 | track_segment.points << GPX::TrackPoint.new( 20 | lat: point.lat, 21 | lon: point.lon, 22 | elevation: point.altitude.to_f, 23 | time: point.recorded_at 24 | ) 25 | end 26 | 27 | GPX::GPXFile.new( 28 | name: "dawarich_#{name}", 29 | gpx_data: gpx_file.to_s.sub(' e 13 | Rails.logger.error("Failed to send tile usage metric: #{e.message}") 14 | end 15 | 16 | private 17 | 18 | def report_to_prometheus 19 | return unless DawarichSettings.prometheus_exporter_enabled? 20 | 21 | metric_data = { 22 | type: 'counter', 23 | name: 'dawarich_map_tiles_usage', 24 | value: @count 25 | } 26 | 27 | PrometheusExporter::Client.default.send_json(metric_data) 28 | end 29 | 30 | def report_to_cache 31 | today_key = "dawarich_map_tiles_usage:#{@user_id}:#{Time.zone.today}" 32 | 33 | current_value = (Rails.cache.read(today_key) || 0).to_i 34 | Rails.cache.write(today_key, current_value + @count, expires_in: 7.days) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/services/notifications/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Notifications::Create 4 | attr_reader :user, :kind, :title, :content 5 | 6 | def initialize(user:, kind:, title:, content:) 7 | @user = user 8 | @kind = kind 9 | @title = title 10 | @content = content 11 | end 12 | 13 | def call 14 | Notification.create!(user:, kind:, title:, content:) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/services/own_tracks/rec_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class OwnTracks::RecParser 4 | attr_reader :file 5 | 6 | def initialize(file) 7 | @file = file 8 | end 9 | 10 | def call 11 | file.split("\n").map do |line| 12 | parts = line.split("\t") 13 | 14 | Oj.load(parts[2]) if parts.size > 2 && parts[1].strip == '*' 15 | end.compact 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/services/photoprism/cache_preview_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Photoprism::CachePreviewToken 4 | attr_reader :user, :preview_token 5 | 6 | TOKEN_CACHE_KEY = 'dawarich/photoprism_preview_token' 7 | 8 | def initialize(user, preview_token) 9 | @user = user 10 | @preview_token = preview_token 11 | end 12 | 13 | def call 14 | Rails.cache.write("#{TOKEN_CACHE_KEY}_#{user.id}", preview_token) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/services/photos/importer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Photos::Importer 4 | include Imports::Broadcaster 5 | include PointValidation 6 | attr_reader :import, :user_id 7 | 8 | def initialize(import, user_id) 9 | @import = import 10 | @user_id = user_id 11 | end 12 | 13 | def call 14 | file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification 15 | json = Oj.load(file_content) 16 | 17 | json.each.with_index(1) { |point, index| create_point(point, index) } 18 | end 19 | 20 | def create_point(point, index) 21 | return 0 if point['latitude'].blank? || point['longitude'].blank? || point['timestamp'].blank? 22 | return 0 if point_exists?(point, point['timestamp']) 23 | 24 | Point.create( 25 | lonlat: "POINT(#{point['longitude']} #{point['latitude']})", 26 | timestamp: point['timestamp'], 27 | raw_data: point, 28 | import_id: import.id, 29 | user_id: 30 | ) 31 | 32 | broadcast_import_progress(import, index) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/services/points/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Points::Create 4 | attr_reader :user, :params 5 | 6 | def initialize(user, params) 7 | @user = user 8 | @params = params.to_h 9 | end 10 | 11 | def call 12 | data = Points::Params.new(params, user.id).call 13 | 14 | # Deduplicate points based on unique constraint 15 | deduplicated_data = data.uniq { |point| [point[:lonlat], point[:timestamp], point[:user_id]] } 16 | 17 | created_points = [] 18 | 19 | deduplicated_data.each_slice(1000) do |location_batch| 20 | # rubocop:disable Rails/SkipsModelValidations 21 | result = Point.upsert_all( 22 | location_batch, 23 | unique_by: %i[lonlat timestamp user_id], 24 | returning: Arel.sql('id, timestamp, ST_X(lonlat::geometry) AS longitude, ST_Y(lonlat::geometry) AS latitude') 25 | ) 26 | # rubocop:enable Rails/SkipsModelValidations 27 | 28 | created_points.concat(result) 29 | end 30 | 31 | created_points 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/services/points_limit_exceeded.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PointsLimitExceeded 4 | def initialize(user) 5 | @user = user 6 | end 7 | 8 | def call 9 | return false if DawarichSettings.self_hosted? 10 | return true if @user.points.count >= points_limit 11 | 12 | false 13 | end 14 | 15 | private 16 | 17 | def points_limit 18 | DawarichSettings::BASIC_PAID_PLAN_LIMIT 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/services/reverse_geocoding/points/fetch_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ReverseGeocoding::Points::FetchData 4 | attr_reader :point 5 | 6 | def initialize(point_id) 7 | @point = Point.find(point_id) 8 | rescue ActiveRecord::RecordNotFound => e 9 | ExceptionReporter.call(e) 10 | 11 | Rails.logger.error("Point with id #{point_id} not found: #{e.message}") 12 | end 13 | 14 | def call 15 | return if point.reverse_geocoded? 16 | 17 | update_point_with_geocoding_data 18 | end 19 | 20 | private 21 | 22 | def update_point_with_geocoding_data 23 | response = Geocoder.search([point.lat, point.lon]).first 24 | return if response.blank? || response.data['error'].present? 25 | 26 | point.update!( 27 | city: response.city, 28 | country: response.country, 29 | geodata: response.data, 30 | reverse_geocoded_at: Time.current 31 | ) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/services/subscription/decode_jwt_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Subscription::DecodeJwtToken 4 | def initialize(token) 5 | @token = token 6 | end 7 | 8 | def call 9 | JWT.decode( 10 | @token, 11 | ENV['JWT_SECRET_KEY'], 12 | true, 13 | { algorithm: 'HS256' } 14 | ).first.symbolize_keys 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/services/tracks/build_path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Tracks::BuildPath 4 | def initialize(coordinates) 5 | @coordinates = coordinates 6 | end 7 | 8 | def call 9 | factory.line_string( 10 | coordinates.map { |point| factory.point(point.lon.to_f.round(5), point.lat.to_f.round(5)) } 11 | ) 12 | end 13 | 14 | private 15 | 16 | attr_reader :coordinates 17 | 18 | def factory 19 | @factory ||= RGeo::Geographic.spherical_factory(srid: 3857) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/services/visits/find_in_time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Visits 4 | class FindInTime 5 | def initialize(user, params) 6 | @user = user 7 | @start_at = parse_time(params[:start_at]) 8 | @end_at = parse_time(params[:end_at]) 9 | end 10 | 11 | def call 12 | Visit 13 | .includes(:place) 14 | .where(user:) 15 | .where('started_at >= ? AND ended_at <= ?', start_at, end_at) 16 | .order(started_at: :desc) 17 | end 18 | 19 | private 20 | 21 | attr_reader :user, :start_at, :end_at 22 | 23 | def parse_time(time_string) 24 | parsed_time = Time.zone.parse(time_string) 25 | 26 | raise ArgumentError, "Invalid time format: #{time_string}" if parsed_time.nil? 27 | 28 | parsed_time 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/services/visits/find_within_bounding_box.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Visits 4 | # Finds visits in a selected area on the map 5 | class FindWithinBoundingBox 6 | def initialize(user, params) 7 | @user = user 8 | @sw_lat = params[:sw_lat].to_f 9 | @sw_lng = params[:sw_lng].to_f 10 | @ne_lat = params[:ne_lat].to_f 11 | @ne_lng = params[:ne_lng].to_f 12 | end 13 | 14 | def call 15 | Visit 16 | .includes(:place) 17 | .where(user:) 18 | .joins(:place) 19 | .where( 20 | 'ST_Contains(ST_MakeEnvelope(?, ?, ?, ?, 4326), ST_SetSRID(places.lonlat::geometry, 4326))', 21 | sw_lng, 22 | sw_lat, 23 | ne_lng, 24 | ne_lat 25 | ) 26 | .order(started_at: :desc) 27 | end 28 | 29 | private 30 | 31 | attr_reader :user, :sw_lat, :sw_lng, :ne_lat, :ne_lng 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/services/visits/finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Visits 4 | # Finds visits in a selected area on the map 5 | class Finder 6 | def initialize(user, params) 7 | @user = user 8 | @params = params 9 | end 10 | 11 | def call 12 | if area_selected? 13 | Visits::FindWithinBoundingBox.new(user, params).call 14 | else 15 | Visits::FindInTime.new(user, params).call 16 | end 17 | end 18 | 19 | private 20 | 21 | attr_reader :user, :params 22 | 23 | def area_selected? 24 | params[:selection] == 'true' && 25 | params[:sw_lat].present? && 26 | params[:sw_lng].present? && 27 | params[:ne_lat].present? && 28 | params[:ne_lng].present? 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/views/active_storage/blobs/_blob.html.erb: -------------------------------------------------------------------------------- 1 |
attachment--<%= blob.filename.extension %>"> 2 | <% if blob.representable? %> 3 | <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> 4 | <% end %> 5 | 6 |
7 | <% if caption = blob.try(:caption) %> 8 | <%= caption %> 9 | <% else %> 10 | <%= blob.filename %> 11 | <%= number_to_human_size blob.byte_size %> 12 | <% end %> 13 |
14 |
15 | -------------------------------------------------------------------------------- /app/views/application/_favicon.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/views/devise/confirmations/new.html.erb: -------------------------------------------------------------------------------- 1 |

Resend confirmation instructions

2 | 3 | <%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= render "devise/shared/error_messages", resource: resource %> 5 | 6 |
7 | <%= f.label :email %>
8 | <%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %> 9 |
10 | 11 |
12 | <%= f.submit "Resend confirmation instructions" %> 13 |
14 | <% end %> 15 | 16 | <%= render "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /app/views/devise/mailer/confirmation_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Welcome <%= @email %>!

2 | 3 |

You can confirm your account email through the link below:

4 | 5 |

<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>

6 | -------------------------------------------------------------------------------- /app/views/devise/mailer/email_changed.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @email %>!

2 | 3 | <% if @resource.try(:unconfirmed_email?) %> 4 |

We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.

5 | <% else %> 6 |

We're contacting you to notify you that your email has been changed to <%= @resource.email %>.

7 | <% end %> 8 | -------------------------------------------------------------------------------- /app/views/devise/mailer/password_change.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

We're contacting you to notify you that your password has been changed.

4 | -------------------------------------------------------------------------------- /app/views/devise/mailer/reset_password_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

Someone has requested a link to change your password. You can do this through the link below.

4 | 5 |

<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

6 | 7 |

If you didn't request this, please ignore this email.

8 |

Your password won't change until you access the link above and create a new one.

9 | -------------------------------------------------------------------------------- /app/views/devise/mailer/unlock_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

Your account has been locked due to an excessive number of unsuccessful sign in attempts.

4 | 5 |

Click the link below to unlock your account:

6 | 7 |

<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>

8 | -------------------------------------------------------------------------------- /app/views/devise/passwords/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Change your password

2 | 3 | <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %> 4 | <%= render "devise/shared/error_messages", resource: resource %> 5 | <%= f.hidden_field :reset_password_token %> 6 | 7 |
8 | <%= f.label :password, "New password" %>
9 | <% if @minimum_password_length %> 10 | (<%= @minimum_password_length %> characters minimum)
11 | <% end %> 12 | <%= f.password_field :password, autofocus: true, autocomplete: "new-password" %> 13 |
14 | 15 |
16 | <%= f.label :password_confirmation, "Confirm new password" %>
17 | <%= f.password_field :password_confirmation, autocomplete: "new-password" %> 18 |
19 | 20 |
21 | <%= f.submit "Change my password" %> 22 |
23 | <% end %> 24 | 25 | <%= render "devise/shared/links" %> 26 | -------------------------------------------------------------------------------- /app/views/devise/passwords/new.html.erb: -------------------------------------------------------------------------------- 1 |

Forgot your password?

2 | 3 | <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= render "devise/shared/error_messages", resource: resource %> 5 | 6 |
7 | <%= f.label :email %>
8 | <%= f.email_field :email, autofocus: true, autocomplete: "email" %> 9 |
10 | 11 |
12 | <%= f.submit "Send me reset password instructions" %> 13 |
14 | <% end %> 15 | 16 | <%= render "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /app/views/devise/registrations/_points_usage.html.erb: -------------------------------------------------------------------------------- 1 |

2 |

3 | You have used <%= number_with_delimiter(current_user.points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available. 4 |

5 | 6 |

7 | -------------------------------------------------------------------------------- /app/views/devise/shared/_error_messages.html.erb: -------------------------------------------------------------------------------- 1 | <% if resource.errors.any? %> 2 |
3 |

4 | <%= I18n.t("errors.messages.not_saved", 5 | count: resource.errors.count, 6 | resource: resource.class.model_name.human.downcase) 7 | %> 8 |

9 | 14 |
15 | <% end %> 16 | -------------------------------------------------------------------------------- /app/views/devise/unlocks/new.html.erb: -------------------------------------------------------------------------------- 1 |

Resend unlock instructions

2 | 3 | <%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= render "devise/shared/error_messages", resource: resource %> 5 | 6 |
7 | <%= f.label :email %>
8 | <%= f.email_field :email, autofocus: true, autocomplete: "email" %> 9 |
10 | 11 |
12 | <%= f.submit "Resend unlock instructions" %> 13 |
14 | <% end %> 15 | 16 | <%= render "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /app/views/imports/_import.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 18 | 19 | 20 | 21 |
NameImported pointsCreated at
13 | <%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>) 14 | 16 | <%= "#{number_with_delimiter import.points.size}" %> 17 | <%= human_datetime(import.created_at) %>
22 | 23 | <% if action_name != "show" %> 24 | <%= link_to "Show this import", import, class: "rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> 25 | <%= link_to "Edit this import", edit_import_path(import), class: "rounded-lg py-3 ml-2 px-5 bg-gray-100 inline-block font-medium" %> 26 |
27 | <% end %> 28 |
29 | -------------------------------------------------------------------------------- /app/views/imports/edit.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Editing import

3 | 4 | <%= form_with model: @import, class: 'form-body mt-4' do |form| %> 5 |
6 | <%= form.label :name %> 7 | <%= form.text_field :name, class: 'input input-bordered' %> 8 |
9 | 10 |
11 | <%= form.label :source %> 12 | <%= form.select :source, options_for_select(Import.sources.keys.map { |source| [source.humanize, source] }, @import.source), {}, class: 'select select-bordered' %> 13 |
14 | 15 |
16 | <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> 17 | <%= link_to "Back to imports", imports_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> 18 |
19 | <% end %> 20 |
21 | -------------------------------------------------------------------------------- /app/views/imports/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, 'New Import' %> 2 | 3 |
4 |

New import

5 | 6 | <%= render "form", import: @import %> 7 | 8 | <%= link_to "Back to imports", imports_path, class: "btn mx-5 mb-5" %> 9 |
10 | -------------------------------------------------------------------------------- /app/views/imports/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, 'Import' %> 2 | 3 |
4 |
5 | <% if notice.present? %> 6 |

<%= notice %>

7 | <% end %> 8 | 9 | <%= render @import %> 10 | 11 | <%= link_to "Edit this import", edit_import_path(@import), class: "mt-2 rounded-lg py-3 px-5 bg-secondary-content inline-block font-medium" %> 12 |
13 | <%= link_to "Destroy this import", import_path(@import), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This action will delete all points imported with this file", turbo_method: :delete }, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-secondary-content font-medium" %> 14 |
15 | <%= link_to "Back to imports", imports_path, class: "ml-2 rounded-lg py-3 px-5 bg-secondary-content inline-block font-medium" %> 16 |
17 |
18 | -------------------------------------------------------------------------------- /app/views/layouts/action_text/contents/_content.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= yield %> 3 |
4 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/views/notifications/show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render @notification %> 4 | 5 |
6 | <%= link_to "Back to notifications", notifications_path, class: "btn btn-small" %> 7 |
8 | <%= button_to "Destroy this notification", @notification, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-small btn-warning" %> 9 |
10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /app/views/points/_point.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= check_box_tag "point_ids[]", 4 | point.id, 5 | nil, 6 | { 7 | multiple: true, 8 | form: :bulk_destroy_form, 9 | data: { 10 | checkbox_select_all_target: 'child', 11 | action: 'change->checkbox-select-all#toggleParent' 12 | } 13 | } 14 | %> 15 | 16 | <%= point.velocity %> 17 | <%= human_datetime_with_seconds(point.recorded_at) %> 18 | <%= point.lat %>, <%= point.lon %> 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/views/settings/_navigation.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= link_to 'Integrations', settings_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_path)}" %> 3 | <%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_maps_path)}" %> 4 | <% if DawarichSettings.self_hosted? && current_user.admin? %> 5 | <%= link_to 'Users', settings_users_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_users_path)}" %> 6 | <%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_background_jobs_path)}" %> 7 | <% end %> 8 |
9 | -------------------------------------------------------------------------------- /app/views/shared/_flash.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% flash.each do |key, value| %> 3 |
6 |
<%= value %>
7 | 8 | 13 |
14 | <% end %> 15 |
16 | -------------------------------------------------------------------------------- /app/views/shared/_footer.html.erb: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /app/views/stats/_year.html.erb: -------------------------------------------------------------------------------- 1 |

2 | <%= link_to year, "/stats/#{year}", class: "underline hover:no-underline text-#{header_colors.sample}" %> 3 | <%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %> 4 |

5 |
6 | <%= column_chart( 7 | Stat.year_distance(year, current_user), 8 | height: '200px', 9 | suffix: " #{current_user.safe_settings.distance_unit}", 10 | xtitle: 'Days', 11 | ytitle: 'Distance' 12 | ) %> 13 |
14 |
15 | <% stats.each do |stat| %> 16 | <%= render stat %> 17 | <% end %> 18 |
19 | -------------------------------------------------------------------------------- /app/views/stats/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, "Statistics for #{@year} year" %> 2 | 3 |
4 | <%= render partial: 'stats/year', locals: { year: @year, stats: @stats } %> 5 |
6 | -------------------------------------------------------------------------------- /app/views/trips/_distance.html.erb: -------------------------------------------------------------------------------- 1 | <% if trip.distance.present? %> 2 | <%= trip.distance %> <%= distance_unit %> 3 | <% else %> 4 | Calculating... 5 | 6 | <% end %> 7 | -------------------------------------------------------------------------------- /app/views/trips/_path.html.erb: -------------------------------------------------------------------------------- 1 | <% if trip.path.present? %> 2 |
13 |
14 |
15 |
16 | <% else %> 17 |
18 |
19 |

Trip path is being calculated...

20 |
21 |
22 |
23 | <% end %> 24 | -------------------------------------------------------------------------------- /app/views/trips/edit.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Editing trip

3 | 4 | <%= render "form", trip: @trip %> 5 | 6 | 7 |
8 |
9 | <%= link_to "Show this trip", @trip, class: "btn" %> 10 | <%= link_to "Back to trips", trips_path, class: "btn" %> 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /app/views/trips/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, 'New trip' %> 2 | 3 |
4 |

New trip

5 | 6 | <%= render "form", trip: @trip %> 7 | 8 | <%= link_to "Back to trips", trips_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> 9 |
10 | -------------------------------------------------------------------------------- /app/views/visits/_buttons.html.erb: -------------------------------------------------------------------------------- 1 | <% if !visit.confirmed? %> 2 | <%= link_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-success' %> 3 | <% end %> 4 | <% if !visit.declined? %> 5 | <%= link_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-error mx-1' %> 6 | <% end %> 7 | -------------------------------------------------------------------------------- /app/views/visits/_name.html.erb: -------------------------------------------------------------------------------- 1 | 5 | 6 | 12 | <%= visit.default_name %> 13 | 14 | 15 | 16 | 23 | 24 | -------------------------------------------------------------------------------- /app/views/visits/_visit.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | <%= render 'visits/name', visit: visit %> 5 |
<%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %>
6 |
7 |
8 | <%= render 'visits/buttons', visit: visit %> 9 | 10 | 11 | 12 | <%= render 'visits/modal', visit: visit %> 13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if ! command -v foreman &> /dev/null 4 | then 5 | echo "Installing foreman..." 6 | gem install foreman 7 | fi 8 | 9 | if [ "$PROMETHEUS_EXPORTER_ENABLED" = "true" ]; then 10 | echo "Starting Foreman with Procfile.prometheus.dev..." 11 | foreman start -f Procfile.prometheus.dev 12 | else 13 | echo "Starting Foreman with Procfile.dev..." 14 | foreman start -f Procfile.dev 15 | fi 16 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative '../config/application' 4 | require 'importmap/commands' 5 | -------------------------------------------------------------------------------- /bin/jobs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/environment" 4 | require "solid_queue/cli" 5 | 6 | SolidQueue::Cli.start(ARGV) 7 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | Rails.application.load_server 9 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | # Async adapter only works within the same process, so for manually triggering cable updates from a console, 2 | # and seeing results in the browser, you must do so from the web console (running inside the dev process), 3 | # not a terminal started via bin/rails console! Add "console" to any action or any ERB template view 4 | # to make the web console appear. 5 | 6 | default: &default 7 | adapter: solid_cable 8 | connects_to: 9 | database: 10 | writing: cable 11 | polling_interval: 0.1.seconds 12 | message_retention: 1.day 13 | 14 | development: 15 | <<: *default 16 | 17 | test: 18 | adapter: test 19 | 20 | production: 21 | <<: *default 22 | -------------------------------------------------------------------------------- /config/cache.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | store_options: 3 | # Cap age of oldest cache entry to fulfill retention policies 4 | max_age: <%= 60.days.to_i %> 5 | max_size: <%= 256.megabytes %> 6 | namespace: <%= Rails.env %> 7 | 8 | development: 9 | <<: *default 10 | 11 | test: 12 | <<: *default 13 | 14 | production: 15 | <<: *default 16 | -------------------------------------------------------------------------------- /config/database.ci.yml: -------------------------------------------------------------------------------- 1 | # config/database.ci.yml 2 | test: 3 | adapter: postgis 4 | encoding: unicode 5 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 6 | host: localhost 7 | database: <%= ENV["POSTGRES_DB"] %> 8 | username: <%= ENV['POSTGRES_USER'] %> 9 | password: <%= ENV["POSTGRES_PASSWORD"] %> 10 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /config/initializers/00_random.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This code fixes failed to get urandom for running Ruby on Docker for Synology. 4 | class Random 5 | class << self 6 | private 7 | 8 | # :stopdoc: 9 | 10 | # Implementation using OpenSSL 11 | def gen_random_openssl(n) 12 | OpenSSL::Random.random_bytes(n) 13 | end 14 | 15 | begin 16 | # Check if Random.urandom is available 17 | Random.urandom(1) 18 | rescue RuntimeError 19 | begin 20 | require 'openssl' 21 | rescue NoMethodError 22 | raise NotImplementedError, 'No random device' 23 | else 24 | alias urandom gen_random_openssl 25 | end 26 | end 27 | 28 | public :urandom 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Version of your assets, change this if you want to expire all your assets. 6 | Rails.application.config.assets.version = '1.0' 7 | 8 | # Add additional assets to the asset load path. 9 | # Rails.application.config.assets.paths << Emoji.images_path 10 | -------------------------------------------------------------------------------- /config/initializers/aws.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'aws-sdk-core' 4 | 5 | if ENV['AWS_ACCESS_KEY_ID'] && 6 | ENV['AWS_SECRET_ACCESS_KEY'] && 7 | ENV['AWS_REGION'] && 8 | ENV['AWS_ENDPOINT'] 9 | Aws.config.update( 10 | { 11 | region: ENV['AWS_REGION'], 12 | endpoint: ENV['AWS_ENDPOINT'], 13 | credentials: Aws::Credentials.new(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY']) 14 | } 15 | ) 16 | end 17 | -------------------------------------------------------------------------------- /config/initializers/cache_jobs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.config.after_initialize do 4 | # Only run in server mode and ensure one-time execution with atomic write 5 | if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true) 6 | # Clear the cache 7 | Cache::CleaningJob.perform_later 8 | 9 | # Preheat the cache 10 | Cache::PreheatingJob.perform_later 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 6 | # Use this to limit dissemination of sensitive information. 7 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 8 | Rails.application.config.filter_parameters += %i[ 9 | passw secret token _key crypt salt certificate otp ssn cvv cvc latitude longitude lat lng 10 | ] 11 | -------------------------------------------------------------------------------- /config/initializers/geocoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | settings = { 4 | timeout: 5, 5 | units: :km, 6 | cache: Redis.new, 7 | always_raise: :all, 8 | use_https: PHOTON_API_USE_HTTPS, 9 | http_headers: { 'User-Agent' => "Dawarich #{APP_VERSION} (https://dawarich.app)" }, 10 | cache_options: { 11 | expiration: 1.day 12 | } 13 | } 14 | 15 | if PHOTON_API_HOST.present? 16 | settings[:lookup] = :photon 17 | settings[:photon] = { use_https: PHOTON_API_USE_HTTPS, host: PHOTON_API_HOST } 18 | settings[:http_headers] = { 'X-Api-Key' => PHOTON_API_KEY } if PHOTON_API_KEY.present? 19 | elsif GEOAPIFY_API_KEY.present? 20 | settings[:lookup] = :geoapify 21 | settings[:api_key] = GEOAPIFY_API_KEY 22 | elsif NOMINATIM_API_HOST.present? 23 | settings[:lookup] = :nominatim 24 | settings[:nominatim] = { use_https: NOMINATIM_API_USE_HTTPS, host: NOMINATIM_API_HOST } 25 | settings[:api_key] = NOMINATIM_API_KEY if NOMINATIM_API_KEY.present? 26 | end 27 | 28 | Geocoder.configure(settings) 29 | -------------------------------------------------------------------------------- /config/initializers/httparty.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Suppress warnings about nil deprecation 4 | # https://github.com/jnunemaker/httparty/issues/568#issuecomment-1450473603 5 | 6 | HTTParty::Response.class_eval do 7 | def warn_about_nil_deprecation; end 8 | end 9 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Mime::Type.register 'application/geo+json', :geojson 4 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide HTTP permissions policy. For further 4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 5 | 6 | # Rails.application.config.permissions_policy do |policy| 7 | # policy.camera :none 8 | # policy.gyroscope :none 9 | # policy.microphone :none 10 | # policy.usb :none 11 | # policy.fullscreen :self 12 | # policy.payment :self, "https://secure.example.com" 13 | # end 14 | -------------------------------------------------------------------------------- /config/initializers/prometheus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled? 4 | require 'prometheus_exporter/middleware' 5 | require 'prometheus_exporter/instrumentation' 6 | 7 | # This reports stats per request like HTTP status and timings 8 | Rails.application.middleware.unshift PrometheusExporter::Middleware 9 | 10 | # This reports basic process stats like RSS and GC info 11 | PrometheusExporter::Instrumentation::Process.start(type: 'web') 12 | 13 | # Add ActiveRecord instrumentation 14 | PrometheusExporter::Instrumentation::ActiveRecord.start 15 | end 16 | -------------------------------------------------------------------------------- /config/initializers/rswag_api.rb: -------------------------------------------------------------------------------- 1 | Rswag::Api.configure do |c| 2 | 3 | # Specify a root folder where Swagger JSON files are located 4 | # This is used by the Swagger middleware to serve requests for API descriptions 5 | # NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure 6 | # that it's configured to generate files in the same folder 7 | c.openapi_root = Rails.root.to_s + '/swagger' 8 | 9 | # Inject a lambda function to alter the returned Swagger prior to serialization 10 | # The function will have access to the rack env for the current request 11 | # For example, you could leverage this to dynamically assign the "host" property 12 | # 13 | #c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } 14 | end 15 | -------------------------------------------------------------------------------- /config/initializers/rswag_ui.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rswag::Ui.configure do |c| 4 | 5 | # List the Swagger endpoints that you want to be documented through the 6 | # swagger-ui. The first parameter is the path (absolute or relative to the UI 7 | # host) to the corresponding endpoint and the second is a title that will be 8 | # displayed in the document selector. 9 | # NOTE: If you're using rspec-api to expose Swagger files 10 | # (under openapi_root) as JSON or YAML endpoints, then the list below should 11 | # correspond to the relative paths for those endpoints. 12 | 13 | c.openapi_endpoint '/api-docs/v1/swagger.yaml', 'API V1 Docs' 14 | 15 | # Add Basic Auth in case your API is private 16 | # c.basic_auth_enabled = true 17 | # c.basic_auth_credentials 'username', 'password' 18 | end 19 | -------------------------------------------------------------------------------- /config/initializers/sentry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | return unless SENTRY_DSN 4 | 5 | Sentry.init do |config| 6 | config.breadcrumbs_logger = [:active_support_logger] 7 | config.dsn = SENTRY_DSN 8 | config.traces_sample_rate = 1.0 9 | config.profiles_sample_rate = 1.0 10 | # config.enable_logs = true 11 | end 12 | -------------------------------------------------------------------------------- /config/initializers/strong_migrations.rb: -------------------------------------------------------------------------------- 1 | # Mark existing migrations as safe 2 | StrongMigrations.start_after = 20_250_122_150_500 3 | 4 | # Set timeouts for migrations 5 | # If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user 6 | StrongMigrations.lock_timeout = 10.seconds 7 | StrongMigrations.statement_timeout = 1.hour 8 | 9 | # Analyze tables after indexes are added 10 | # Outdated statistics can sometimes hurt performance 11 | StrongMigrations.auto_analyze = true 12 | 13 | # Set the version of the production database 14 | # so the right checks are run in development 15 | # StrongMigrations.target_version = 10 16 | 17 | # Add custom checks 18 | # StrongMigrations.add_check do |method, args| 19 | # if method == :add_index && args[0].to_s == "users" 20 | # stop! "No more indexes on the users table" 21 | # end 22 | # end 23 | 24 | # Make some operations safe by default 25 | # See https://github.com/ankane/strong_migrations#safe-by-default 26 | # StrongMigrations.safe_by_default = true 27 | -------------------------------------------------------------------------------- /config/initializers/web_app_manifest.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by rails_favicon_generator, from 2 | # https://realfavicongenerator.net/ 3 | 4 | # It makes files with .webmanifest extension first class files in the asset 5 | # pipeline. This is to preserve this extension, as is it referenced in a call 6 | # to asset_path in the _favicon.html.erb partial. 7 | 8 | Rails.application.config.assets.configure do |env| 9 | mime_type = 'application/manifest+json' 10 | extensions = ['.webmanifest'] 11 | 12 | if Sprockets::VERSION.to_i >= 4 13 | extensions << '.webmanifest.erb' 14 | env.register_preprocessor(mime_type, Sprockets::ERBProcessor) 15 | end 16 | 17 | env.register_mime_type(mime_type, extensions: extensions) 18 | end 19 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | 2 | en: 3 | 4 | -------------------------------------------------------------------------------- /config/queue.yml: -------------------------------------------------------------------------------- 1 | 2 | default: &default 3 | dispatchers: 4 | - polling_interval: 1 5 | batch_size: 500 6 | workers: 7 | - queues: "*" 8 | threads: 3 9 | processes: <%= ENV['BACKGROUND_PROCESSING_CONCURRENCY'] || ENV.fetch("JOB_CONCURRENCY", 10) %> 10 | polling_interval: 2 11 | - queues: imports 12 | threads: 5 13 | processes: 1 14 | polling_interval: 1 15 | - queues: exports 16 | threads: 5 17 | processes: 1 18 | polling_interval: 2 19 | 20 | development: 21 | <<: *default 22 | 23 | test: 24 | <<: *default 25 | 26 | production: 27 | <<: *default 28 | -------------------------------------------------------------------------------- /config/recurring.yml: -------------------------------------------------------------------------------- 1 | periodic_cleanup: 2 | class: "Jobs::CleanFinishedJob" 3 | queue: default 4 | schedule: every month 5 | 6 | bulk_stats_calculating_job: 7 | class: "BulkStatsCalculatingJob" 8 | queue: stats 9 | schedule: every hour 10 | 11 | area_visits_calculation_scheduling_job: 12 | class: "AreaVisitsCalculationSchedulingJob" 13 | queue: visit_suggesting 14 | schedule: every day at 0:00 15 | 16 | visit_suggesting_job: 17 | class: "BulkVisitsSuggestingJob" 18 | queue: visit_suggesting 19 | schedule: every day at 00:05 20 | 21 | watcher_job: 22 | class: "Import::WatcherJob" 23 | queue: imports 24 | schedule: every hour 25 | 26 | app_version_checking_job: 27 | class: "AppVersionCheckingJob" 28 | queue: default 29 | schedule: every 6 hours 30 | 31 | cache_preheating_job: 32 | class: "Cache::PreheatingJob" 33 | queue: default 34 | schedule: every day at 0:00 35 | -------------------------------------------------------------------------------- /config/schedule.yml: -------------------------------------------------------------------------------- 1 | # config/schedule.yml 2 | 3 | bulk_stats_calculating_job: 4 | cron: "0 */1 * * *" # every 1 hour 5 | class: "BulkStatsCalculatingJob" 6 | queue: stats 7 | 8 | area_visits_calculation_scheduling_job: 9 | cron: "0 0 * * *" # every day at 0:00 10 | class: "AreaVisitsCalculationSchedulingJob" 11 | queue: visit_suggesting 12 | 13 | visit_suggesting_job: 14 | cron: "5 0 * * *" # every day at 00:05 15 | class: "BulkVisitsSuggestingJob" 16 | queue: visit_suggesting 17 | 18 | watcher_job: 19 | cron: "0 */1 * * *" # every 1 hour 20 | class: "Import::WatcherJob" 21 | queue: imports 22 | 23 | app_version_checking_job: 24 | cron: "0 */6 * * *" # every 6 hours 25 | class: "AppVersionCheckingJob" 26 | queue: default 27 | 28 | cache_preheating_job: 29 | cron: "0 0 * * *" # every day at 0:00 30 | class: "Cache::PreheatingJob" 31 | queue: default 32 | -------------------------------------------------------------------------------- /config/sidekiq.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :concurrency: <%= ENV.fetch("BACKGROUND_PROCESSING_CONCURRENCY", 10) %> 3 | :queues: 4 | - points 5 | - default 6 | - imports 7 | - exports 8 | - stats 9 | - reverse_geocoding 10 | - visit_suggesting 11 | -------------------------------------------------------------------------------- /config/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | 3 | module.exports = { 4 | content: [ 5 | './app/helpers/**/*.rb', 6 | './app/javascript/**/*.js', 7 | './app/views/**/*.{erb,haml,html,slim}' 8 | ], 9 | theme: { 10 | extend: { 11 | fontFamily: { 12 | sans: ['Inter var', ...defaultTheme.fontFamily.sans], 13 | }, 14 | }, 15 | }, 16 | plugins: [ 17 | require('daisyui'), 18 | require('@tailwindcss/forms'), 19 | require('@tailwindcss/aspect-ratio'), 20 | require('@tailwindcss/typography'), 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /db/data/20240525110530_bind_existing_points_to_first_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BindExistingPointsToFirstUser < ActiveRecord::Migration[7.1] 4 | def up 5 | user = User.first 6 | 7 | return if user.blank? 8 | 9 | points = Point.where(user_id: nil) 10 | 11 | points.update_all(user_id: user.id) 12 | 13 | Rails.logger.info "Bound #{points.count} points to user #{user.email}" 14 | end 15 | 16 | def down 17 | raise ActiveRecord::IrreversibleMigration 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/data/20240610170930_remove_points_without_coordinates.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemovePointsWithoutCoordinates < ActiveRecord::Migration[7.1] 4 | def up 5 | points = Point.where('longitude = 0.0 OR latitude = 0.0') 6 | 7 | Rails.logger.info "Found #{points.count} points without coordinates..." 8 | 9 | points 10 | .select { |point| point.raw_data['latitudeE7'].nil? && point.raw_data['longitudeE7'].nil? } 11 | .each(&:destroy) 12 | 13 | Rails.logger.info 'Points without coordinates removed.' 14 | 15 | BulkStatsCalculatingJob.perform_later 16 | end 17 | 18 | def down 19 | raise ActiveRecord::IrreversibleMigration 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /db/data/20240625201842_add_fog_of_war_meters_to_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddFogOfWarMetersToSettings < ActiveRecord::Migration[7.1] 4 | def up 5 | User.find_each do |user| 6 | user.settings = user.settings.merge(fog_of_war_meters: 100) 7 | user.save! 8 | end 9 | end 10 | 11 | def down 12 | raise ActiveRecord::IrreversibleMigration 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/data/20240713103122_make_first_user_admin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MakeFirstUserAdmin < ActiveRecord::Migration[7.1] 4 | def up 5 | user = User.first 6 | user&.update!(admin: true) 7 | end 8 | 9 | def down 10 | user = User.first 11 | user&.update!(admin: false) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/data/20240724141417_add_visit_settings_to_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddVisitSettingsToUser < ActiveRecord::Migration[7.1] 4 | def up 5 | User.find_each do |user| 6 | user.settings = user.settings.merge( 7 | time_threshold_minutes: 30, 8 | merge_threshold_minutes: 15 9 | ) 10 | user.save! 11 | end 12 | end 13 | 14 | def down 15 | raise ActiveRecord::IrreversibleMigration 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/data/20240730130922_add_route_opacity_to_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddRouteOpacityToSettings < ActiveRecord::Migration[7.1] 4 | def up 5 | User.find_each do |user| 6 | user.settings = user.settings.merge(route_opacity: 20) 7 | user.save! 8 | end 9 | end 10 | 11 | def down 12 | raise ActiveRecord::IrreversibleMigration 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/data/20240808133112_run_initial_visit_suggestion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RunInitialVisitSuggestion < ActiveRecord::Migration[7.1] 4 | def up 5 | start_at = 30.years.ago 6 | end_at = Time.current 7 | 8 | User.find_each do |user| 9 | VisitSuggestingJob.perform_later(user_id: user.id, start_at:, end_at:) 10 | end 11 | end 12 | 13 | def down 14 | raise ActiveRecord::IrreversibleMigration 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/data/20240822094532_add_counter_cache_to_imports.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCounterCacheToImports < ActiveRecord::Migration[7.1] 4 | def up 5 | Import.find_each do |import| 6 | Import.reset_counters(import.id, :points) 7 | end 8 | end 9 | 10 | def down 11 | raise ActiveRecord::IrreversibleMigration 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/data/20241022100309_add_points_rendering_mode_to_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPointsRenderingModeToSettings < ActiveRecord::Migration[7.2] 4 | def up 5 | User.find_each do |user| 6 | user.settings = user.settings.merge(points_rendering_mode: 'raw') 7 | user.save! 8 | end 9 | end 10 | 11 | def down 12 | raise ActiveRecord::IrreversibleMigration 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/data/20241107112451_add_live_map_enabled_to_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddLiveMapEnabledToSettings < ActiveRecord::Migration[7.2] 4 | def up 5 | User.find_each do |user| 6 | user.settings = user.settings.merge(live_map_enabled: false) 7 | user.save! 8 | end 9 | end 10 | 11 | def down 12 | raise ActiveRecord::IrreversibleMigration 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/data/20241202125248_set_reverse_geocoded_at_for_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SetReverseGeocodedAtForPoints < ActiveRecord::Migration[7.2] 4 | def up 5 | DataMigrations::SetReverseGeocodedAtForPointsJob.perform_later 6 | end 7 | 8 | def down 9 | raise ActiveRecord::IrreversibleMigration 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/data/20241206163450_create_telemetry_notification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTelemetryNotification < ActiveRecord::Migration[7.2] 4 | def up; end 5 | 6 | def down; end 7 | end 8 | -------------------------------------------------------------------------------- /db/data/20250120154554_remove_duplicate_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveDuplicatePoints < ActiveRecord::Migration[8.0] 4 | def up 5 | # Find duplicate groups using a subquery 6 | duplicate_groups = 7 | Point.select('latitude, longitude, timestamp, user_id, COUNT(*) as count') 8 | .group('latitude, longitude, timestamp, user_id') 9 | .having('COUNT(*) > 1') 10 | 11 | puts "Duplicate groups found: #{duplicate_groups.length}" 12 | 13 | duplicate_groups.each do |group| 14 | points = Point.where( 15 | latitude: group.latitude, 16 | longitude: group.longitude, 17 | timestamp: group.timestamp, 18 | user_id: group.user_id 19 | ).order(id: :asc) 20 | 21 | # Keep the latest record and destroy all others 22 | latest = points.last 23 | points.where.not(id: latest.id).destroy_all 24 | end 25 | end 26 | 27 | def down 28 | # This migration cannot be reversed 29 | raise ActiveRecord::IrreversibleMigration 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /db/data/20250123151849_create_paths_for_trips.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreatePathsForTrips < ActiveRecord::Migration[8.0] 4 | def up 5 | Trip.find_each do |trip| 6 | Trips::CalculatePathJob.perform_later(trip.id) 7 | end 8 | end 9 | 10 | def down 11 | raise ActiveRecord::IrreversibleMigration 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/data/20250222213848_migrate_points_latlon.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MigratePointsLatlon < ActiveRecord::Migration[8.0] 4 | def up 5 | User.find_each do |user| 6 | DataMigrations::MigratePointsLatlonJob.perform_later(user.id) 7 | end 8 | end 9 | 10 | def down 11 | raise ActiveRecord::IrreversibleMigration 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/data/20250226192005_activate_selfhosted_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActivateSelfhostedUsers < ActiveRecord::Migration[8.0] 4 | def up 5 | return unless DawarichSettings.self_hosted? 6 | 7 | User.update_all(status: :active) # rubocop:disable Rails/SkipsModelValidations 8 | end 9 | 10 | def down 11 | return unless DawarichSettings.self_hosted? 12 | 13 | User.update_all(status: :inactive) # rubocop:disable Rails/SkipsModelValidations 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/data/20250303194123_migrate_places_lonlat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MigratePlacesLonlat < ActiveRecord::Migration[8.0] 4 | def up 5 | User.find_each do |user| 6 | DataMigrations::MigratePlacesLonlatJob.perform_later(user.id) 7 | end 8 | end 9 | 10 | def down 11 | raise ActiveRecord::IrreversibleMigration 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/data/20250403204658_update_imports_points_count.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UpdateImportsPointsCount < ActiveRecord::Migration[8.0] 4 | def up 5 | Import.find_each do |import| 6 | Import::UpdatePointsCountJob.perform_later(import.id) 7 | end 8 | end 9 | 10 | def down 11 | raise ActiveRecord::IrreversibleMigration 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/data/20250404182629_set_active_until_for_selfhosted_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SetActiveUntilForSelfhostedUsers < ActiveRecord::Migration[8.0] 4 | def up 5 | return unless DawarichSettings.self_hosted? 6 | 7 | # rubocop:disable Rails/SkipsModelValidations 8 | User.where(active_until: nil).update_all(active_until: 1000.years.from_now) 9 | # rubocop:enable Rails/SkipsModelValidations 10 | end 11 | 12 | def down 13 | return unless DawarichSettings.self_hosted? 14 | 15 | # rubocop:disable Rails/SkipsModelValidations 16 | User.where.not(active_until: nil).update_all(active_until: nil) 17 | # rubocop:enable Rails/SkipsModelValidations 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/data/20250516180933_set_points_country_ids.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SetPointsCountryIds < ActiveRecord::Migration[8.0] 4 | def up 5 | DataMigrations::StartSettingsPointsCountryIdsJob.perform_later 6 | end 7 | 8 | def down 9 | raise ActiveRecord::IrreversibleMigration 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/data/20250518173936_fix_france_codes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FixFranceCodes < ActiveRecord::Migration[8.0] 4 | def up 5 | Country.find_by(name: 'France')&.update(iso_a2: 'FR', iso_a3: 'FRA') 6 | end 7 | 8 | def down 9 | raise ActiveRecord::IrreversibleMigration 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/data/20250518174305_set_default_distance_unit_for_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SetDefaultDistanceUnitForUser < ActiveRecord::Migration[8.0] 4 | def up 5 | User.find_each do |user| 6 | map_settings = user.settings['maps'] 7 | 8 | next if map_settings.try(:[], 'distance_unit')&.in?(%w[km mi]) 9 | 10 | if map_settings.blank? 11 | map_settings = { distance_unit: 'km' } 12 | else 13 | map_settings['distance_unit'] = 'km' 14 | end 15 | 16 | user.settings['maps'] = map_settings 17 | user.save! 18 | end 19 | end 20 | 21 | def down 22 | raise ActiveRecord::IrreversibleMigration 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /db/data_schema.rb: -------------------------------------------------------------------------------- 1 | DataMigrate::Data.define(version: 20250518174305) 2 | -------------------------------------------------------------------------------- /db/migrate/20231021104256_add_service_name_to_active_storage_blobs.active_storage.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from active_storage (originally 20190112182829) 2 | class AddServiceNameToActiveStorageBlobs < ActiveRecord::Migration[6.0] 3 | def up 4 | return unless table_exists?(:active_storage_blobs) 5 | 6 | unless column_exists?(:active_storage_blobs, :service_name) 7 | add_column :active_storage_blobs, :service_name, :string 8 | 9 | if configured_service = ActiveStorage::Blob.service.name 10 | ActiveStorage::Blob.unscoped.update_all(service_name: configured_service) 11 | end 12 | 13 | change_column :active_storage_blobs, :service_name, :string, null: false 14 | end 15 | end 16 | 17 | def down 18 | return unless table_exists?(:active_storage_blobs) 19 | 20 | remove_column :active_storage_blobs, :service_name 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20231021104258_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from active_storage (originally 20211119233751) 2 | class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0] 3 | def change 4 | return unless table_exists?(:active_storage_blobs) 5 | 6 | change_column_null(:active_storage_blobs, :checksum, true) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20240315215423_create_imports.rb: -------------------------------------------------------------------------------- 1 | class CreateImports < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :imports do |t| 4 | t.string :name, null: false 5 | t.bigint :user_id, null: false 6 | t.integer :source, default: 0 7 | 8 | t.timestamps 9 | end 10 | add_index :imports, :user_id 11 | add_index :imports, :source 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20240317171559_add_indicies_to_points_latitude_longitude.rb: -------------------------------------------------------------------------------- 1 | class AddIndiciesToPointsLatitudeLongitude < ActiveRecord::Migration[7.1] 2 | def change 3 | add_index :points, [:latitude, :longitude] 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240323125126_add_raw_points_and_doubles_to_import.rb: -------------------------------------------------------------------------------- 1 | class AddRawPointsAndDoublesToImport < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :imports, :raw_points, :integer, default: 0 4 | add_column :imports, :doubles, :integer, default: 0 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20240323160300_create_stats.rb: -------------------------------------------------------------------------------- 1 | class CreateStats < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :stats do |t| 4 | t.integer :year, null: false 5 | t.integer :month, null: false 6 | t.integer :distance, null: false 7 | t.jsonb :toponyms 8 | 9 | t.timestamps 10 | end 11 | add_index :stats, :year 12 | add_index :stats, :month 13 | add_index :stats, :distance 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20240323161049_add_index_to_points_timestamp.rb: -------------------------------------------------------------------------------- 1 | class AddIndexToPointsTimestamp < ActiveRecord::Migration[7.1] 2 | def change 3 | add_index :points, :timestamp 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240323190039_add_user_id_to_stat.rb: -------------------------------------------------------------------------------- 1 | class AddUserIdToStat < ActiveRecord::Migration[7.1] 2 | def change 3 | add_reference :stats, :user, null: false, foreign_key: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240324161800_add_processed_to_imports.rb: -------------------------------------------------------------------------------- 1 | class AddProcessedToImports < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :imports, :processed, :integer, default: 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240324173315_add_daily_distance_to_stat.rb: -------------------------------------------------------------------------------- 1 | class AddDailyDistanceToStat < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :stats, :daily_distance, :jsonb, default: {} 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240404154959_add_api_key_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddApiKeyToUsers < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :users, :api_key, :string, null: false, default: '' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240425200155_add_raw_data_to_imports.rb: -------------------------------------------------------------------------------- 1 | class AddRawDataToImports < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :imports, :raw_data, :jsonb 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240518095848_add_theme_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddThemeToUsers < ActiveRecord::Migration[7.1] 4 | def change 5 | add_column :users, :theme, :string, default: 'dark', null: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240525110244_add_user_id_to_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddUserIdToPoints < ActiveRecord::Migration[7.1] 4 | def change 5 | add_reference :points, :user, foreign_key: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240612152451_create_exports.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateExports < ActiveRecord::Migration[7.1] 4 | def change 5 | create_table :exports do |t| 6 | t.string :name, null: false 7 | t.string :url 8 | t.integer :status, default: 0, null: false 9 | t.bigint :user_id, null: false 10 | 11 | t.timestamps 12 | end 13 | add_index :exports, :status 14 | add_index :exports, :user_id 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20240620205120_add_settings_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddSettingsToUsers < ActiveRecord::Migration[7.1] 4 | def change 5 | add_column :users, :settings, :jsonb, default: { 6 | meters_between_routes: 500, 7 | minutes_between_routes: 60 8 | } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20240630093005_add_fog_of_war_to_default_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddFogOfWarToDefaultSettings < ActiveRecord::Migration[7.1] 4 | def change 5 | change_column_default :users, :settings, 6 | from: { meters_between_routes: '1000', minutes_between_routes: '60' }, 7 | to: { fog_of_war_meters: '100', meters_between_routes: '1000', minutes_between_routes: '60' } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20240703105734_create_notifications.rb: -------------------------------------------------------------------------------- 1 | class CreateNotifications < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :notifications do |t| 4 | t.string :title, null: false 5 | t.text :content, null: false 6 | t.references :user, null: false, foreign_key: true 7 | t.integer :kind, null: false, default: 0 8 | t.datetime :read_at 9 | 10 | t.timestamps 11 | end 12 | add_index :notifications, :kind 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20240712141303_add_geodata_to_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddGeodataToPoints < ActiveRecord::Migration[7.1] 4 | def change 5 | add_column :points, :geodata, :jsonb, null: false, default: {} 6 | add_index :points, :geodata, using: :gin 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20240713103051_add_admin_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddAdminToUsers < ActiveRecord::Migration[7.1] 4 | def change 5 | add_column :users, :admin, :boolean, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240721165313_create_areas.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateAreas < ActiveRecord::Migration[7.1] 4 | def change 5 | create_table :areas do |t| 6 | t.string :name, null: false 7 | t.references :user, null: false, foreign_key: true 8 | t.decimal :longitude, precision: 10, scale: 6, null: false 9 | t.decimal :latitude, precision: 10, scale: 6, null: false 10 | t.integer :radius, null: false 11 | 12 | t.timestamps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20240721183005_create_visits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateVisits < ActiveRecord::Migration[7.1] 4 | def change 5 | create_table :visits do |t| 6 | t.references :area, null: false, foreign_key: true 7 | t.references :user, null: false, foreign_key: true 8 | t.datetime :started_at, null: false 9 | t.datetime :ended_at, null: false 10 | t.integer :duration, null: false 11 | t.string :name, null: false 12 | t.integer :status, null: false, default: 0 13 | 14 | t.timestamps 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20240721183116_add_visit_id_to_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddVisitIdToPoints < ActiveRecord::Migration[7.1] 4 | def change 5 | add_reference :points, :visit, foreign_key: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240805150111_create_places.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreatePlaces < ActiveRecord::Migration[7.1] 4 | def change 5 | create_table :places do |t| 6 | t.string :name, null: false 7 | t.decimal :longitude, precision: 10, scale: 6, null: false 8 | t.decimal :latitude, precision: 10, scale: 6, null: false 9 | t.string :city 10 | t.string :country 11 | t.integer :source, default: 0 12 | t.jsonb :geodata, default: {}, null: false 13 | t.datetime :reverse_geocoded_at 14 | 15 | t.timestamps 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/20240808102348_add_place_id_to_visits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPlaceIdToVisits < ActiveRecord::Migration[7.1] 4 | def change 5 | add_reference :visits, :place, foreign_key: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240808102425_make_area_id_optional_in_visits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MakeAreaIdOptionalInVisits < ActiveRecord::Migration[7.1] 4 | def change 5 | change_column_null :visits, :area_id, true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240808121027_create_place_visits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreatePlaceVisits < ActiveRecord::Migration[7.1] 4 | def change 5 | create_table :place_visits do |t| 6 | t.references :place, null: false, foreign_key: true 7 | t.references :visit, null: false, foreign_key: true 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20240822092405_add_points_count_to_imports.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPointsCountToImports < ActiveRecord::Migration[7.1] 4 | def change 5 | add_column :imports, :points_count, :integer, default: 0 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20241127161621_create_trips.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTrips < ActiveRecord::Migration[7.2] 4 | def change 5 | create_table :trips do |t| 6 | t.string :name, null: false 7 | t.datetime :started_at, null: false 8 | t.datetime :ended_at, null: false 9 | t.integer :distance 10 | t.references :user, null: false, foreign_key: true 11 | 12 | t.timestamps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20241202114820_add_reverse_geocoded_at_to_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddReverseGeocodedAtToPoints < ActiveRecord::Migration[7.2] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | return if column_exists?(:points, :reverse_geocoded_at) 8 | 9 | add_column :points, :reverse_geocoded_at, :datetime 10 | add_index :points, :reverse_geocoded_at, algorithm: :concurrently 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20241205160055_add_devise_trackable_columns_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDeviseTrackableColumnsToUsers < ActiveRecord::Migration[7.2] 4 | def change 5 | change_table :users, bulk: true do |t| 6 | t.integer :sign_in_count, default: 0, null: false 7 | t.datetime :current_sign_in_at 8 | t.datetime :last_sign_in_at 9 | t.string :current_sign_in_ip 10 | t.string :last_sign_in_ip 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20241211113119_add_started_at_index_to_visits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddStartedAtIndexToVisits < ActiveRecord::Migration[7.2] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | add_index :visits, :started_at, algorithm: :concurrently 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20241226202204_add_database_users_constraints.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDatabaseUsersConstraints < ActiveRecord::Migration[8.0] 4 | def change 5 | add_check_constraint :users, 'email IS NOT NULL', name: 'users_email_null', validate: false 6 | add_check_constraint :users, 'admin IS NOT NULL', name: 'users_admin_null', validate: false 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20241226202831_validate_add_database_users_constraints.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ValidateAddDatabaseUsersConstraints < ActiveRecord::Migration[8.0] 4 | def up 5 | validate_check_constraint :users, name: 'users_email_null' 6 | change_column_null :users, :email, false 7 | remove_check_constraint :users, name: 'users_email_null' 8 | end 9 | 10 | def down 11 | add_check_constraint :users, 'email IS NOT NULL', name: 'users_email_null', validate: false 12 | change_column_null :users, :email, true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20250120152014_add_course_and_course_accuracy_to_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCourseAndCourseAccuracyToPoints < ActiveRecord::Migration[8.0] 4 | def change 5 | add_column :points, :course, :decimal, precision: 8, scale: 5 6 | add_column :points, :course_accuracy, :decimal, precision: 8, scale: 5 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20250120152540_add_external_track_id_to_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddExternalTrackIdToPoints < ActiveRecord::Migration[8.0] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | add_column :points, :external_track_id, :string 8 | 9 | add_index :points, :external_track_id, algorithm: :concurrently 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20250123145155_enable_postgis_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EnablePostgisExtension < ActiveRecord::Migration[8.0] 4 | def change 5 | enable_extension 'postgis' unless extension_enabled?('postgis') 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20250123151657_add_path_to_trips.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPathToTrips < ActiveRecord::Migration[8.0] 4 | def change 5 | add_column :trips, :path, :line_string, srid: 3857 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20250219195822_add_status_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddStatusToUsers < ActiveRecord::Migration[8.0] 4 | def change 5 | add_column :users, :status, :integer, default: 0 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20250221181805_add_lonlat_to_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddLonlatToPoints < ActiveRecord::Migration[8.0] 4 | def change 5 | add_column :points, :lonlat, :st_point, geographic: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20250221185032_add_lonlat_index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddLonlatIndex < ActiveRecord::Migration[8.0] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | add_index :points, :lonlat, using: :gist, algorithm: :concurrently 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20250221194430_remove_points_latitude_longitude_uniqueness_index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemovePointsLatitudeLongitudeUniquenessIndex < ActiveRecord::Migration[8.0] 4 | disable_ddl_transaction! 5 | 6 | def up 7 | return unless index_exists?( 8 | :points, %i[latitude longitude timestamp user_id], 9 | name: 'unique_points_lat_long_timestamp_user_id_index' 10 | ) 11 | 12 | remove_index :points, 13 | name: 'unique_points_lat_long_timestamp_user_id_index', 14 | algorithm: :concurrently 15 | end 16 | 17 | def down 18 | return if index_exists?( 19 | :points, %i[latitude longitude timestamp user_id], 20 | name: 'unique_points_lat_long_timestamp_user_id_index' 21 | ) 22 | 23 | add_index :points, %i[latitude longitude timestamp user_id], 24 | unique: true, 25 | name: 'unique_points_lat_long_timestamp_user_id_index', 26 | algorithm: :concurrently 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /db/migrate/20250221194509_add_unique_lon_lat_index_to_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddUniqueLonLatIndexToPoints < ActiveRecord::Migration[8.0] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | return if index_exists?(:points, %i[lonlat timestamp user_id], name: 'index_points_on_lonlat_timestamp_user_id') 8 | 9 | add_index :points, %i[lonlat timestamp user_id], unique: true, 10 | name: 'index_points_on_lonlat_timestamp_user_id', 11 | algorithm: :concurrently 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20250303194009_add_lonlat_to_places.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddLonlatToPlaces < ActiveRecord::Migration[8.0] 4 | def change 5 | add_column :places, :lonlat, :st_point, geographic: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20250303194043_add_lonlat_index_to_places.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddLonlatIndexToPlaces < ActiveRecord::Migration[8.0] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | add_index :places, :lonlat, using: :gist, algorithm: :concurrently 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20250324180755_add_format_start_at_end_at_to_exports.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddFormatStartAtEndAtToExports < ActiveRecord::Migration[8.0] 4 | def change 5 | add_column :exports, :file_format, :integer, default: 0 6 | add_column :exports, :start_at, :datetime 7 | add_column :exports, :end_at, :datetime 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20250404182437_add_active_until_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddActiveUntilToUsers < ActiveRecord::Migration[8.0] 4 | def change 5 | add_column :users, :active_until, :datetime 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20250513164521_add_visited_countries_to_trips.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddVisitedCountriesToTrips < ActiveRecord::Migration[8.0] 4 | def change 5 | safety_assured do 6 | execute <<-SQL 7 | ALTER TABLE trips ADD COLUMN visited_countries JSONB DEFAULT '{}'::jsonb NOT NULL; 8 | SQL 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20250515190752_create_countries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateCountries < ActiveRecord::Migration[8.0] 4 | def change 5 | create_table :countries do |t| 6 | t.string :name, null: false 7 | t.string :iso_a2, null: false 8 | t.string :iso_a3, null: false 9 | t.multi_polygon :geom, srid: 4326 10 | 11 | t.timestamps 12 | end 13 | 14 | add_index :countries, :name 15 | add_index :countries, :iso_a2 16 | add_index :countries, :iso_a3 17 | add_index :countries, :geom, using: :gist 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20250515192211_add_country_id_to_points.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCountryIdToPoints < ActiveRecord::Migration[8.0] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | add_reference :points, :country, index: { algorithm: :concurrently } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | /log 2 | /tmp 3 | 4 | # We need directories for import and export files, but not the files themselves. 5 | /public/exports/* 6 | !/public/exports/.keep 7 | /public/imports/* 8 | !/public/imports/.keep 9 | -------------------------------------------------------------------------------- /docs/How_to_install_Dawarich_using_Docker.md: -------------------------------------------------------------------------------- 1 | # How to install Dawarich using Docker 2 | 3 | > To do that you need previously install [Docker](https://docs.docker.com/get-docker/) on your system. 4 | 5 | To quick Dawarich install copy the contents of the `docker-compose.yml` file from project root folder to dedicated folder in your server and run `docker compose up` in this folder. 6 | 7 | This command use [docker-compose.yml](../docker-compose.yml) to build your local environment. 8 | 9 | When this command done successfully and all services in containers will start you can open Dawarich web UI by this link [http://127.0.0.1:3000](http://127.0.0.1:3000). 10 | 11 | Default credentials for first login in are `demo@dawarich.app` and `password`. 12 | -------------------------------------------------------------------------------- /docs/synology/.env: -------------------------------------------------------------------------------- 1 | ################################################################################### 2 | # Dawarich 3 | ################################################################################### 4 | 5 | RAILS_ENV=development 6 | MIN_MINUTES_SPENT_IN_CITY=60 7 | APPLICATION_HOSTS=dawarich.example.synology.me 8 | TIME_ZONE=Europe/Berlin 9 | BACKGROUND_PROCESSING_CONCURRENCY=10 10 | STORE_GEODATA=false 11 | 12 | ################################################################################### 13 | # Database 14 | ################################################################################### 15 | 16 | DATABASE_HOST=dawarich_db 17 | DATABASE_USERNAME=postgres 18 | DATABASE_PASSWORD=password 19 | DATABASE_NAME=dawarich 20 | 21 | ################################################################################### 22 | # Redis 23 | ################################################################################### 24 | 25 | REDIS_URL=redis://dawarich_redis:6379/0 26 | -------------------------------------------------------------------------------- /docs/synology/spk.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/docs/synology/spk.tgz -------------------------------------------------------------------------------- /docs/synology/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | author="your name" 4 | URL="https://your.dawarich.domain.com" 5 | 6 | 7 | if [[ -d spk ]] 8 | then 9 | rm -rf spk 10 | fi 11 | 12 | if [[ -f Dawarich.spk ]] 13 | then 14 | rm -rf Dawarich.spk 15 | fi 16 | 17 | tar -xf spk.tgz 18 | 19 | if [[ -f package.tgz ]] 20 | then 21 | rm -f package.tgz 22 | fi 23 | 24 | sed -i "s/maintainer=\"\"/maintainer=\"${author}\"/" spk/INFO 25 | sed -i "s/distributor=\"\"/distributor=\"${author}\"/" spk/INFO 26 | sed -i "s|https://dawarich.my-syno.com|${URL}|" spk/package/ui/config 27 | 28 | cd spk/package 29 | 30 | tar -czf ../package.tgz * 31 | 32 | cd .. 33 | 34 | sum=$(md5sum package.tgz | cut -f1 -d" ") 35 | 36 | sed -i "s/checksum=\"\"/checksum=\"${sum}\"/" INFO 37 | 38 | tar -cf ../Dawarich.spk package.tgz conf scripts INFO PACKAGE_ICON*.PNG 39 | 40 | cd .. 41 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/lib/assets/.keep -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/exports.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :exports do 4 | desc 'Migrate existing exports from file system to the new file storage' 5 | 6 | task migrate_to_new_storage: :environment do 7 | Export.find_each do |export| 8 | export.migrate_to_new_storage 9 | rescue StandardError => e 10 | puts "Error migrating export #{export.id}: #{e.message}" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tasks/import.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :import do 4 | # Usage: rake import:big_file['/path/to/file.json','user@email.com'] 5 | desc 'Accepts a file path and user email and imports the data into the database' 6 | 7 | task :big_file, %i[file_path user_email] => :environment do |_, args| 8 | Tasks::Imports::GoogleRecords.new(args[:file_path], args[:user_email]).call 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/tasks/imports.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :imports do 4 | desc 'Migrate existing imports from `raw_data` to the new file storage' 5 | 6 | task migrate_to_new_storage: :environment do 7 | Import.find_each do |import| 8 | import.migrate_to_new_storage 9 | rescue StandardError => e 10 | puts "Error migrating import #{import.id}: #{e.message}" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tasks/rswag.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :rswag do 4 | desc 'Generate Swagger docs' 5 | task generate: [:environment] do 6 | system 'bundle exec rake rswag:specs:swaggerize PATTERN="spec/swagger/**/*_spec.rb"' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/tasks/users.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :users do 4 | desc 'Activate all users' 5 | task activate: :environment do 6 | unless DawarichSettings.self_hosted? 7 | puts 'This task is only available for self-hosted users' 8 | exit 1 9 | end 10 | 11 | puts 'Activating all users...' 12 | # rubocop:disable Rails/SkipsModelValidations 13 | User.update_all(status: :active) 14 | # rubocop:enable Rails/SkipsModelValidations 15 | 16 | puts 'All users have been activated' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/timestamps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Timestamps 4 | 5 | def self.parse_timestamp(timestamp) 6 | begin 7 | # if the timestamp is in ISO 8601 format, try to parse it 8 | DateTime.parse(timestamp).to_time.to_i 9 | rescue 10 | if timestamp.to_s.length > 10 11 | # If the timestamp is in milliseconds, convert to seconds 12 | timestamp.to_i / 1000 13 | else 14 | # If the timestamp is in seconds, return it without change 15 | timestamp.to_i 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/log/.keep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@hotwired/turbo-rails": "^7.3.0", 4 | "@rails/actiontext": "^8.0.0", 5 | "daisyui": "^4.7.3", 6 | "leaflet": "^1.9.4", 7 | "postcss": "^8.4.49", 8 | "trix": "^2.1.15" 9 | }, 10 | "engines": { 11 | "node": "18.17.1", 12 | "npm": "9.6.7" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /public/.well-known/apple-app-site-association: -------------------------------------------------------------------------------- 1 | { 2 | "webcredentials": { 3 | "apps": [ 4 | "2A275P77DQ.app.dawarich.Dawarich" 5 | ] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/exports/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/public/exports/.keep -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/public/icon.png -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /screenshots/imports.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/screenshots/imports.jpeg -------------------------------------------------------------------------------- /screenshots/map.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/screenshots/map.jpeg -------------------------------------------------------------------------------- /screenshots/stats.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/screenshots/stats.jpeg -------------------------------------------------------------------------------- /spec/channels/imports_channel_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe ImportsChannel, type: :channel do 6 | let(:user) { create(:user) } 7 | 8 | before do 9 | stub_connection(current_user: user) 10 | end 11 | 12 | it 'subscribes to a stream for the current user' do 13 | subscribe 14 | 15 | expect(subscription).to be_confirmed 16 | expect(subscription).to have_stream_for(user) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/channels/notifications_channel_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe NotificationsChannel, type: :channel do 6 | let(:user) { create(:user) } 7 | 8 | before do 9 | stub_connection(current_user: user) 10 | end 11 | 12 | it 'subscribes to a stream for the current user' do 13 | subscribe 14 | 15 | expect(subscription).to be_confirmed 16 | expect(subscription).to have_stream_for(user) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/channels/points_channel_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe PointsChannel, type: :channel do 6 | let(:user) { create(:user) } 7 | 8 | before do 9 | stub_connection(current_user: user) 10 | end 11 | 12 | it 'subscribes to a stream for the current user' do 13 | subscribe 14 | 15 | expect(subscription).to be_confirmed 16 | expect(subscription).to have_stream_for(user) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/factories/areas.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :area do 5 | name { 'Adlershof' } 6 | user 7 | latitude { 52.437 } 8 | longitude { 13.539 } 9 | radius { 100 } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/factories/countries.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :country do 3 | name { "Serranilla Bank" } 4 | iso_a2 { "SB" } 5 | iso_a3 { "SBX" } 6 | geom { 7 | "MULTIPOLYGON (((-78.637074 15.862087, -78.640411 15.864, -78.636871 15.867296, -78.637074 15.862087)))" 8 | } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/factories/exports.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :export do 5 | name { 'export' } 6 | status { :created } 7 | file_format { :json } 8 | user 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/factories/imports.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :import do 5 | user 6 | name { 'owntracks_export.json' } 7 | source { Import.sources[:owntracks] } 8 | 9 | trait :with_points do 10 | after(:create) do |import| 11 | create_list(:point, 10, import:) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/factories/notifications.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :notification do 5 | title { "MyString" } 6 | content { "MyText" } 7 | user 8 | kind { :info } 9 | read_at { nil } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/factories/place_visits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :place_visit do 5 | place 6 | visit 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/factories/stats.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :stat do 5 | year { 1 } 6 | month { 1 } 7 | distance { 1 } 8 | user 9 | toponyms do 10 | [ 11 | { 12 | 'cities' => [ 13 | { 'city' => 'Moscow', 'points' => 7, 'timestamp' => 1_554_317_696, 'stayed_for' => 1831 } 14 | ], 15 | 'country' => 'Russia' 16 | }, { 'cities' => [], 'country' => nil } 17 | ] 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/factories/trips.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :trip do 5 | user 6 | name { FFaker::Lorem.word } 7 | started_at { DateTime.new(2024, 11, 27, 17, 16, 21) } 8 | ended_at { DateTime.new(2024, 11, 29, 17, 16, 21) } 9 | notes { FFaker::Lorem.sentence } 10 | distance { 100 } 11 | path { 'LINESTRING(1 1, 2 2, 3 3)' } 12 | 13 | trait :with_points do 14 | after(:build) do |trip| 15 | (1..25).map do |i| 16 | create( 17 | :point, 18 | :with_geodata, 19 | :reverse_geocoded, 20 | timestamp: trip.started_at + i.minutes, 21 | user: trip.user 22 | ) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/factories/visits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :visit do 5 | area 6 | user 7 | started_at { Time.zone.now } 8 | ended_at { Time.zone.now + 1.hour } 9 | duration { 1.hour } 10 | name { 'Visit' } 11 | status { 'suggested' } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/fixtures/files/geojson/gpslogger_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": [ 3 | { 4 | "geometry": { 5 | "coordinates": [ 6 | 106.64234449272531, 7 | 10.758321212464024 8 | ], 9 | "type": "Point" 10 | }, 11 | "properties": { 12 | "accuracy": 4.7551565, 13 | "altitude": 17.634344400269068, 14 | "provider": "gps", 15 | "speed": 1.2, 16 | "time": "2024-11-03T16:30:11.331+07:00", 17 | "time_long": 1730626211331 18 | }, 19 | "type": "Feature" 20 | } 21 | ], 22 | "type": "FeatureCollection" 23 | } 24 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_activitySegment_with_startLocation.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "activitySegment": { 5 | "startLocation": { "latitudeE7": 123422222, "longitudeE7": 123422222 }, 6 | "duration": { "startTimestamp": "2025-03-24 20:07:24 +0100" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_activitySegment_with_startLocation_timestampMs.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "activitySegment": { 5 | "startLocation": { "latitudeE7": 123466666, "longitudeE7": 123466666 }, 6 | "duration": { "startTimestampMs": "1742844302585" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_activitySegment_with_startLocation_timestamp_in_milliseconds_format.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "activitySegment": { 5 | "startLocation": { "latitudeE7": 123455555, "longitudeE7": 123455555 }, 6 | "duration": { "startTimestamp": "1742844232" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_activitySegment_with_startLocation_timestamp_in_seconds_format.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "activitySegment": { 5 | "startLocation": { "latitudeE7": 123444444, "longitudeE7": 123444444 }, 6 | "duration": { "startTimestamp": "1742844302585" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_activitySegment_with_startLocation_with_iso_timestamp.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "activitySegment": { 5 | "startLocation": { "latitudeE7": 123433333, "longitudeE7": 123433333 }, 6 | "duration": { "startTimestamp": "2025-03-24T20:20:23+01:00" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_activitySegment_without_startLocation.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "activitySegment": { 5 | "waypointPath": { 6 | "waypoints": [ 7 | { "latE7": 123411111, "lngE7": 123411111 } 8 | ] 9 | }, 10 | "duration": { "startTimestamp": "2025-03-24 20:07:24 +0100" } 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_activitySegment_without_startLocation_without_waypointPath.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "activitySegment": { 5 | "duration": { "startTimestamp": "2025-03-24 20:07:24 +0100" } 6 | } 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_placeVisit_with_location_with_coordinates.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "placeVisit": { 5 | "location": { "latitudeE7": 123477777, "longitudeE7": 123477777 }, 6 | "duration": { "startTimestamp": "1742844232" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_placeVisit_with_location_with_coordinates_with_iso_timestamp.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "placeVisit": { 5 | "location": { "latitudeE7": 123488888, "longitudeE7": 123488888 }, 6 | "duration": { "startTimestamp": "2025-03-24T20:25:02+01:00" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_placeVisit_with_location_with_coordinates_with_milliseconds_timestamp.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "placeVisit": { 5 | "location": { "latitudeE7": 123511111, "longitudeE7": 123511111 }, 6 | "duration": { "startTimestamp": "1742844302585" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_placeVisit_with_location_with_coordinates_with_seconds_timestamp.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "placeVisit": { 5 | "location": { "latitudeE7": 123499999, "longitudeE7": 123499999 }, 6 | "duration": { "startTimestamp": "1742844302" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_placeVisit_with_location_with_coordinates_with_timestampMs.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "placeVisit": { 5 | "location": { "latitudeE7": 123522222, "longitudeE7": 123522222 }, 6 | "duration": { "startTimestampMs": "1742844302585" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_placeVisit_without_location_with_coordinates.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "placeVisit": { 5 | "location": {}, 6 | "duration": { "startTimestamp": "2025-03-24 20:25:02 +0100" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/location-history/with_placeVisit_without_location_with_coordinates_with_otherCandidateLocations.json: -------------------------------------------------------------------------------- 1 | { 2 | "timelineObjects": [ 3 | { 4 | "placeVisit": { 5 | "otherCandidateLocations": [{ "latitudeE7": 123533333, "longitudeE7": 123533333 }], 6 | "duration": { "startTimestamp": "2025-03-24 20:25:02 +0100" } 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/files/google/records.json: -------------------------------------------------------------------------------- 1 | { 2 | "locations": [{ 3 | "latitudeE7": 533690550, 4 | "longitudeE7": 836950010, 5 | "accuracy": 150, 6 | "source": "UNKNOWN", 7 | "timestamp": "2012-12-15T14:21:29.460Z" 8 | }, { 9 | "latitudeE7": 533563380, 10 | "longitudeE7": 837616500, 11 | "accuracy": 18000, 12 | "source": "UNKNOWN", 13 | "timestamp": "2013-01-04T10:22:43.225Z" 14 | }, { 15 | "latitudeE7": 533690589, 16 | "longitudeE7": 836951347, 17 | "accuracy": 22, 18 | "source": "WIFI", 19 | "deviceTag": 1184882232, 20 | "timestamp": "2013-03-01T05:17:39.849Z" 21 | }] 22 | } 23 | -------------------------------------------------------------------------------- /spec/fixtures/files/immich/geodata.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "latitude": 59.0000, 4 | "longitude": 30.0000, 5 | "timestamp": 978296400 6 | }, 7 | { 8 | "latitude": 55.0001, 9 | "longitude": 37.0001, 10 | "timestamp": 978296400 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /spec/fixtures/files/immich/response.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | { 4 | "assets": [ 5 | { 6 | "exifInfo": { 7 | "dateTimeOriginal": "2022-12-31T23:17:06.170Z", 8 | "latitude": 52.0000, 9 | "longitude": 13.0000 10 | } 11 | }, 12 | { 13 | "exifInfo": { 14 | "dateTimeOriginal": "2022-12-31T23:21:53.140Z", 15 | "latitude": 52.0000, 16 | "longitude": 13.0000 17 | } 18 | } 19 | ], 20 | "title": "1 year ago", 21 | "yearsAgo": 1 22 | } 23 | ] 24 | ] 25 | -------------------------------------------------------------------------------- /spec/jobs/app_version_checking_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe AppVersionCheckingJob, type: :job do 6 | describe '#perform' do 7 | let(:job) { described_class.new } 8 | 9 | it 'calls CheckAppVersion service' do 10 | expect(CheckAppVersion).to receive(:new).and_return(instance_double(CheckAppVersion, call: true)) 11 | 12 | job.perform 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/jobs/area_visits_calculating_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe AreaVisitsCalculatingJob, type: :job do 6 | describe '#perform' do 7 | let(:user) { create(:user) } 8 | let(:area) { create(:area, user:) } 9 | 10 | it 'calls the AreaVisitsCalculationService' do 11 | expect(Areas::Visits::Create).to receive(:new).with(user, [area]).and_call_original 12 | 13 | described_class.new.perform(user.id) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/jobs/area_visits_calculation_scheduling_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do 6 | describe '#perform' do 7 | let(:area) { create(:area) } 8 | let(:user) { create(:user) } 9 | 10 | it 'calls the AreaVisitsCalculationService' do 11 | expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original 12 | 13 | described_class.new.perform 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/jobs/bulk_stats_calculating_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe BulkStatsCalculatingJob, type: :job do 6 | describe '#perform' do 7 | let(:user1) { create(:user) } 8 | let(:user2) { create(:user) } 9 | 10 | let(:timestamp) { DateTime.new(2024, 1, 1).to_i } 11 | 12 | let!(:points1) do 13 | (1..10).map do |i| 14 | create(:point, user_id: user1.id, timestamp: timestamp + i.minutes) 15 | end 16 | end 17 | 18 | let!(:points2) do 19 | (1..10).map do |i| 20 | create(:point, user_id: user2.id, timestamp: timestamp + i.minutes) 21 | end 22 | end 23 | 24 | it 'enqueues Stats::CalculatingJob for each user' do 25 | expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1) 26 | expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id, 2024, 1) 27 | 28 | BulkStatsCalculatingJob.perform_now 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/jobs/data_migrations/migrate_points_latlon_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe DataMigrations::MigratePointsLatlonJob, type: :job do 6 | describe '#perform' do 7 | it 'updates the lonlat column for all tracked points' do 8 | user = create(:user) 9 | point = create(:point, latitude: 2.0, longitude: 1.0, user: user) 10 | 11 | expect { subject.perform(user.id) }.to change { 12 | point.reload.lonlat 13 | }.to(RGeo::Geographic.spherical_factory.point(1.0, 2.0)) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/jobs/data_migrations/set_points_country_ids_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe DataMigrations::SetPointsCountryIdsJob, type: :job do 6 | describe '#perform' do 7 | let(:point) { create(:point, lonlat: 'POINT(10.0 20.0)', country_id: nil) } 8 | let(:country) { create(:country) } 9 | 10 | before do 11 | allow(Country).to receive(:containing_point) 12 | .with(point.lon, point.lat) 13 | .and_return(country) 14 | end 15 | 16 | it 'updates the point with the correct country_id' do 17 | described_class.perform_now(point.id) 18 | 19 | expect(point.reload.country_id).to eq(country.id) 20 | end 21 | end 22 | 23 | describe 'queue' do 24 | it 'uses the default queue' do 25 | expect(described_class.queue_name).to eq('default') 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/jobs/enqueue_background_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe EnqueueBackgroundJob, type: :job do 6 | let(:job_name) { 'start_reverse_geocoding' } 7 | let(:user_id) { 1 } 8 | 9 | it 'calls job creation service' do 10 | expect(Jobs::Create).to receive(:new).with(job_name, user_id).and_return(double(call: nil)) 11 | 12 | EnqueueBackgroundJob.perform_now(job_name, user_id) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/jobs/export_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe ExportJob, type: :job do 6 | let(:export) { create(:export) } 7 | let(:start_at) { 1.day.ago } 8 | let(:end_at) { Time.zone.now } 9 | 10 | it 'calls the Exports::Create service class' do 11 | expect(Exports::Create).to receive(:new).with(export:).and_call_original 12 | 13 | described_class.perform_now(export.id) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/jobs/import/immich_geodata_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Import::ImmichGeodataJob, type: :job do 6 | describe '#perform' do 7 | let(:user) { create(:user) } 8 | 9 | it 'calls Immich::ImportGeodata' do 10 | expect_any_instance_of(Immich::ImportGeodata).to receive(:call) 11 | 12 | described_class.perform_now(user.id) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/jobs/import/watcher_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Import::WatcherJob, type: :job do 6 | describe '#perform' do 7 | context 'when Dawarich is not self-hosted' do 8 | before do 9 | allow(DawarichSettings).to receive(:self_hosted?).and_return(false) 10 | end 11 | 12 | it 'does not call Imports::Watcher' do 13 | expect_any_instance_of(Imports::Watcher).not_to receive(:call) 14 | 15 | described_class.perform_now 16 | end 17 | end 18 | 19 | context 'when Dawarich is self-hosted' do 20 | before do 21 | allow(DawarichSettings).to receive(:self_hosted?).and_return(true) 22 | end 23 | 24 | it 'calls Imports::Watcher' do 25 | expect_any_instance_of(Imports::Watcher).to receive(:call) 26 | 27 | described_class.perform_now 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/jobs/overland/batch_creating_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Overland::BatchCreatingJob, type: :job do 6 | describe '#perform' do 7 | subject(:perform) { described_class.new.perform(json, user.id) } 8 | 9 | let(:file_path) { 'spec/fixtures/files/overland/geodata.json' } 10 | let(:file) { File.open(file_path) } 11 | let(:json) { JSON.parse(file.read) } 12 | let(:user) { create(:user) } 13 | 14 | it 'creates a location' do 15 | expect { perform }.to change { Point.count }.by(1) 16 | end 17 | 18 | it 'creates a point with the correct user_id' do 19 | perform 20 | 21 | expect(Point.last.user_id).to eq(user.id) 22 | end 23 | 24 | context 'when point already exists' do 25 | it 'does not create a point' do 26 | perform 27 | 28 | expect { perform }.not_to(change { Point.count }) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/jobs/owntracks/point_creating_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Owntracks::PointCreatingJob, type: :job do 6 | describe '#perform' do 7 | subject(:perform) { described_class.new.perform(point_params, user.id) } 8 | 9 | let(:point_params) do 10 | { lat: 1.0, lon: 1.0, tid: 'test', tst: Time.now.to_i, topic: 'iPhone 12 pro' } 11 | end 12 | let(:user) { create(:user) } 13 | 14 | it 'creates a point' do 15 | expect { perform }.to change { Point.count }.by(1) 16 | end 17 | 18 | it 'creates a point with the correct user_id' do 19 | perform 20 | 21 | expect(Point.last.user_id).to eq(user.id) 22 | end 23 | 24 | context 'when point already exists' do 25 | it 'does not create a point' do 26 | perform 27 | 28 | expect { perform }.not_to(change { Point.count }) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/jobs/points/create_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Points::CreateJob, type: :job do 6 | describe '#perform' do 7 | subject(:perform) { described_class.new.perform(json, user.id) } 8 | 9 | let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } 10 | let(:file) { File.open(file_path) } 11 | let(:json) { JSON.parse(file.read) } 12 | let(:user) { create(:user) } 13 | 14 | it 'creates a point' do 15 | expect { perform }.to change { Point.count }.by(6) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/models/area_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Area, type: :model do 6 | describe 'associations' do 7 | it { is_expected.to belong_to(:user) } 8 | it { is_expected.to have_many(:visits).dependent(:destroy) } 9 | end 10 | 11 | describe 'validations' do 12 | it { is_expected.to validate_presence_of(:name) } 13 | it { is_expected.to validate_presence_of(:latitude) } 14 | it { is_expected.to validate_presence_of(:longitude) } 15 | it { is_expected.to validate_presence_of(:radius) } 16 | end 17 | 18 | describe 'factory' do 19 | it { expect(build(:area)).to be_valid } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/models/country_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Country, type: :model do 6 | describe 'validations' do 7 | it { is_expected.to validate_presence_of(:name) } 8 | it { is_expected.to validate_presence_of(:iso_a2) } 9 | it { is_expected.to validate_presence_of(:iso_a3) } 10 | it { is_expected.to validate_presence_of(:geom) } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/export_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Export, type: :model do 6 | describe 'associations' do 7 | it { is_expected.to belong_to(:user) } 8 | end 9 | 10 | describe 'enums' do 11 | it { is_expected.to define_enum_for(:status).with_values(created: 0, processing: 1, completed: 2, failed: 3) } 12 | it { is_expected.to define_enum_for(:file_format).with_values(json: 0, gpx: 1) } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/models/notification_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Notification, type: :model do 6 | describe 'validations' do 7 | it { is_expected.to validate_presence_of(:title) } 8 | it { is_expected.to validate_presence_of(:content) } 9 | it { is_expected.to validate_presence_of(:kind) } 10 | end 11 | 12 | describe 'associations' do 13 | it { is_expected.to belong_to(:user) } 14 | end 15 | 16 | describe 'enums' do 17 | it { is_expected.to define_enum_for(:kind).with_values(info: 0, warning: 1, error: 2) } 18 | end 19 | 20 | describe 'scopes' do 21 | describe '.unread' do 22 | let(:read_notification) { create(:notification, read_at: Time.current) } 23 | let(:unread_notification) { create(:notification, read_at: nil) } 24 | 25 | it 'returns only unread notifications' do 26 | expect(described_class.unread).to eq([unread_notification]) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/models/place_visit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe PlaceVisit, type: :model do 6 | describe 'associations' do 7 | it { is_expected.to belong_to(:place) } 8 | it { is_expected.to belong_to(:visit) } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/models/visit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Visit, type: :model do 6 | describe 'associations' do 7 | it { is_expected.to belong_to(:area).optional } 8 | it { is_expected.to belong_to(:place).optional } 9 | it { is_expected.to belong_to(:user) } 10 | it { is_expected.to have_many(:points).dependent(:nullify) } 11 | end 12 | 13 | describe 'factory' do 14 | it { expect(build(:visit)).to be_valid } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/requests/api/v1/countries/borders_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Api::V1::Countries::Borders', type: :request do 6 | describe 'GET /index' do 7 | it 'returns a list of countries with borders' do 8 | get '/api/v1/countries/borders' 9 | 10 | expect(response).to have_http_status(:success) 11 | expect(response.body).to include('AF') 12 | expect(response.body).to include('ZW') 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/requests/api/v1/countries/visited_cities_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do 6 | describe 'GET /index' do 7 | let(:user) { create(:user) } 8 | let(:start_at) { '2023-01-01' } 9 | let(:end_at) { '2023-12-31' } 10 | 11 | it 'returns visited cities' do 12 | get "/api/v1/countries/visited_cities?api_key=#{user.api_key}&start_at=#{start_at}&end_at=#{end_at}" 13 | 14 | expect(response).to have_http_status(:ok) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/requests/api/v1/points/tracked_months_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Api::V1::Points::TrackedMonths', type: :request do 6 | describe 'GET /index' do 7 | let(:user) { create(:user) } 8 | 9 | it 'returns tracked months' do 10 | get "/api/v1/points/tracked_months?api_key=#{user.api_key}" 11 | 12 | expect(response).to have_http_status(:ok) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/requests/api/v1/users_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Api::V1::Users', type: :request do 6 | describe 'GET /me' do 7 | let(:user) { create(:user) } 8 | let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } } 9 | 10 | it 'returns http success' do 11 | get '/api/v1/users/me', headers: headers 12 | 13 | expect(response).to have_http_status(:success) 14 | expect(response.body).to include(user.email) 15 | expect(response.body).to include(user.id.to_s) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/requests/home_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Homes', type: :request do 6 | describe 'GET /' do 7 | before do 8 | stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') 9 | .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) 10 | end 11 | 12 | it 'returns http success' do 13 | get '/' 14 | 15 | expect(response).to have_http_status(:success) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/requests/map_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Map', type: :request do 6 | before do 7 | stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') 8 | .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) 9 | end 10 | 11 | describe 'GET /index' do 12 | context 'when user signed in' do 13 | let(:user) { create(:user) } 14 | let(:points) do 15 | (1..10).map do |i| 16 | create(:point, user:, timestamp: 1.day.ago + i.minutes) 17 | end 18 | end 19 | 20 | before { sign_in user } 21 | 22 | it 'returns http success' do 23 | get map_path 24 | 25 | expect(response).to have_http_status(:success) 26 | end 27 | end 28 | 29 | context 'when user not signed in' do 30 | it 'returns redirects to sign in page' do 31 | get map_path 32 | 33 | expect(response).to have_http_status(302) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/requests/places_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe '/places', type: :request do 6 | let(:user) { create(:user) } 7 | 8 | before do 9 | stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') 10 | .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) 11 | 12 | sign_in user 13 | end 14 | 15 | describe 'GET /index' do 16 | it 'renders a successful response' do 17 | get places_url 18 | 19 | expect(response).to be_successful 20 | end 21 | end 22 | 23 | describe 'DELETE /destroy' do 24 | let!(:place) { create(:place) } 25 | let!(:visit) { create(:visit, place:, user:) } 26 | 27 | it 'destroys the requested place' do 28 | expect do 29 | delete place_url(place) 30 | end.to change(Place, :count).by(-1) 31 | end 32 | 33 | it 'redirects to the places list' do 34 | delete place_url(place) 35 | 36 | expect(response).to redirect_to(places_url) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/requests/users_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Users', type: :request do 6 | before do 7 | stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') 8 | .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) 9 | end 10 | 11 | describe 'GET /users/sign_up' do 12 | context 'when self-hosted' do 13 | before do 14 | stub_const('SELF_HOSTED', true) 15 | end 16 | 17 | it 'returns http success' do 18 | get '/users/sign_up' 19 | expect(response).to have_http_status(:not_found) 20 | end 21 | end 22 | 23 | context 'when not self-hosted' do 24 | before do 25 | stub_const('SELF_HOSTED', false) 26 | Rails.application.reload_routes! 27 | end 28 | 29 | it 'returns http success' do 30 | get '/users/sign_up' 31 | expect(response).to have_http_status(:success) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/serializers/api/point_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Api::PointSerializer do 6 | describe '#call' do 7 | subject(:serializer) { described_class.new(point).call } 8 | 9 | let(:point) { create(:point) } 10 | let(:expected_json) { point.attributes.except(*Api::PointSerializer::EXCLUDED_ATTRIBUTES) } 11 | 12 | it 'returns JSON with correct attributes' do 13 | expect(serializer.to_json).to eq(expected_json.to_json) 14 | end 15 | 16 | it 'does not include excluded attributes' do 17 | expect(serializer).not_to include(*Api::PointSerializer::EXCLUDED_ATTRIBUTES) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/serializers/api/slim_point_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Api::SlimPointSerializer do 6 | describe '#call' do 7 | subject(:serializer) { described_class.new(point).call } 8 | 9 | let!(:point) { create(:point, :with_known_location) } 10 | let(:expected_json) do 11 | { 12 | id: point.id, 13 | latitude: point.lat.to_s, 14 | longitude: point.lon.to_s, 15 | timestamp: point.timestamp 16 | } 17 | end 18 | 19 | it 'returns JSON with correct attributes' do 20 | expect(serializer.to_json).to eq(expected_json.to_json) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/serializers/points/geojson_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Points::GeojsonSerializer do 6 | describe '#call' do 7 | subject(:serializer) { described_class.new(points).call } 8 | 9 | let(:points) do 10 | (1..3).map do |i| 11 | create(:point, timestamp: 1.day.ago + i.minutes) 12 | end 13 | end 14 | 15 | let(:expected_json) do 16 | { 17 | type: 'FeatureCollection', 18 | features: points.map do |point| 19 | { 20 | type: 'Feature', 21 | geometry: { 22 | type: 'Point', 23 | coordinates: [point.lon, point.lat] 24 | }, 25 | properties: PointSerializer.new(point).call 26 | } 27 | end 28 | } 29 | end 30 | 31 | it 'returns JSON' do 32 | expect(serializer).to eq(expected_json.to_json) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/serializers/points/gpx_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Points::GpxSerializer do 6 | describe '#call' do 7 | subject(:serializer) { described_class.new(points, 'some_name').call } 8 | 9 | let(:points) do 10 | (1..3).map do |i| 11 | create(:point, timestamp: 1.day.ago + i.minutes) 12 | end 13 | end 14 | 15 | it 'returns GPX file' do 16 | expect(serializer).to be_a(GPX::GPXFile) 17 | end 18 | 19 | it 'includes waypoints' do 20 | expect(serializer.tracks[0].points.size).to eq(3) 21 | end 22 | 23 | it 'includes waypoints with correct attributes' do 24 | serializer.tracks[0].points.each_with_index do |track_point, index| 25 | point = points[index] 26 | 27 | expect(track_point.lat.to_s).to eq(point.lat.to_s) 28 | expect(track_point.lon.to_s).to eq(point.lon.to_s) 29 | expect(track_point.time).to eq(point.recorded_at) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/services/geojson/importer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Geojson::Importer do 6 | describe '#call' do 7 | subject(:service) { described_class.new(import, user.id).call } 8 | 9 | let(:user) { create(:user) } 10 | 11 | let(:user) { create(:user) } 12 | 13 | context 'when file content is an object' do 14 | let(:file_path) { Rails.root.join('spec/fixtures/files/geojson/export.json') } 15 | let(:file) { Rack::Test::UploadedFile.new(file_path, 'application/json') } 16 | let(:import) { create(:import, user:, name: 'geojson.json', file:) } 17 | 18 | before do 19 | import.file.attach(io: File.open(file_path), filename: 'geojson.json', content_type: 'application/json') 20 | end 21 | 22 | it 'creates new points' do 23 | expect { service }.to change { Point.count }.by(10) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/services/imports/destroy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Imports::Destroy do 6 | describe '#call' do 7 | let!(:user) { create(:user) } 8 | let!(:import) { create(:import, :with_points, user: user) } 9 | let(:service) { described_class.new(user, import) } 10 | 11 | it 'destroys the import' do 12 | expect { service.call }.to change { Import.count }.by(-1) 13 | end 14 | 15 | it 'destroys the points' do 16 | expect { service.call }.to change { Point.count }.by(-import.points.count) 17 | end 18 | 19 | it 'enqueues a BulkStatsCalculatingJob' do 20 | expect(Stats::BulkCalculator).to receive(:new).with(user.id).and_return(double(call: nil)) 21 | 22 | service.call 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/services/notifications/create_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Notifications::Create do 6 | describe '#call' do 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/services/photoprism/cache_preview_token_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Photoprism::CachePreviewToken, type: :service do 6 | let(:user) { double('User', id: 1) } 7 | let(:preview_token) { 'sample_token' } 8 | let(:service) { described_class.new(user, preview_token) } 9 | 10 | describe '#call' do 11 | it 'writes the preview token to the cache with the correct key' do 12 | expect(Rails.cache).to receive(:write).with( 13 | "dawarich/photoprism_preview_token_#{user.id}", preview_token 14 | ) 15 | 16 | service.call 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/services/reverse_geocoding/places/fetch_data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe ReverseGeocoding::Places::FetchData do 6 | describe '#call' do 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/services/tasks/imports/google_records_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Tasks::Imports::GoogleRecords do 6 | describe '#call' do 7 | let(:user) { create(:user) } 8 | let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json').to_s } 9 | 10 | it 'schedules the Import::GoogleTakeoutJob' do 11 | expect(Import::GoogleTakeoutJob).to receive(:perform_later).exactly(1).time 12 | 13 | described_class.new(file_path, user.email).call 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/devise.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # https://makandracards.com/makandra/37161-rspec-devise-how-to-sign-in-users-in-request-specs 4 | 5 | module DeviseRequestSpecHelpers 6 | include Warden::Test::Helpers 7 | 8 | def sign_in(resource_or_scope, resource = nil) 9 | resource ||= resource_or_scope 10 | scope = Devise::Mapping.find_scope!(resource_or_scope) 11 | login_as(resource, scope:) 12 | end 13 | 14 | def sign_out(resource_or_scope) 15 | scope = Devise::Mapping.find_scope!(resource_or_scope) 16 | logout(scope) 17 | end 18 | end 19 | 20 | RSpec.configure do |config| 21 | config.include DeviseRequestSpecHelpers, type: :request 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/geocoder_stubs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Stub all Geocoder requests in tests 4 | RSpec.configure do |config| 5 | config.before(:each) do 6 | # Create a generic stub for all Geocoder requests 7 | allow(Geocoder).to receive(:search).and_return( 8 | [ 9 | double( 10 | data: { 11 | 'properties' => { 12 | 'countrycode' => 'US', 13 | 'country' => 'United States', 14 | 'state' => 'New York', 15 | 'name' => 'Test Location' 16 | } 17 | } 18 | ) 19 | ] 20 | ) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/pundit_matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Custom RSpec matchers for Pundit policies 4 | 5 | RSpec::Matchers.define :permit do |action| 6 | match do |policy| 7 | policy.public_send("#{action}?") 8 | end 9 | 10 | failure_message do |policy| 11 | "#{policy.class} does not permit #{action} on #{policy.record} for #{policy.user.inspect}." 12 | end 13 | 14 | failure_message_when_negated do |policy| 15 | "#{policy.class} does not forbid #{action} on #{policy.record} for #{policy.user.inspect}." 16 | end 17 | end 18 | 19 | RSpec::Matchers.define :forbid do |action| 20 | match do |policy| 21 | policy.public_send("#{action}?") 22 | end 23 | 24 | failure_message do |policy| 25 | "#{policy.class} does not forbid #{action} on #{policy.record} for #{policy.user.inspect}." 26 | end 27 | 28 | failure_message_when_negated do |policy| 29 | "#{policy.class} does not permit #{action} on #{policy.record} for #{policy.user.inspect}." 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/support/system_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SystemHelpers 4 | def sign_in_user(user, password = 'password123') 5 | visit new_user_session_path 6 | fill_in 'Email', with: user.email 7 | fill_in 'Password', with: password 8 | click_button 'Log in' 9 | end 10 | 11 | def sign_in_and_visit_map(user, password = 'password123') 12 | sign_in_user(user, password) 13 | expect(page).to have_current_path(map_path) 14 | expect(page).to have_css('.leaflet-container', wait: 10) 15 | end 16 | end 17 | 18 | RSpec.configure do |config| 19 | config.include SystemHelpers, type: :system 20 | end 21 | -------------------------------------------------------------------------------- /spec/tasks/import_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | describe 'import.rake' do 6 | let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json').to_s } 7 | let(:user) { create(:user) } 8 | 9 | it 'calls importing class' do 10 | expect(Tasks::Imports::GoogleRecords).to receive(:new).with(file_path, user.email).and_call_original.once 11 | 12 | Rake::Task['import:big_file'].invoke(file_path, user.email) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/storage/.keep -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 4 | driven_by :selenium, using: :chrome, screen_size: [1400, 1400] 5 | end 6 | -------------------------------------------------------------------------------- /test/channels/application_cable/connection_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase 4 | # test "connects with cookies" do 5 | # cookies.signed[:user_id] = 42 6 | # 7 | # connect 8 | # 9 | # assert_equal connection.user_id, "42" 10 | # end 11 | end 12 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/test/controllers/.keep -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/test/fixtures/files/.keep -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/test/helpers/.keep -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/test/integration/.keep -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/test/mailers/.keep -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/test/models/.keep -------------------------------------------------------------------------------- /test/system/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/test/system/.keep -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] ||= "test" 2 | require_relative "../config/environment" 3 | require "rails/test_help" 4 | 5 | class ActiveSupport::TestCase 6 | # Run tests in parallel with specified workers 7 | parallelize(workers: :number_of_processors) 8 | 9 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 10 | fixtures :all 11 | 12 | # Add more helper methods to be used by all tests here... 13 | end 14 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/tmp/.keep -------------------------------------------------------------------------------- /tmp/imports/watched/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/tmp/imports/watched/.keep -------------------------------------------------------------------------------- /tmp/imports/watched/put-your-directory-here.txt: -------------------------------------------------------------------------------- 1 | The /tmp/imports/watched/USER@EMAIL.TLD directory is watched by Dawarich. Any files you put in this directory under a directory names with the email of the user you want to import the file for will be imported into the database. 2 | 3 | For example, if you want to import a file for the user with the email address "email@dawarich.app", you would place the file in the directory /tmp/imports/watched/email@dawarich.app. The file you place in this directory should be a GeoJSON or GPX file that contains the data you want to import. Dawarich automatically scans directories for new files every 60 minutes, on 0 minute of every hour, so you should see the file imported into the database within 1 hour of placing it in the directory. 4 | 5 | -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/tmp/pids/.keep -------------------------------------------------------------------------------- /tmp/storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/tmp/storage/.keep -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/vendor/.keep -------------------------------------------------------------------------------- /vendor/javascript/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Freika/dawarich/52fd8982d8d079eaf7f340d19c2002e8098ad68d/vendor/javascript/.keep --------------------------------------------------------------------------------