mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 20:38:01 -05:00
@@ -1 +1 @@
|
||||
0.30.9
|
||||
0.30.10
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [0.30.10] - 2025-08-22
|
||||
|
||||
## Added
|
||||
|
||||
- `POST /api/v1/visits` endpoint.
|
||||
- User now can create visits manually on the map.
|
||||
- User can now delete a visit by clicking on the delete button in the visit popup.
|
||||
- Import failure now throws an internal server error.
|
||||
|
||||
## Changed
|
||||
|
||||
- Source of imports is now being detected automatically.
|
||||
|
||||
|
||||
# [0.30.9] - 2025-08-19
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,6 +33,40 @@
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Add Visit Marker Styles */
|
||||
.add-visit-marker {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
background: white;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
animation: pulse-visit 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-visit {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Visit Form Popup Styles */
|
||||
.visit-form-popup .leaflet-popup-content-wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.leaflet-right-panel.controls-shifted {
|
||||
right: 310px;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@ class Api::V1::VisitsController < ApiController
|
||||
render json: serialized_visits
|
||||
end
|
||||
|
||||
def create
|
||||
service = Visits::Create.new(current_api_user, visit_params)
|
||||
|
||||
result = service.call
|
||||
|
||||
if result
|
||||
render json: Api::VisitSerializer.new(service.visit).call
|
||||
else
|
||||
error_message = service.errors || 'Failed to create visit'
|
||||
render json: { error: error_message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
visit = update_visit(visit)
|
||||
@@ -62,10 +75,25 @@ class Api::V1::VisitsController < ApiController
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
|
||||
if visit.destroy
|
||||
head :no_content
|
||||
else
|
||||
render json: {
|
||||
error: 'Failed to delete visit',
|
||||
errors: visit.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Visit not found' }, status: :not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def visit_params
|
||||
params.require(:visit).permit(:name, :place_id, :status)
|
||||
params.require(:visit).permit(:name, :place_id, :status, :latitude, :longitude, :started_at, :ended_at)
|
||||
end
|
||||
|
||||
def merge_params
|
||||
@@ -78,6 +106,8 @@ class Api::V1::VisitsController < ApiController
|
||||
|
||||
def update_visit(visit)
|
||||
visit_params.each do |key, value|
|
||||
next if %w[latitude longitude].include?(key.to_s)
|
||||
|
||||
visit[key] = value
|
||||
visit.name = visit.place.name if visit_params[:place_id].present?
|
||||
end
|
||||
|
||||
@@ -43,8 +43,7 @@ class ImportsController < ApplicationController
|
||||
raw_files = Array(files_params).reject(&:blank?)
|
||||
|
||||
if raw_files.empty?
|
||||
redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity
|
||||
return
|
||||
redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity and return
|
||||
end
|
||||
|
||||
created_imports = []
|
||||
@@ -59,11 +58,11 @@ class ImportsController < ApplicationController
|
||||
if created_imports.any?
|
||||
redirect_to imports_url,
|
||||
notice: "#{created_imports.size} files are queued to be imported in background",
|
||||
status: :see_other
|
||||
status: :see_other and return
|
||||
else
|
||||
redirect_to new_import_path,
|
||||
alert: 'No valid file references were found. Please upload files using the file selector.',
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_entity and return
|
||||
end
|
||||
rescue StandardError => e
|
||||
if created_imports.present?
|
||||
@@ -95,7 +94,7 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
|
||||
def import_params
|
||||
params.require(:import).permit(:name, :source, files: [])
|
||||
params.require(:import).permit(:name, files: [])
|
||||
end
|
||||
|
||||
def create_import_from_signed_id(signed_id)
|
||||
@@ -103,18 +102,29 @@ class ImportsController < ApplicationController
|
||||
|
||||
blob = ActiveStorage::Blob.find_signed(signed_id)
|
||||
|
||||
import = current_user.imports.build(
|
||||
name: blob.filename.to_s,
|
||||
source: params[:import][:source]
|
||||
)
|
||||
|
||||
import = current_user.imports.build(name: blob.filename.to_s)
|
||||
import.file.attach(blob)
|
||||
import.source = detect_import_source(import.file) if import.source.blank?
|
||||
|
||||
import.save!
|
||||
|
||||
import
|
||||
end
|
||||
|
||||
def detect_import_source(file_attachment)
|
||||
temp_file_path = Imports::SecureFileDownloader.new(file_attachment).download_to_temp_file
|
||||
|
||||
Imports::SourceDetector.new_from_file_header(temp_file_path).detect_source
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to auto-detect import source for #{file_attachment.filename}: #{e.message}"
|
||||
nil
|
||||
ensure
|
||||
# Cleanup temp file
|
||||
if temp_file_path && File.exist?(temp_file_path)
|
||||
File.unlink(temp_file_path)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_points_limit
|
||||
limit_exceeded = PointsLimitExceeded.new(current_user).call
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
|
||||
import "@rails/ujs"
|
||||
import "@rails/actioncable"
|
||||
import "controllers"
|
||||
import "@hotwired/turbo-rails"
|
||||
|
||||
462
app/javascript/controllers/add_visit_controller.js
Normal file
462
app/javascript/controllers/add_visit_controller.js
Normal file
@@ -0,0 +1,462 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import L from "leaflet";
|
||||
import { showFlashMessage } from "../maps/helpers";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [""];
|
||||
static values = {
|
||||
apiKey: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
console.log("Add visit controller connected");
|
||||
this.map = null;
|
||||
this.isAddingVisit = false;
|
||||
this.addVisitMarker = null;
|
||||
this.addVisitButton = null;
|
||||
this.currentPopup = null;
|
||||
this.mapsController = null;
|
||||
|
||||
// Wait for the map to be initialized
|
||||
this.waitForMap();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.cleanup();
|
||||
console.log("Add visit controller disconnected");
|
||||
}
|
||||
|
||||
waitForMap() {
|
||||
// Get the map from the maps controller instance
|
||||
const mapElement = document.querySelector('[data-controller*="maps"]');
|
||||
|
||||
if (mapElement) {
|
||||
// Try to get Stimulus controller instance
|
||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapElement, 'maps');
|
||||
if (stimulusController && stimulusController.map) {
|
||||
this.map = stimulusController.map;
|
||||
this.mapsController = stimulusController;
|
||||
this.apiKeyValue = stimulusController.apiKey;
|
||||
this.setupAddVisitButton();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for map container and try to find map instance
|
||||
const mapContainer = document.getElementById('map');
|
||||
if (mapContainer && mapContainer._leaflet_id) {
|
||||
// Get map instance from Leaflet registry
|
||||
this.map = window.L._getMap ? window.L._getMap(mapContainer._leaflet_id) : null;
|
||||
|
||||
if (!this.map) {
|
||||
// Try through Leaflet internal registry
|
||||
const maps = window.L.Map._instances || {};
|
||||
this.map = maps[mapContainer._leaflet_id];
|
||||
}
|
||||
|
||||
if (this.map) {
|
||||
// Get API key from map element data
|
||||
this.apiKeyValue = mapContainer.dataset.api_key || this.element.dataset.apiKey;
|
||||
this.setupAddVisitButton();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit more for the map to initialize
|
||||
setTimeout(() => this.waitForMap(), 200);
|
||||
}
|
||||
|
||||
setupAddVisitButton() {
|
||||
if (!this.map || this.addVisitButton) return;
|
||||
|
||||
// Create the Add Visit control
|
||||
const AddVisitControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'leaflet-control-button add-visit-button');
|
||||
button.innerHTML = '➕';
|
||||
button.title = 'Add a visit';
|
||||
|
||||
// Style the button to match other map controls
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
button.style.fontSize = '18px';
|
||||
button.style.textAlign = 'center';
|
||||
button.style.transition = 'all 0.2s ease';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
|
||||
// Add hover effects
|
||||
button.addEventListener('mouseenter', () => {
|
||||
if (!this.isAddingVisit) {
|
||||
button.style.backgroundColor = '#f0f0f0';
|
||||
}
|
||||
});
|
||||
|
||||
button.addEventListener('mouseleave', () => {
|
||||
if (!this.isAddingVisit) {
|
||||
button.style.backgroundColor = 'white';
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle add visit mode on button click
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
this.toggleAddVisitMode(button);
|
||||
});
|
||||
|
||||
this.addVisitButton = button;
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
// Add the control to the map (top right, below existing buttons)
|
||||
this.map.addControl(new AddVisitControl({ position: 'topright' }));
|
||||
}
|
||||
|
||||
toggleAddVisitMode(button) {
|
||||
if (this.isAddingVisit) {
|
||||
// Exit add visit mode
|
||||
this.exitAddVisitMode(button);
|
||||
} else {
|
||||
// Enter add visit mode
|
||||
this.enterAddVisitMode(button);
|
||||
}
|
||||
}
|
||||
|
||||
enterAddVisitMode(button) {
|
||||
this.isAddingVisit = true;
|
||||
|
||||
// Update button style to show active state
|
||||
button.style.backgroundColor = '#dc3545';
|
||||
button.style.color = 'white';
|
||||
button.innerHTML = '✕';
|
||||
|
||||
// Change cursor to crosshair
|
||||
this.map.getContainer().style.cursor = 'crosshair';
|
||||
|
||||
// Add map click listener
|
||||
this.map.on('click', this.onMapClick, this);
|
||||
|
||||
showFlashMessage('notice', 'Click on the map to place a visit');
|
||||
}
|
||||
|
||||
exitAddVisitMode(button) {
|
||||
this.isAddingVisit = false;
|
||||
|
||||
// Reset button style
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.color = 'black';
|
||||
button.innerHTML = '➕';
|
||||
|
||||
// Reset cursor
|
||||
this.map.getContainer().style.cursor = '';
|
||||
|
||||
// Remove map click listener
|
||||
this.map.off('click', this.onMapClick, this);
|
||||
|
||||
// Remove any existing marker
|
||||
if (this.addVisitMarker) {
|
||||
this.map.removeLayer(this.addVisitMarker);
|
||||
this.addVisitMarker = null;
|
||||
}
|
||||
|
||||
// Close any open popup
|
||||
if (this.currentPopup) {
|
||||
this.map.closePopup(this.currentPopup);
|
||||
this.currentPopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMapClick(e) {
|
||||
if (!this.isAddingVisit) return;
|
||||
|
||||
const { lat, lng } = e.latlng;
|
||||
|
||||
// Remove existing marker if any
|
||||
if (this.addVisitMarker) {
|
||||
this.map.removeLayer(this.addVisitMarker);
|
||||
}
|
||||
|
||||
// Create a new marker at the clicked location
|
||||
this.addVisitMarker = L.marker([lat, lng], {
|
||||
draggable: true,
|
||||
icon: L.divIcon({
|
||||
className: 'add-visit-marker',
|
||||
html: '📍',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
})
|
||||
}).addTo(this.map);
|
||||
|
||||
// Show the visit form popup
|
||||
this.showVisitForm(lat, lng);
|
||||
}
|
||||
|
||||
showVisitForm(lat, lng) {
|
||||
// Get current date/time for default values
|
||||
const now = new Date();
|
||||
const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000));
|
||||
|
||||
// Format dates for datetime-local input
|
||||
const formatDateTime = (date) => {
|
||||
return date.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const startTime = formatDateTime(now);
|
||||
const endTime = formatDateTime(oneHourLater);
|
||||
|
||||
// Create form HTML
|
||||
const formHTML = `
|
||||
<div class="visit-form" style="min-width: 280px;">
|
||||
<h3 style="margin-top: 0; margin-bottom: 15px; font-size: 16px; color: #333;">Add New Visit</h3>
|
||||
|
||||
<form id="add-visit-form" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<div>
|
||||
<label for="visit-name" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">Name:</label>
|
||||
<input type="text" id="visit-name" name="name" required
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;"
|
||||
placeholder="Enter visit name">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="visit-start" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">Start Time:</label>
|
||||
<input type="datetime-local" id="visit-start" name="started_at" required value="${startTime}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="visit-end" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">End Time:</label>
|
||||
<input type="datetime-local" id="visit-end" name="ended_at" required value="${endTime}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="latitude" value="${lat}">
|
||||
<input type="hidden" name="longitude" value="${lng}">
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
||||
<button type="submit" style="flex: 1; background: #28a745; color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||||
Create Visit
|
||||
</button>
|
||||
<button type="button" id="cancel-visit" style="flex: 1; background: #dc3545; color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create popup at the marker location
|
||||
this.currentPopup = L.popup({
|
||||
closeOnClick: false,
|
||||
autoClose: false,
|
||||
maxWidth: 300,
|
||||
className: 'visit-form-popup'
|
||||
})
|
||||
.setLatLng([lat, lng])
|
||||
.setContent(formHTML)
|
||||
.openOn(this.map);
|
||||
|
||||
// Add event listeners after the popup is added to DOM
|
||||
setTimeout(() => {
|
||||
const form = document.getElementById('add-visit-form');
|
||||
const cancelButton = document.getElementById('cancel-visit');
|
||||
const nameInput = document.getElementById('visit-name');
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', (e) => this.handleFormSubmit(e));
|
||||
}
|
||||
|
||||
if (cancelButton) {
|
||||
cancelButton.addEventListener('click', () => {
|
||||
this.exitAddVisitMode(this.addVisitButton);
|
||||
});
|
||||
}
|
||||
|
||||
// Focus the name input
|
||||
if (nameInput) {
|
||||
nameInput.focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async handleFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Get form values
|
||||
const visitData = {
|
||||
visit: {
|
||||
name: formData.get('name'),
|
||||
started_at: formData.get('started_at'),
|
||||
ended_at: formData.get('ended_at'),
|
||||
latitude: formData.get('latitude'),
|
||||
longitude: formData.get('longitude')
|
||||
}
|
||||
};
|
||||
|
||||
// Validate that end time is after start time
|
||||
const startTime = new Date(visitData.visit.started_at);
|
||||
const endTime = new Date(visitData.visit.ended_at);
|
||||
|
||||
if (endTime <= startTime) {
|
||||
showFlashMessage('error', 'End time must be after start time');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable form while submitting
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Creating...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/visits`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`
|
||||
},
|
||||
body: JSON.stringify(visitData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showFlashMessage('notice', `Visit "${visitData.visit.name}" created successfully!`);
|
||||
this.exitAddVisitMode(this.addVisitButton);
|
||||
|
||||
// Refresh visits layer - this will clear and refetch data
|
||||
this.refreshVisitsLayer();
|
||||
|
||||
// Ensure confirmed visits layer is enabled (with a small delay for the API call to complete)
|
||||
setTimeout(() => {
|
||||
this.ensureVisitsLayersEnabled();
|
||||
}, 300);
|
||||
} else {
|
||||
const errorMessage = data.error || data.message || 'Failed to create visit';
|
||||
showFlashMessage('error', errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating visit:', error);
|
||||
showFlashMessage('error', 'Network error: Failed to create visit');
|
||||
} finally {
|
||||
// Re-enable form
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
refreshVisitsLayer() {
|
||||
console.log('Attempting to refresh visits layer...');
|
||||
|
||||
// Try multiple approaches to refresh the visits layer
|
||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||
if (mapsController) {
|
||||
// Try to get the Stimulus controller instance
|
||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||
|
||||
if (stimulusController && stimulusController.visitsManager) {
|
||||
console.log('Found maps controller with visits manager');
|
||||
|
||||
// Clear existing visits and fetch fresh data
|
||||
if (stimulusController.visitsManager.visitCircles) {
|
||||
stimulusController.visitsManager.visitCircles.clearLayers();
|
||||
}
|
||||
if (stimulusController.visitsManager.confirmedVisitCircles) {
|
||||
stimulusController.visitsManager.confirmedVisitCircles.clearLayers();
|
||||
}
|
||||
|
||||
// Refresh the visits data
|
||||
if (typeof stimulusController.visitsManager.fetchAndDisplayVisits === 'function') {
|
||||
console.log('Refreshing visits data...');
|
||||
stimulusController.visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
} else {
|
||||
console.log('Could not find maps controller or visits manager');
|
||||
|
||||
// Fallback: Try to dispatch a custom event
|
||||
const refreshEvent = new CustomEvent('visits:refresh', { bubbles: true });
|
||||
mapsController.dispatchEvent(refreshEvent);
|
||||
}
|
||||
} else {
|
||||
console.log('Could not find maps controller element');
|
||||
}
|
||||
}
|
||||
|
||||
ensureVisitsLayersEnabled() {
|
||||
console.log('Ensuring visits layers are enabled...');
|
||||
|
||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||
if (mapsController) {
|
||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||
|
||||
if (stimulusController && stimulusController.map && stimulusController.visitsManager) {
|
||||
const map = stimulusController.map;
|
||||
const visitsManager = stimulusController.visitsManager;
|
||||
|
||||
// Get the confirmed visits layer (newly created visits are always confirmed)
|
||||
const confirmedVisitsLayer = visitsManager.getConfirmedVisitCirclesLayer();
|
||||
|
||||
// Ensure confirmed visits layer is added to map since we create confirmed visits
|
||||
if (confirmedVisitsLayer && !map.hasLayer(confirmedVisitsLayer)) {
|
||||
console.log('Adding confirmed visits layer to map');
|
||||
map.addLayer(confirmedVisitsLayer);
|
||||
|
||||
// Update the layer control checkbox to reflect the layer is now active
|
||||
this.updateLayerControlCheckbox('Confirmed Visits', true);
|
||||
}
|
||||
|
||||
// Refresh visits data to include the new visit
|
||||
if (typeof visitsManager.fetchAndDisplayVisits === 'function') {
|
||||
console.log('Final refresh of visits to show new visit...');
|
||||
visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLayerControlCheckbox(layerName, isEnabled) {
|
||||
// Find the layer control input for the specified layer
|
||||
const layerControlContainer = document.querySelector('.leaflet-control-layers');
|
||||
if (!layerControlContainer) {
|
||||
console.log('Layer control container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]');
|
||||
inputs.forEach(input => {
|
||||
const label = input.nextElementSibling;
|
||||
if (label && label.textContent.trim() === layerName) {
|
||||
console.log(`Updating ${layerName} checkbox to ${isEnabled}`);
|
||||
input.checked = isEnabled;
|
||||
|
||||
// Trigger change event to ensure proper state management
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.map) {
|
||||
this.map.off('click', this.onMapClick, this);
|
||||
|
||||
if (this.addVisitMarker) {
|
||||
this.map.removeLayer(this.addVisitMarker);
|
||||
}
|
||||
|
||||
if (this.currentPopup) {
|
||||
this.map.closePopup(this.currentPopup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -645,7 +645,7 @@ export default class extends BaseController {
|
||||
const markerId = parseInt(marker[6]);
|
||||
return markerId !== numericId;
|
||||
});
|
||||
|
||||
|
||||
// Update scratch layer manager with updated markers
|
||||
if (this.scratchLayerManager) {
|
||||
this.scratchLayerManager.updateMarkers(this.markers);
|
||||
|
||||
@@ -1326,44 +1326,79 @@ export class VisitsManager {
|
||||
// Create popup content with form and dropdown
|
||||
const defaultName = visit.name;
|
||||
const popupContent = `
|
||||
<div class="p-3">
|
||||
<div class="mb-3">
|
||||
<div class="text-sm mb-1">
|
||||
<div class="p-4 bg-base-100 text-base-content rounded-lg shadow-lg">
|
||||
<div class="mb-4">
|
||||
<div class="text-sm mb-2 text-base-content/80 font-medium">
|
||||
${dateTimeDisplay.trim()}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">
|
||||
Duration: ${durationText},
|
||||
</span>
|
||||
<span class="text-sm mb-1 ${statusColorClass} font-semibold">
|
||||
status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)}
|
||||
</span>
|
||||
<span>${visit.place.latitude}, ${visit.place.longitude}</span>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Duration: ${durationText}
|
||||
</div>
|
||||
<div class="text-sm ${statusColorClass} font-semibold">
|
||||
Status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50 font-mono">
|
||||
${visit.place.latitude}, ${visit.place.longitude}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form class="visit-name-form" data-visit-id="${visit.id}">
|
||||
<form class="visit-name-form space-y-3" data-visit-id="${visit.id}">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm font-medium">Visit Name</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
class="input input-bordered input-sm w-full text-neutral-content"
|
||||
class="input input-bordered input-sm w-full bg-base-200 text-base-content placeholder:text-base-content/50"
|
||||
value="${defaultName}"
|
||||
placeholder="Enter visit name">
|
||||
</div>
|
||||
<div class="form-control mt-2">
|
||||
<select class="select text-neutral-content select-bordered select-sm w-full h-fit" name="place">
|
||||
${possiblePlaces.map(place => `
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm font-medium">Location</span>
|
||||
</label>
|
||||
<select class="select select-bordered select-sm text-xs w-full bg-base-200 text-base-content" name="place">
|
||||
${possiblePlaces.length > 0 ? possiblePlaces.map(place => `
|
||||
<option value="${place.id}" ${place.id === visit.place.id ? 'selected' : ''}>
|
||||
${place.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
`).join('') : `
|
||||
<option value="${visit.place.id}" selected>
|
||||
${visit.place.name || 'Current Location'}
|
||||
</option>
|
||||
`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-xs btn-primary">Save</button>
|
||||
<div class="flex gap-2 mt-4 pt-2 border-t border-base-300">
|
||||
<button type="submit" class="btn btn-sm btn-primary flex-1">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
Save
|
||||
</button>
|
||||
${visit.status !== 'confirmed' ? `
|
||||
<button type="button" class="btn btn-xs btn-success confirm-visit" data-id="${visit.id}">Confirm</button>
|
||||
<button type="button" class="btn btn-xs btn-error decline-visit" data-id="${visit.id}">Decline</button>
|
||||
<button type="button" class="btn btn-sm btn-success confirm-visit" data-id="${visit.id}">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"></path>
|
||||
</svg>
|
||||
Confirm
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-error decline-visit" data-id="${visit.id}">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
Decline
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline btn-error w-full delete-visit" data-id="${visit.id}">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Delete Visit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
@@ -1374,8 +1409,9 @@ export class VisitsManager {
|
||||
closeOnClick: true,
|
||||
autoClose: true,
|
||||
closeOnEscapeKey: true,
|
||||
maxWidth: 450, // Set maximum width
|
||||
minWidth: 300 // Set minimum width
|
||||
maxWidth: 420, // Set maximum width
|
||||
minWidth: 320, // Set minimum width
|
||||
className: 'visit-popup' // Add custom class for additional styling
|
||||
})
|
||||
.setLatLng([visit.place.latitude, visit.place.longitude])
|
||||
.setContent(popupContent);
|
||||
@@ -1407,6 +1443,12 @@ export class VisitsManager {
|
||||
const newName = event.target.querySelector('input').value;
|
||||
const selectedPlaceId = event.target.querySelector('select[name="place"]').value;
|
||||
|
||||
// Validate that we have a valid place_id
|
||||
if (!selectedPlaceId || selectedPlaceId === '') {
|
||||
showFlashMessage('error', 'Please select a valid location');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the selected place name from the dropdown
|
||||
const selectedOption = event.target.querySelector(`select[name="place"] option[value="${selectedPlaceId}"]`);
|
||||
const selectedPlaceName = selectedOption ? selectedOption.textContent.trim() : '';
|
||||
@@ -1473,9 +1515,11 @@ export class VisitsManager {
|
||||
// Add event listeners for confirm and decline buttons
|
||||
const confirmBtn = form.querySelector('.confirm-visit');
|
||||
const declineBtn = form.querySelector('.decline-visit');
|
||||
const deleteBtn = form.querySelector('.delete-visit');
|
||||
|
||||
confirmBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'confirmed'));
|
||||
declineBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'declined'));
|
||||
deleteBtn?.addEventListener('click', (event) => this.handleDeleteVisit(event, visit.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1517,6 +1561,51 @@ export class VisitsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles deletion of a visit with confirmation
|
||||
* @param {Event} event - The click event
|
||||
* @param {string} visitId - The visit ID to delete
|
||||
*/
|
||||
async handleDeleteVisit(event, visitId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Show confirmation dialog
|
||||
const confirmDelete = confirm('Are you sure you want to delete this visit? This action cannot be undone.');
|
||||
|
||||
if (!confirmDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Close the popup
|
||||
if (this.currentPopup) {
|
||||
this.map.closePopup(this.currentPopup);
|
||||
this.currentPopup = null;
|
||||
}
|
||||
|
||||
// Refresh the visits list
|
||||
this.fetchAndDisplayVisits();
|
||||
showFlashMessage('notice', 'Visit deleted successfully');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = errorData.error || 'Failed to delete visit';
|
||||
showFlashMessage('error', errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting visit:', error);
|
||||
showFlashMessage('error', 'Failed to delete visit');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates text to a specified length and adds ellipsis if needed
|
||||
* @param {string} text - The text to truncate
|
||||
|
||||
@@ -15,3 +15,42 @@
|
||||
.merge-visits-button {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Visit popup styling */
|
||||
.visit-popup .leaflet-popup-content-wrapper {
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visit-popup .leaflet-popup-content {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.visit-popup .leaflet-popup-tip {
|
||||
border-top-color: hsl(var(--b1));
|
||||
}
|
||||
|
||||
.visit-popup .leaflet-popup-close-button {
|
||||
color: hsl(var(--bc)) !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: bold !important;
|
||||
top: 8px !important;
|
||||
right: 8px !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
text-align: center !important;
|
||||
line-height: 24px !important;
|
||||
background: hsl(var(--b2)) !important;
|
||||
border-radius: 50% !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.visit-popup .leaflet-popup-close-button:hover {
|
||||
background: hsl(var(--b3)) !important;
|
||||
color: hsl(var(--bc)) !important;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ class Visit < ApplicationRecord
|
||||
|
||||
validates :started_at, :ended_at, :duration, :name, :status, presence: true
|
||||
|
||||
validates :ended_at, comparison: { greater_than: :started_at }
|
||||
|
||||
enum :status, { suggested: 0, confirmed: 1, declined: 2 }
|
||||
|
||||
def coordinates
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
class Geojson::Importer
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
include PointValidation
|
||||
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
json = Oj.load(file_content)
|
||||
|
||||
json = load_json_data
|
||||
data = Geojson::Params.new(json).call
|
||||
|
||||
data.each.with_index(1) do |point, index|
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
class GoogleMaps::PhoneTakeoutImporter
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
@@ -46,9 +48,7 @@ class GoogleMaps::PhoneTakeoutImporter
|
||||
raw_signals = []
|
||||
raw_array = []
|
||||
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
|
||||
json = Oj.load(file_content)
|
||||
json = load_json_data
|
||||
|
||||
if json.is_a?(Array)
|
||||
raw_array = parse_raw_array(json)
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
# via the UI, vs the CLI, which uses the `GoogleMaps::RecordsImporter` class.
|
||||
|
||||
class GoogleMaps::RecordsStorageImporter
|
||||
include Imports::FileLoader
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user = User.find_by(id: user_id)
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
@@ -20,21 +23,16 @@ class GoogleMaps::RecordsStorageImporter
|
||||
|
||||
private
|
||||
|
||||
attr_reader :import, :user
|
||||
attr_reader :import, :user, :file_path
|
||||
|
||||
def process_file_in_batches
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
locations = parse_file(file_content)
|
||||
parsed_file = load_json_data
|
||||
return unless parsed_file.is_a?(Hash) && parsed_file['locations']
|
||||
|
||||
locations = parsed_file['locations']
|
||||
process_locations_in_batches(locations) if locations.present?
|
||||
end
|
||||
|
||||
def parse_file(file_content)
|
||||
parsed_file = Oj.load(file_content, mode: :compat)
|
||||
return nil unless parsed_file.is_a?(Hash) && parsed_file['locations']
|
||||
|
||||
parsed_file['locations']
|
||||
end
|
||||
|
||||
def process_locations_in_batches(locations)
|
||||
batch = []
|
||||
index = 0
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
class GoogleMaps::SemanticHistoryImporter
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
@current_index = 0
|
||||
end
|
||||
|
||||
@@ -61,8 +63,7 @@ class GoogleMaps::SemanticHistoryImporter
|
||||
end
|
||||
|
||||
def points_data
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
json = Oj.load(file_content)
|
||||
json = load_json_data
|
||||
|
||||
json['timelineObjects'].flat_map do |timeline_object|
|
||||
parse_timeline_object(timeline_object)
|
||||
|
||||
@@ -4,16 +4,18 @@ require 'rexml/document'
|
||||
|
||||
class Gpx::TrackImporter
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
file_content = load_file_content
|
||||
json = Hash.from_xml(file_content)
|
||||
|
||||
tracks = json['gpx']['trk']
|
||||
|
||||
@@ -14,7 +14,10 @@ class Imports::Create
|
||||
import.update!(status: :processing)
|
||||
broadcast_status_update
|
||||
|
||||
importer(import.source).new(import, user.id).call
|
||||
temp_file_path = Imports::SecureFileDownloader.new(import.file).download_to_temp_file
|
||||
|
||||
source = import.source.presence || detect_source_from_file(temp_file_path)
|
||||
importer(source).new(import, user.id, temp_file_path).call
|
||||
|
||||
schedule_stats_creating(user.id)
|
||||
schedule_visit_suggesting(user.id, import)
|
||||
@@ -23,8 +26,14 @@ class Imports::Create
|
||||
import.update!(status: :failed)
|
||||
broadcast_status_update
|
||||
|
||||
ExceptionReporter.call(e, 'Import failed')
|
||||
|
||||
create_import_failed_notification(import, user, e)
|
||||
ensure
|
||||
if temp_file_path && File.exist?(temp_file_path)
|
||||
File.unlink(temp_file_path)
|
||||
end
|
||||
|
||||
if import.processing?
|
||||
import.update!(status: :completed)
|
||||
broadcast_status_update
|
||||
@@ -34,7 +43,7 @@ class Imports::Create
|
||||
private
|
||||
|
||||
def importer(source)
|
||||
case source
|
||||
case source.to_s
|
||||
when 'google_semantic_history' then GoogleMaps::SemanticHistoryImporter
|
||||
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutImporter
|
||||
when 'google_records' then GoogleMaps::RecordsStorageImporter
|
||||
@@ -42,6 +51,8 @@ class Imports::Create
|
||||
when 'gpx' then Gpx::TrackImporter
|
||||
when 'geojson' then Geojson::Importer
|
||||
when 'immich_api', 'photoprism_api' then Photos::Importer
|
||||
else
|
||||
raise ArgumentError, "Unsupported source: #{source}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,6 +90,11 @@ class Imports::Create
|
||||
).call
|
||||
end
|
||||
|
||||
def detect_source_from_file(temp_file_path)
|
||||
detector = Imports::SourceDetector.new_from_file_header(temp_file_path)
|
||||
detector.detect_source!
|
||||
end
|
||||
|
||||
def import_failed_message(import, error)
|
||||
if DawarichSettings.self_hosted?
|
||||
"Import \"#{import.name}\" failed: #{error.message}, stacktrace: #{error.backtrace.join("\n")}"
|
||||
|
||||
26
app/services/imports/file_loader.rb
Normal file
26
app/services/imports/file_loader.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Imports
|
||||
module FileLoader
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def load_json_data
|
||||
if file_path && File.exist?(file_path)
|
||||
Oj.load_file(file_path, mode: :compat)
|
||||
else
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
Oj.load(file_content, mode: :compat)
|
||||
end
|
||||
end
|
||||
|
||||
def load_file_content
|
||||
if file_path && File.exist?(file_path)
|
||||
File.read(file_path)
|
||||
else
|
||||
Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,63 @@ class Imports::SecureFileDownloader
|
||||
end
|
||||
|
||||
def download_with_verification
|
||||
file_content = download_to_string
|
||||
verify_file_integrity(file_content)
|
||||
file_content
|
||||
end
|
||||
|
||||
def download_to_temp_file
|
||||
retries = 0
|
||||
temp_file = nil
|
||||
|
||||
begin
|
||||
Timeout.timeout(DOWNLOAD_TIMEOUT) do
|
||||
temp_file = create_temp_file
|
||||
|
||||
# Download directly to temp file
|
||||
storage_attachment.download do |chunk|
|
||||
temp_file.write(chunk)
|
||||
end
|
||||
temp_file.rewind
|
||||
|
||||
# If file is empty, try alternative download method
|
||||
if temp_file.size == 0
|
||||
Rails.logger.warn('No content received from block download, trying alternative method')
|
||||
temp_file.write(storage_attachment.blob.download)
|
||||
temp_file.rewind
|
||||
end
|
||||
end
|
||||
rescue Timeout::Error => e
|
||||
retries += 1
|
||||
if retries <= MAX_RETRIES
|
||||
Rails.logger.warn("Download timeout, attempt #{retries} of #{MAX_RETRIES}")
|
||||
cleanup_temp_file(temp_file)
|
||||
retry
|
||||
else
|
||||
Rails.logger.error("Download failed after #{MAX_RETRIES} attempts")
|
||||
cleanup_temp_file(temp_file)
|
||||
raise
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Download error: #{e.message}")
|
||||
cleanup_temp_file(temp_file)
|
||||
raise
|
||||
end
|
||||
|
||||
raise 'Download completed but no content was received' if temp_file.size == 0
|
||||
|
||||
verify_temp_file_integrity(temp_file)
|
||||
temp_file.path
|
||||
ensure
|
||||
# Keep temp file open so it can be read by other processes
|
||||
# Caller is responsible for cleanup
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :storage_attachment
|
||||
|
||||
def download_to_string
|
||||
retries = 0
|
||||
file_content = nil
|
||||
|
||||
@@ -51,13 +108,23 @@ class Imports::SecureFileDownloader
|
||||
|
||||
raise 'Download completed but no content was received' if file_content.nil? || file_content.empty?
|
||||
|
||||
verify_file_integrity(file_content)
|
||||
file_content
|
||||
end
|
||||
|
||||
private
|
||||
def create_temp_file
|
||||
extension = File.extname(storage_attachment.filename.to_s)
|
||||
basename = File.basename(storage_attachment.filename.to_s, extension)
|
||||
Tempfile.new(["#{basename}_#{Time.now.to_i}", extension], binmode: true)
|
||||
end
|
||||
|
||||
attr_reader :storage_attachment
|
||||
def cleanup_temp_file(temp_file)
|
||||
return unless temp_file
|
||||
|
||||
temp_file.close unless temp_file.closed?
|
||||
temp_file.unlink if File.exist?(temp_file.path)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Failed to cleanup temp file: #{e.message}")
|
||||
end
|
||||
|
||||
def verify_file_integrity(file_content)
|
||||
return if file_content.nil? || file_content.empty?
|
||||
@@ -78,4 +145,26 @@ class Imports::SecureFileDownloader
|
||||
|
||||
raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
|
||||
end
|
||||
|
||||
def verify_temp_file_integrity(temp_file)
|
||||
return if temp_file.nil? || temp_file.size == 0
|
||||
|
||||
# Verify file size
|
||||
expected_size = storage_attachment.blob.byte_size
|
||||
actual_size = temp_file.size
|
||||
|
||||
if expected_size != actual_size
|
||||
raise "Incomplete download: expected #{expected_size} bytes, got #{actual_size} bytes"
|
||||
end
|
||||
|
||||
# Verify checksum
|
||||
expected_checksum = storage_attachment.blob.checksum
|
||||
temp_file.rewind
|
||||
actual_checksum = Base64.strict_encode64(Digest::MD5.digest(temp_file.read))
|
||||
temp_file.rewind
|
||||
|
||||
return unless expected_checksum != actual_checksum
|
||||
|
||||
raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
|
||||
end
|
||||
end
|
||||
|
||||
235
app/services/imports/source_detector.rb
Normal file
235
app/services/imports/source_detector.rb
Normal file
@@ -0,0 +1,235 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Imports::SourceDetector
|
||||
class UnknownSourceError < StandardError; end
|
||||
|
||||
DETECTION_RULES = {
|
||||
google_semantic_history: {
|
||||
required_keys: ['timelineObjects'],
|
||||
nested_patterns: [
|
||||
['timelineObjects', 0, 'activitySegment'],
|
||||
['timelineObjects', 0, 'placeVisit']
|
||||
]
|
||||
},
|
||||
google_records: {
|
||||
required_keys: ['locations'],
|
||||
nested_patterns: [
|
||||
['locations', 0, 'latitudeE7'],
|
||||
['locations', 0, 'longitudeE7']
|
||||
]
|
||||
},
|
||||
google_phone_takeout: {
|
||||
alternative_patterns: [
|
||||
# Pattern 1: Object with semanticSegments
|
||||
{
|
||||
required_keys: ['semanticSegments'],
|
||||
nested_patterns: [['semanticSegments', 0, 'startTime']]
|
||||
},
|
||||
# Pattern 2: Object with rawSignals
|
||||
{
|
||||
required_keys: ['rawSignals']
|
||||
},
|
||||
# Pattern 3: Array format with visit/activity objects
|
||||
{
|
||||
structure: :array,
|
||||
nested_patterns: [
|
||||
[0, 'visit', 'topCandidate', 'placeLocation'],
|
||||
[0, 'activity']
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
geojson: {
|
||||
required_keys: ['type', 'features'],
|
||||
required_values: { 'type' => 'FeatureCollection' },
|
||||
nested_patterns: [
|
||||
['features', 0, 'type'],
|
||||
['features', 0, 'geometry'],
|
||||
['features', 0, 'properties']
|
||||
]
|
||||
},
|
||||
owntracks: {
|
||||
structure: :rec_file_lines,
|
||||
line_pattern: /"_type":"location"/
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def initialize(file_content, filename = nil, file_path = nil)
|
||||
@file_content = file_content
|
||||
@filename = filename
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def self.new_from_file_header(file_path)
|
||||
filename = File.basename(file_path)
|
||||
|
||||
# For detection, read only first 2KB to optimize performance
|
||||
header_content = File.open(file_path, 'rb') { |f| f.read(2048) }
|
||||
|
||||
new(header_content, filename, file_path)
|
||||
end
|
||||
|
||||
def detect_source
|
||||
return :gpx if gpx_file?
|
||||
return :owntracks if owntracks_file?
|
||||
|
||||
json_data = parse_json
|
||||
return nil unless json_data
|
||||
|
||||
DETECTION_RULES.each do |format, rules|
|
||||
next if format == :owntracks # Already handled above
|
||||
|
||||
if matches_format?(json_data, rules)
|
||||
return format
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def detect_source!
|
||||
format = detect_source
|
||||
raise UnknownSourceError, 'Unable to detect file format' unless format
|
||||
|
||||
format
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :file_content, :filename, :file_path
|
||||
|
||||
def gpx_file?
|
||||
return false unless filename
|
||||
|
||||
# Must have .gpx extension AND contain GPX XML structure
|
||||
return false unless filename.downcase.end_with?('.gpx')
|
||||
|
||||
# Check content for GPX structure
|
||||
content_to_check = if file_path && File.exist?(file_path)
|
||||
# Read first 1KB for GPX detection
|
||||
File.open(file_path, 'rb') { |f| f.read(1024) }
|
||||
else
|
||||
file_content
|
||||
end
|
||||
|
||||
content_to_check.strip.start_with?('<?xml') && content_to_check.include?('<gpx')
|
||||
end
|
||||
|
||||
def owntracks_file?
|
||||
return false unless filename
|
||||
|
||||
# Check for .rec extension first (fastest check)
|
||||
return true if filename.downcase.end_with?('.rec')
|
||||
|
||||
# Check for specific OwnTracks line format in content
|
||||
content_to_check = if file_path && File.exist?(file_path)
|
||||
# For OwnTracks, read first few lines only
|
||||
File.open(file_path, 'r') { |f| f.read(2048) }
|
||||
else
|
||||
file_content
|
||||
end
|
||||
|
||||
content_to_check.lines.any? { |line| line.include?('"_type":"location"') }
|
||||
end
|
||||
|
||||
def parse_json
|
||||
# If we have a file path, use streaming for better memory efficiency
|
||||
if file_path && File.exist?(file_path)
|
||||
Oj.load_file(file_path, mode: :compat)
|
||||
else
|
||||
Oj.load(file_content, mode: :compat)
|
||||
end
|
||||
rescue Oj::ParseError, JSON::ParserError
|
||||
# If full file parsing fails but we have a file path, try with just the header
|
||||
if file_path && file_content.length < 2048
|
||||
begin
|
||||
File.open(file_path, 'rb') do |f|
|
||||
partial_content = f.read(4096) # Try a bit more content
|
||||
Oj.load(partial_content, mode: :compat)
|
||||
end
|
||||
rescue Oj::ParseError, JSON::ParserError
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def matches_format?(json_data, rules)
|
||||
# Handle alternative patterns (for google_phone_takeout)
|
||||
if rules[:alternative_patterns]
|
||||
return rules[:alternative_patterns].any? { |pattern| matches_pattern?(json_data, pattern) }
|
||||
end
|
||||
|
||||
matches_pattern?(json_data, rules)
|
||||
end
|
||||
|
||||
def matches_pattern?(json_data, pattern)
|
||||
# Check structure requirements
|
||||
return false unless structure_matches?(json_data, pattern[:structure])
|
||||
|
||||
# Check required keys
|
||||
if pattern[:required_keys]
|
||||
return false unless has_required_keys?(json_data, pattern[:required_keys])
|
||||
end
|
||||
|
||||
# Check required values
|
||||
if pattern[:required_values]
|
||||
return false unless has_required_values?(json_data, pattern[:required_values])
|
||||
end
|
||||
|
||||
# Check nested patterns
|
||||
if pattern[:nested_patterns]
|
||||
return false unless has_nested_patterns?(json_data, pattern[:nested_patterns])
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def structure_matches?(json_data, required_structure)
|
||||
case required_structure
|
||||
when :array
|
||||
json_data.is_a?(Array)
|
||||
when nil
|
||||
true # No specific structure required
|
||||
else
|
||||
true # Default to no restriction
|
||||
end
|
||||
end
|
||||
|
||||
def has_required_keys?(json_data, keys)
|
||||
return false unless json_data.is_a?(Hash)
|
||||
|
||||
keys.all? { |key| json_data.key?(key) }
|
||||
end
|
||||
|
||||
def has_required_values?(json_data, values)
|
||||
return false unless json_data.is_a?(Hash)
|
||||
|
||||
values.all? { |key, expected_value| json_data[key] == expected_value }
|
||||
end
|
||||
|
||||
def has_nested_patterns?(json_data, patterns)
|
||||
patterns.any? { |pattern| nested_key_exists?(json_data, pattern) }
|
||||
end
|
||||
|
||||
def nested_key_exists?(data, key_path)
|
||||
current = data
|
||||
|
||||
key_path.each do |key|
|
||||
return false unless current
|
||||
|
||||
if current.is_a?(Array)
|
||||
return false if key >= current.length
|
||||
current = current[key]
|
||||
elsif current.is_a?(Hash)
|
||||
return false unless current.key?(key)
|
||||
current = current[key]
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
!current.nil?
|
||||
end
|
||||
end
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
class OwnTracks::Importer
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
file_content = load_file_content
|
||||
parsed_data = OwnTracks::RecParser.new(file_content).call
|
||||
|
||||
points_data = parsed_data.map do |point|
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
class Photos::Importer
|
||||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
include PointValidation
|
||||
attr_reader :import, :user_id
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id)
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
@import = import
|
||||
@user_id = user_id
|
||||
@file_path = file_path
|
||||
end
|
||||
|
||||
def call
|
||||
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
|
||||
json = Oj.load(file_content)
|
||||
json = load_json_data
|
||||
|
||||
json.each.with_index(1) { |point, index| create_point(point, index) }
|
||||
end
|
||||
|
||||
90
app/services/visits/create.rb
Normal file
90
app/services/visits/create.rb
Normal file
@@ -0,0 +1,90 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
class Create
|
||||
attr_reader :user, :params, :errors, :visit
|
||||
|
||||
def initialize(user, params)
|
||||
@user = user
|
||||
@params = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
|
||||
@visit = nil
|
||||
@errors = nil
|
||||
end
|
||||
|
||||
def call
|
||||
ActiveRecord::Base.transaction do
|
||||
place = find_or_create_place
|
||||
return false unless place
|
||||
|
||||
visit = create_visit(place)
|
||||
visit
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
ExceptionReporter.call(e, "Failed to create visit: #{e.message}")
|
||||
|
||||
@errors = "Failed to create visit: #{e.message}"
|
||||
|
||||
false
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Failed to create visit: #{e.message}")
|
||||
|
||||
@errors = "Failed to create visit: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_place
|
||||
existing_place = find_existing_place
|
||||
|
||||
return existing_place if existing_place
|
||||
|
||||
create_new_place
|
||||
end
|
||||
|
||||
def find_existing_place
|
||||
Place.joins("JOIN visits ON places.id = visits.place_id")
|
||||
.where(visits: { user: user })
|
||||
.where(
|
||||
"ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)",
|
||||
params[:longitude].to_f, params[:latitude].to_f, 0.001 # approximately 100 meters
|
||||
).first
|
||||
end
|
||||
|
||||
def create_new_place
|
||||
place_name = params[:name]
|
||||
lat_f = params[:latitude].to_f
|
||||
lon_f = params[:longitude].to_f
|
||||
|
||||
place = Place.create!(
|
||||
name: place_name,
|
||||
latitude: lat_f,
|
||||
longitude: lon_f,
|
||||
lonlat: "POINT(#{lon_f} #{lat_f})",
|
||||
source: :manual
|
||||
)
|
||||
|
||||
place
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Failed to create place: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def create_visit(place)
|
||||
started_at = DateTime.parse(params[:started_at])
|
||||
ended_at = DateTime.parse(params[:ended_at])
|
||||
duration_minutes = (ended_at - started_at) * 24 * 60
|
||||
|
||||
@visit = user.visits.create!(
|
||||
name: params[:name],
|
||||
place: place,
|
||||
started_at: started_at,
|
||||
ended_at: ended_at,
|
||||
duration: duration_minutes.to_i,
|
||||
status: :confirmed
|
||||
)
|
||||
|
||||
@visit
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,71 +1,25 @@
|
||||
<!-- Supported Formats Info Card -->
|
||||
<div class="card bg-base-200 w-full max-w-md mb-5 mt-5">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="card-title text-sm">Supported Import Formats</h3>
|
||||
<ul class="text-xs space-y-1">
|
||||
<li><strong>✅ Google Maps:</strong> Records.json, Semantic History, Phone Takeout (.json)</li>
|
||||
<li><strong>✅ GPX:</strong> Track files (.gpx)</li>
|
||||
<li><strong>✅ GeoJSON:</strong> Feature collections (.json)</li>
|
||||
<li><strong>✅ OwnTracks:</strong> Recorder files (.rec)</li>
|
||||
</ul>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
File format is automatically detected during upload.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with model: import, class: "contents", data: {
|
||||
controller: "direct-upload",
|
||||
direct_upload_url_value: rails_direct_uploads_url,
|
||||
direct_upload_user_trial_value: current_user.trial?,
|
||||
direct_upload_target: "form"
|
||||
} do |form| %>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Select source</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :google_semantic_history, class: "radio radio-primary" %>
|
||||
<span class="label-text">Google Semantic History</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">JSON files from your Takeout/Location History/Semantic Location History/YEAR</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :google_records, class: "radio radio-primary" %>
|
||||
<span class="label-text">Google Records</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">The Records.json file from your Google Takeout</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :google_phone_takeout, class: "radio radio-primary" %>
|
||||
<span class="label-text">Google Phone Takeout</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">A JSON file you received after your request for Takeout from your mobile device</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :owntracks, class: "radio radio-primary" %>
|
||||
<span class="label-text">Owntracks</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">A .REC file you could find in your volumes/owntracks-recorder/store/rec/USER/TOPIC directory</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :geojson, class: "radio radio-primary" %>
|
||||
<span class="label-text">GeoJSON</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">A valid GeoJSON file. For example, a file, exported from a Dawarich instance</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer space-x-3">
|
||||
<%= form.radio_button :source, :gpx, class: "radio radio-primary" %>
|
||||
<span class="label-text">GPX</span>
|
||||
</label>
|
||||
<p class="text-sm mt-2">GPX track file</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-control w-full max-w-xs my-5">
|
||||
<div class="label">
|
||||
<span class="label-text">Select one or multiple files</span>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<div
|
||||
id='map'
|
||||
class="w-full z-0"
|
||||
data-controller="maps points"
|
||||
data-controller="maps points add-visit"
|
||||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
|
||||
@@ -101,7 +101,7 @@ Rails.application.routes.draw do
|
||||
|
||||
resources :areas, only: %i[index create update destroy]
|
||||
resources :points, only: %i[index create update destroy]
|
||||
resources :visits, only: %i[index update] do
|
||||
resources :visits, only: %i[index create update destroy] do
|
||||
get 'possible_places', to: 'visits/possible_places#index', on: :member
|
||||
collection do
|
||||
post 'merge', to: 'visits#merge'
|
||||
|
||||
@@ -10,6 +10,21 @@ RSpec.describe Visit, type: :model do
|
||||
it { is_expected.to have_many(:points).dependent(:nullify) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_presence_of(:started_at) }
|
||||
it { is_expected.to validate_presence_of(:ended_at) }
|
||||
it { is_expected.to validate_presence_of(:duration) }
|
||||
it { is_expected.to validate_presence_of(:status) }
|
||||
|
||||
it 'validates ended_at is greater than started_at' do
|
||||
visit = build(:visit, started_at: Time.zone.now, ended_at: Time.zone.now - 1.hour)
|
||||
|
||||
expect(visit).not_to be_valid
|
||||
expect(visit.errors[:ended_at]).to include("must be greater than #{visit.started_at}")
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it { expect(build(:visit)).to be_valid }
|
||||
end
|
||||
|
||||
@@ -64,6 +64,104 @@ RSpec.describe 'Api::V1::Visits', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/visits' do
|
||||
let(:valid_create_params) do
|
||||
{
|
||||
visit: {
|
||||
name: 'Test Visit',
|
||||
latitude: 52.52,
|
||||
longitude: 13.405,
|
||||
started_at: '2023-12-01T10:00:00Z',
|
||||
ended_at: '2023-12-01T12:00:00Z'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:existing_place) { create(:place, latitude: 52.52, longitude: 13.405) }
|
||||
|
||||
it 'creates a new visit' do
|
||||
expect {
|
||||
post '/api/v1/visits', params: valid_create_params, headers: auth_headers
|
||||
}.to change { user.visits.count }.by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'creates a visit with correct attributes' do
|
||||
post '/api/v1/visits', params: valid_create_params, headers: auth_headers
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['name']).to eq('Test Visit')
|
||||
expect(json_response['status']).to eq('confirmed')
|
||||
expect(json_response['duration']).to eq(120) # 2 hours in minutes
|
||||
expect(json_response['place']['latitude']).to eq(52.52)
|
||||
expect(json_response['place']['longitude']).to eq(13.405)
|
||||
end
|
||||
|
||||
it 'creates a place for the visit' do
|
||||
expect {
|
||||
post '/api/v1/visits', params: valid_create_params, headers: auth_headers
|
||||
}.to change { Place.count }.by(1)
|
||||
|
||||
created_place = Place.last
|
||||
expect(created_place.name).to eq('Test Visit')
|
||||
expect(created_place.latitude).to eq(52.52)
|
||||
expect(created_place.longitude).to eq(13.405)
|
||||
expect(created_place.source).to eq('manual')
|
||||
end
|
||||
|
||||
it 'reuses existing place when coordinates are exactly the same' do
|
||||
create(:visit, user: user, place: existing_place)
|
||||
|
||||
expect {
|
||||
post '/api/v1/visits', params: valid_create_params, headers: auth_headers
|
||||
}.not_to change { Place.count }
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['place']['id']).to eq(existing_place.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
context 'when required fields are missing' do
|
||||
let(:missing_name_params) do
|
||||
valid_create_params.deep_merge(visit: { name: '' })
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
post '/api/v1/visits', params: missing_name_params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns error message' do
|
||||
post '/api/v1/visits', params: missing_name_params, headers: auth_headers
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
expect(json_response['error']).to eq('Failed to create visit')
|
||||
end
|
||||
|
||||
it 'does not create a visit' do
|
||||
expect {
|
||||
post '/api/v1/visits', params: missing_name_params, headers: auth_headers
|
||||
}.not_to change { Visit.count }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid API key' do
|
||||
let(:invalid_auth_headers) { { 'Authorization' => 'Bearer invalid-key' } }
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
post '/api/v1/visits', params: valid_create_params, headers: invalid_auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/visits/:id' do
|
||||
let(:visit) { create(:visit, user:) }
|
||||
|
||||
@@ -224,4 +322,61 @@ RSpec.describe 'Api::V1::Visits', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/visits/:id' do
|
||||
let!(:visit) { create(:visit, user: user, place: place) }
|
||||
let!(:other_user_visit) { create(:visit, user: other_user, place: place) }
|
||||
|
||||
context 'when visit exists and belongs to current user' do
|
||||
it 'deletes the visit' do
|
||||
expect {
|
||||
delete "/api/v1/visits/#{visit.id}", headers: auth_headers
|
||||
}.to change { user.visits.count }.by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'removes the visit from the database' do
|
||||
delete "/api/v1/visits/#{visit.id}", headers: auth_headers
|
||||
|
||||
expect { visit.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit does not exist' do
|
||||
it 'returns not found status' do
|
||||
delete '/api/v1/visits/999999', headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Visit not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit belongs to another user' do
|
||||
it 'returns not found status' do
|
||||
delete "/api/v1/visits/#{other_user_visit.id}", headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Visit not found')
|
||||
end
|
||||
|
||||
it 'does not delete the visit' do
|
||||
expect {
|
||||
delete "/api/v1/visits/#{other_user_visit.id}", headers: auth_headers
|
||||
}.not_to change { Visit.count }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid API key' do
|
||||
let(:invalid_auth_headers) { { 'Authorization' => 'Bearer invalid-key' } }
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
delete "/api/v1/visits/#{visit.id}", headers: invalid_auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,7 @@ RSpec.describe Imports::Create do
|
||||
|
||||
it 'sets status to processing at start' do
|
||||
service.call
|
||||
|
||||
expect(import.reload.status).to eq('processing').or eq('completed')
|
||||
end
|
||||
|
||||
@@ -29,7 +30,7 @@ RSpec.describe Imports::Create do
|
||||
|
||||
context 'when import fails' do
|
||||
before do
|
||||
allow(OwnTracks::Importer).to receive(:new).with(import, user.id).and_raise(StandardError)
|
||||
allow(OwnTracks::Importer).to receive(:new).with(import, user.id, kind_of(String)).and_raise(StandardError)
|
||||
end
|
||||
|
||||
it 'sets status to failed' do
|
||||
@@ -51,7 +52,7 @@ RSpec.describe Imports::Create do
|
||||
|
||||
it 'calls the GoogleMaps::SemanticHistoryImporter' do
|
||||
expect(GoogleMaps::SemanticHistoryImporter).to \
|
||||
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||
receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true))
|
||||
service.call
|
||||
end
|
||||
|
||||
@@ -62,10 +63,16 @@ RSpec.describe Imports::Create do
|
||||
|
||||
context 'when source is google_phone_takeout' do
|
||||
let(:import) { create(:import, source: 'google_phone_takeout') }
|
||||
let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout.json') }
|
||||
|
||||
before do
|
||||
import.file.attach(io: File.open(file_path), filename: 'phone-takeout.json',
|
||||
content_type: 'application/json')
|
||||
end
|
||||
|
||||
it 'calls the GoogleMaps::PhoneTakeoutImporter' do
|
||||
expect(GoogleMaps::PhoneTakeoutImporter).to \
|
||||
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||
receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true))
|
||||
service.call
|
||||
end
|
||||
end
|
||||
@@ -81,7 +88,7 @@ RSpec.describe Imports::Create do
|
||||
|
||||
it 'calls the OwnTracks::Importer' do
|
||||
expect(OwnTracks::Importer).to \
|
||||
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||
receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true))
|
||||
service.call
|
||||
end
|
||||
|
||||
@@ -102,7 +109,7 @@ RSpec.describe Imports::Create do
|
||||
|
||||
context 'when import fails' do
|
||||
before do
|
||||
allow(OwnTracks::Importer).to receive(:new).with(import, user.id).and_raise(StandardError)
|
||||
allow(OwnTracks::Importer).to receive(:new).with(import, user.id, kind_of(String)).and_raise(StandardError)
|
||||
end
|
||||
|
||||
context 'when self-hosted' do
|
||||
@@ -153,37 +160,55 @@ RSpec.describe Imports::Create do
|
||||
|
||||
it 'calls the Gpx::TrackImporter' do
|
||||
expect(Gpx::TrackImporter).to \
|
||||
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||
receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true))
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when source is geojson' do
|
||||
let(:import) { create(:import, source: 'geojson') }
|
||||
let(:file_path) { Rails.root.join('spec/fixtures/files/geojson/export.json') }
|
||||
|
||||
before do
|
||||
import.file.attach(io: File.open(file_path), filename: 'export.json',
|
||||
content_type: 'application/json')
|
||||
end
|
||||
|
||||
it 'calls the Geojson::Importer' do
|
||||
expect(Geojson::Importer).to \
|
||||
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||
receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true))
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when source is immich_api' do
|
||||
let(:import) { create(:import, source: 'immich_api') }
|
||||
let(:file_path) { Rails.root.join('spec/fixtures/files/immich/geodata.json') }
|
||||
|
||||
before do
|
||||
import.file.attach(io: File.open(file_path), filename: 'geodata.json',
|
||||
content_type: 'application/json')
|
||||
end
|
||||
|
||||
it 'calls the Photos::Importer' do
|
||||
expect(Photos::Importer).to \
|
||||
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||
receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true))
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when source is photoprism_api' do
|
||||
let(:import) { create(:import, source: 'photoprism_api') }
|
||||
let(:file_path) { Rails.root.join('spec/fixtures/files/immich/geodata.json') }
|
||||
|
||||
before do
|
||||
import.file.attach(io: File.open(file_path), filename: 'geodata.json',
|
||||
content_type: 'application/json')
|
||||
end
|
||||
|
||||
it 'calls the Photos::Importer' do
|
||||
expect(Photos::Importer).to \
|
||||
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||
receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true))
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
174
spec/services/imports/source_detector_spec.rb
Normal file
174
spec/services/imports/source_detector_spec.rb
Normal file
@@ -0,0 +1,174 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Imports::SourceDetector do
|
||||
let(:detector) { described_class.new(file_content, filename) }
|
||||
let(:filename) { nil }
|
||||
|
||||
describe '#detect_source' do
|
||||
context 'with Google Semantic History format' do
|
||||
let(:file_content) { file_fixture('google/semantic_history.json').read }
|
||||
|
||||
it 'detects google_semantic_history format' do
|
||||
expect(detector.detect_source).to eq(:google_semantic_history)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with Google Records format' do
|
||||
let(:file_content) { file_fixture('google/records.json').read }
|
||||
|
||||
it 'detects google_records format' do
|
||||
expect(detector.detect_source).to eq(:google_records)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with Google Phone Takeout format' do
|
||||
let(:file_content) { file_fixture('google/phone-takeout.json').read }
|
||||
|
||||
it 'detects google_phone_takeout format' do
|
||||
expect(detector.detect_source).to eq(:google_phone_takeout)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with Google Phone Takeout array format' do
|
||||
let(:file_content) { file_fixture('google/location-history.json').read }
|
||||
|
||||
it 'detects google_phone_takeout format' do
|
||||
expect(detector.detect_source).to eq(:google_phone_takeout)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with GeoJSON format' do
|
||||
let(:file_content) { file_fixture('geojson/export.json').read }
|
||||
|
||||
it 'detects geojson format' do
|
||||
expect(detector.detect_source).to eq(:geojson)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with OwnTracks REC file' do
|
||||
let(:file_content) { file_fixture('owntracks/2024-03.rec').read }
|
||||
let(:filename) { 'test.rec' }
|
||||
|
||||
it 'detects owntracks format' do
|
||||
expect(detector.detect_source).to eq(:owntracks)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with OwnTracks content without .rec extension' do
|
||||
let(:file_content) { '{"_type":"location","lat":52.225,"lon":13.332}' }
|
||||
let(:filename) { 'test.json' }
|
||||
|
||||
it 'detects owntracks format based on content' do
|
||||
expect(detector.detect_source).to eq(:owntracks)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with GPX file' do
|
||||
let(:file_content) { file_fixture('gpx/gpx_track_single_segment.gpx').read }
|
||||
let(:filename) { 'test.gpx' }
|
||||
|
||||
it 'detects gpx format' do
|
||||
expect(detector.detect_source).to eq(:gpx)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid JSON' do
|
||||
let(:file_content) { 'invalid json content' }
|
||||
|
||||
it 'returns nil for invalid JSON' do
|
||||
expect(detector.detect_source).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unknown JSON format' do
|
||||
let(:file_content) { '{"unknown": "format", "data": []}' }
|
||||
|
||||
it 'returns nil for unknown format' do
|
||||
expect(detector.detect_source).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty content' do
|
||||
let(:file_content) { '' }
|
||||
|
||||
it 'returns nil for empty content' do
|
||||
expect(detector.detect_source).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#detect_source!' do
|
||||
context 'with valid format' do
|
||||
let(:file_content) { file_fixture('google/records.json').read }
|
||||
|
||||
it 'returns the detected format' do
|
||||
expect(detector.detect_source!).to eq(:google_records)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unknown format' do
|
||||
let(:file_content) { '{"unknown": "format"}' }
|
||||
|
||||
it 'raises UnknownSourceError' do
|
||||
expect { detector.detect_source! }.to raise_error(
|
||||
Imports::SourceDetector::UnknownSourceError,
|
||||
'Unable to detect file format'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.new_from_file_header' do
|
||||
context 'with Google Records file' do
|
||||
let(:fixture_path) { file_fixture('google/records.json').to_s }
|
||||
|
||||
it 'detects source correctly from file path' do
|
||||
detector = described_class.new_from_file_header(fixture_path)
|
||||
expect(detector.detect_source).to eq(:google_records)
|
||||
end
|
||||
|
||||
it 'can detect source efficiently from file' do
|
||||
detector = described_class.new_from_file_header(fixture_path)
|
||||
|
||||
# Verify it can detect correctly using file-based approach
|
||||
expect(detector.detect_source).to eq(:google_records)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with GeoJSON file' do
|
||||
let(:fixture_path) { file_fixture('geojson/export.json').to_s }
|
||||
|
||||
it 'detects source correctly from file path' do
|
||||
detector = described_class.new_from_file_header(fixture_path)
|
||||
expect(detector.detect_source).to eq(:geojson)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'detection accuracy with real fixture files' do
|
||||
shared_examples 'detects format correctly' do |expected_format, fixture_path|
|
||||
it "detects #{expected_format} format for #{fixture_path}" do
|
||||
file_content = file_fixture(fixture_path).read
|
||||
filename = File.basename(fixture_path)
|
||||
detector = described_class.new(file_content, filename)
|
||||
|
||||
expect(detector.detect_source).to eq(expected_format)
|
||||
end
|
||||
end
|
||||
|
||||
# Test various Google Semantic History variations
|
||||
include_examples 'detects format correctly', :google_semantic_history, 'google/location-history/with_activitySegment_with_startLocation.json'
|
||||
include_examples 'detects format correctly', :google_semantic_history, 'google/location-history/with_placeVisit_with_location_with_coordinates.json'
|
||||
|
||||
# Test GeoJSON variations
|
||||
include_examples 'detects format correctly', :geojson, 'geojson/export_same_points.json'
|
||||
include_examples 'detects format correctly', :geojson, 'geojson/gpslogger_example.json'
|
||||
|
||||
# Test GPX files
|
||||
include_examples 'detects format correctly', :gpx, 'gpx/arc_example.gpx'
|
||||
include_examples 'detects format correctly', :gpx, 'gpx/garmin_example.gpx'
|
||||
include_examples 'detects format correctly', :gpx, 'gpx/gpx_track_multiple_segments.gpx'
|
||||
end
|
||||
end
|
||||
171
spec/services/visits/create_spec.rb
Normal file
171
spec/services/visits/create_spec.rb
Normal file
@@ -0,0 +1,171 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Create do
|
||||
let(:user) { create(:user) }
|
||||
let(:valid_params) do
|
||||
{
|
||||
name: 'Test Visit',
|
||||
latitude: 52.52,
|
||||
longitude: 13.405,
|
||||
started_at: '2023-12-01T10:00:00Z',
|
||||
ended_at: '2023-12-01T12:00:00Z'
|
||||
}
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'when all parameters are valid' do
|
||||
subject(:service) { described_class.new(user, valid_params) }
|
||||
|
||||
it 'creates a visit successfully' do
|
||||
expect { service.call }.to change { user.visits.count }.by(1)
|
||||
expect(service.call).to be_truthy
|
||||
expect(service.visit).to be_persisted
|
||||
end
|
||||
|
||||
it 'creates a visit with correct attributes' do
|
||||
service.call
|
||||
visit = service.visit
|
||||
|
||||
expect(visit.name).to eq('Test Visit')
|
||||
expect(visit.user).to eq(user)
|
||||
expect(visit.status).to eq('confirmed')
|
||||
expect(visit.started_at).to eq(DateTime.parse('2023-12-01T10:00:00Z'))
|
||||
expect(visit.ended_at).to eq(DateTime.parse('2023-12-01T12:00:00Z'))
|
||||
expect(visit.duration).to eq(120) # 2 hours in minutes
|
||||
end
|
||||
|
||||
it 'creates a place with correct coordinates' do
|
||||
service.call
|
||||
place = service.visit.place
|
||||
|
||||
expect(place).to be_persisted
|
||||
expect(place.name).to eq('Test Visit')
|
||||
expect(place.latitude).to eq(52.52)
|
||||
expect(place.longitude).to eq(13.405)
|
||||
expect(place.source).to eq('manual')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reusing existing place' do
|
||||
let!(:existing_place) do
|
||||
create(:place,
|
||||
latitude: 52.52,
|
||||
longitude: 13.405,
|
||||
lonlat: 'POINT(13.405 52.52)')
|
||||
end
|
||||
let!(:existing_visit) { create(:visit, user: user, place: existing_place) }
|
||||
|
||||
subject(:service) { described_class.new(user, valid_params) }
|
||||
|
||||
it 'reuses the existing place' do
|
||||
expect { service.call }.not_to change { Place.count }
|
||||
expect(service.visit.place).to eq(existing_place)
|
||||
end
|
||||
|
||||
it 'creates a new visit with the existing place' do
|
||||
expect { service.call }.to change { user.visits.count }.by(1)
|
||||
expect(service.visit.place).to eq(existing_place)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when place creation fails' do
|
||||
subject(:service) { described_class.new(user, valid_params) }
|
||||
|
||||
before do
|
||||
allow(Place).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(Place.new))
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'calls ExceptionReporter' do
|
||||
expect(ExceptionReporter).to receive(:call)
|
||||
|
||||
service.call
|
||||
end
|
||||
|
||||
it 'does not create a visit' do
|
||||
expect { service.call }.not_to change { Visit.count }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit creation fails' do
|
||||
subject(:service) { described_class.new(user, valid_params) }
|
||||
|
||||
before do
|
||||
allow_any_instance_of(User).to receive_message_chain(:visits, :create!).and_raise(ActiveRecord::RecordInvalid.new(Visit.new))
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'calls ExceptionReporter' do
|
||||
expect(ExceptionReporter).to receive(:call)
|
||||
|
||||
service.call
|
||||
end
|
||||
end
|
||||
|
||||
context 'edge cases' do
|
||||
context 'when name is not provided but defaults are used' do
|
||||
let(:params) { valid_params.merge(name: '') }
|
||||
subject(:service) { described_class.new(user, params) }
|
||||
|
||||
it 'returns false due to validation' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when coordinates are strings' do
|
||||
let(:params) do
|
||||
valid_params.merge(
|
||||
latitude: '52.52',
|
||||
longitude: '13.405'
|
||||
)
|
||||
end
|
||||
subject(:service) { described_class.new(user, params) }
|
||||
|
||||
it 'converts them to floats and creates visit successfully' do
|
||||
expect(service.call).to be_truthy
|
||||
place = service.visit.place
|
||||
expect(place.latitude).to eq(52.52)
|
||||
expect(place.longitude).to eq(13.405)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit duration is very short' do
|
||||
let(:params) do
|
||||
valid_params.merge(
|
||||
started_at: '2023-12-01T12:00:00Z',
|
||||
ended_at: '2023-12-01T12:01:00Z' # 1 minute
|
||||
)
|
||||
end
|
||||
subject(:service) { described_class.new(user, params) }
|
||||
|
||||
it 'creates visit with correct duration' do
|
||||
service.call
|
||||
expect(service.visit.duration).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit duration is very long' do
|
||||
let(:params) do
|
||||
valid_params.merge(
|
||||
started_at: '2023-12-01T08:00:00Z',
|
||||
ended_at: '2023-12-02T20:00:00Z' # 36 hours
|
||||
)
|
||||
end
|
||||
subject(:service) { described_class.new(user, params) }
|
||||
|
||||
it 'creates visit with correct duration' do
|
||||
service.call
|
||||
expect(service.visit.duration).to eq(36 * 60) # 36 hours in minutes
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
393
spec/swagger/api/v1/visits_controller_spec.rb
Normal file
393
spec/swagger/api/v1/visits_controller_spec.rb
Normal file
@@ -0,0 +1,393 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'swagger_helper'
|
||||
|
||||
describe 'Visits API', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:place) { create(:place) }
|
||||
let(:test_visit) { create(:visit, user: user, place: place) }
|
||||
|
||||
path '/api/v1/visits' do
|
||||
get 'List visits' do
|
||||
tags 'Visits'
|
||||
produces 'application/json'
|
||||
parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token'
|
||||
parameter name: :start_at, in: :query, type: :string, required: false, description: 'Start date (ISO 8601)'
|
||||
parameter name: :end_at, in: :query, type: :string, required: false, description: 'End date (ISO 8601)'
|
||||
parameter name: :selection, in: :query, type: :string, required: false, description: 'Set to "true" for area-based search'
|
||||
parameter name: :sw_lat, in: :query, type: :number, required: false, description: 'Southwest latitude for area search'
|
||||
parameter name: :sw_lng, in: :query, type: :number, required: false, description: 'Southwest longitude for area search'
|
||||
parameter name: :ne_lat, in: :query, type: :number, required: false, description: 'Northeast latitude for area search'
|
||||
parameter name: :ne_lng, in: :query, type: :number, required: false, description: 'Northeast longitude for area search'
|
||||
|
||||
response '200', 'visits found' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:start_at) { 1.week.ago.iso8601 }
|
||||
let(:end_at) { Time.current.iso8601 }
|
||||
|
||||
schema type: :array,
|
||||
items: {
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
status: { type: :string, enum: %w[suggested confirmed declined] },
|
||||
started_at: { type: :string, format: :datetime },
|
||||
ended_at: { type: :string, format: :datetime },
|
||||
duration: { type: :integer, description: 'Duration in minutes' },
|
||||
place: {
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
latitude: { type: :number },
|
||||
longitude: { type: :number },
|
||||
city: { type: :string },
|
||||
country: { type: :string }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: %w[id name status started_at ended_at duration]
|
||||
}
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:Authorization) { 'Bearer invalid-token' }
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
post 'Create visit' do
|
||||
tags 'Visits'
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token'
|
||||
parameter name: :visit, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
visit: {
|
||||
type: :object,
|
||||
properties: {
|
||||
name: { type: :string },
|
||||
latitude: { type: :number },
|
||||
longitude: { type: :number },
|
||||
started_at: { type: :string, format: :datetime },
|
||||
ended_at: { type: :string, format: :datetime }
|
||||
},
|
||||
required: %w[name latitude longitude started_at ended_at]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response '200', 'visit created' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:visit) do
|
||||
{
|
||||
visit: {
|
||||
name: 'Test Visit',
|
||||
latitude: 52.52,
|
||||
longitude: 13.405,
|
||||
started_at: '2023-12-01T10:00:00Z',
|
||||
ended_at: '2023-12-01T12:00:00Z'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
schema type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
status: { type: :string },
|
||||
started_at: { type: :string, format: :datetime },
|
||||
ended_at: { type: :string, format: :datetime },
|
||||
duration: { type: :integer },
|
||||
place: {
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
latitude: { type: :number },
|
||||
longitude: { type: :number }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '422', 'invalid request' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:visit) do
|
||||
{
|
||||
visit: {
|
||||
name: '',
|
||||
latitude: 52.52,
|
||||
longitude: 13.405,
|
||||
started_at: '2023-12-01T10:00:00Z',
|
||||
ended_at: '2023-12-01T12:00:00Z'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:Authorization) { 'Bearer invalid-token' }
|
||||
let(:visit) do
|
||||
{
|
||||
visit: {
|
||||
name: 'Test Visit',
|
||||
latitude: 52.52,
|
||||
longitude: 13.405,
|
||||
started_at: '2023-12-01T10:00:00Z',
|
||||
ended_at: '2023-12-01T12:00:00Z'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/visits/{id}' do
|
||||
patch 'Update visit' do
|
||||
tags 'Visits'
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'Visit ID'
|
||||
parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token'
|
||||
parameter name: :visit, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
visit: {
|
||||
type: :object,
|
||||
properties: {
|
||||
name: { type: :string },
|
||||
place_id: { type: :integer },
|
||||
status: { type: :string, enum: %w[suggested confirmed declined] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response '200', 'visit updated' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:id) { test_visit.id }
|
||||
let(:visit) { { visit: { name: 'Updated Visit' } } }
|
||||
|
||||
schema type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
status: { type: :string },
|
||||
started_at: { type: :string, format: :datetime },
|
||||
ended_at: { type: :string, format: :datetime },
|
||||
duration: { type: :integer },
|
||||
place: { type: :object }
|
||||
}
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '404', 'visit not found' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:id) { 999999 }
|
||||
let(:visit) { { visit: { name: 'Updated Visit' } } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:Authorization) { 'Bearer invalid-token' }
|
||||
let(:id) { test_visit.id }
|
||||
let(:visit) { { visit: { name: 'Updated Visit' } } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
delete 'Delete visit' do
|
||||
tags 'Visits'
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'Visit ID'
|
||||
parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token'
|
||||
|
||||
response '204', 'visit deleted' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:id) { test_visit.id }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '404', 'visit not found' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:id) { 999999 }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:Authorization) { 'Bearer invalid-token' }
|
||||
let(:id) { test_visit.id }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/visits/{id}/possible_places' do
|
||||
get 'Get possible places for visit' do
|
||||
tags 'Visits'
|
||||
produces 'application/json'
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'Visit ID'
|
||||
parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token'
|
||||
|
||||
response '200', 'possible places found' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:id) { test_visit.id }
|
||||
|
||||
schema type: :array,
|
||||
items: {
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
latitude: { type: :number },
|
||||
longitude: { type: :number },
|
||||
city: { type: :string },
|
||||
country: { type: :string }
|
||||
}
|
||||
}
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '404', 'visit not found' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:id) { 999999 }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:Authorization) { 'Bearer invalid-token' }
|
||||
let(:id) { test_visit.id }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/visits/merge' do
|
||||
post 'Merge visits' do
|
||||
tags 'Visits'
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token'
|
||||
parameter name: :merge_params, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
visit_ids: {
|
||||
type: :array,
|
||||
items: { type: :integer },
|
||||
minItems: 2,
|
||||
description: 'Array of visit IDs to merge (minimum 2)'
|
||||
}
|
||||
},
|
||||
required: %w[visit_ids]
|
||||
}
|
||||
|
||||
response '200', 'visits merged' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:visit1) { create(:visit, user: user) }
|
||||
let(:visit2) { create(:visit, user: user) }
|
||||
let(:merge_params) { { visit_ids: [visit1.id, visit2.id] } }
|
||||
|
||||
schema type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
status: { type: :string },
|
||||
started_at: { type: :string, format: :datetime },
|
||||
ended_at: { type: :string, format: :datetime },
|
||||
duration: { type: :integer },
|
||||
place: { type: :object }
|
||||
}
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '422', 'invalid request' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:merge_params) { { visit_ids: [test_visit.id] } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:Authorization) { 'Bearer invalid-token' }
|
||||
let(:merge_params) { { visit_ids: [test_visit.id] } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/visits/bulk_update' do
|
||||
post 'Bulk update visits' do
|
||||
tags 'Visits'
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token'
|
||||
parameter name: :bulk_params, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
visit_ids: {
|
||||
type: :array,
|
||||
items: { type: :integer },
|
||||
description: 'Array of visit IDs to update'
|
||||
},
|
||||
status: {
|
||||
type: :string,
|
||||
enum: %w[suggested confirmed declined],
|
||||
description: 'New status for the visits'
|
||||
}
|
||||
},
|
||||
required: %w[visit_ids status]
|
||||
}
|
||||
|
||||
response '200', 'visits updated' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:visit1) { create(:visit, user: user, status: 'suggested') }
|
||||
let(:visit2) { create(:visit, user: user, status: 'suggested') }
|
||||
let(:bulk_params) { { visit_ids: [visit1.id, visit2.id], status: 'confirmed' } }
|
||||
|
||||
schema type: :object,
|
||||
properties: {
|
||||
message: { type: :string },
|
||||
updated_count: { type: :integer }
|
||||
}
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '422', 'invalid request' do
|
||||
let(:Authorization) { "Bearer #{api_key}" }
|
||||
let(:bulk_params) { { visit_ids: [test_visit.id], status: 'invalid_status' } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:Authorization) { 'Bearer invalid-token' }
|
||||
let(:bulk_params) { { visit_ids: [test_visit.id], status: 'confirmed' } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1275,6 +1275,424 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: user found
|
||||
"/api/v1/visits":
|
||||
get:
|
||||
summary: List visits
|
||||
tags:
|
||||
- Visits
|
||||
parameters:
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
description: Bearer token
|
||||
schema:
|
||||
type: string
|
||||
- name: start_at
|
||||
in: query
|
||||
required: false
|
||||
description: Start date (ISO 8601)
|
||||
schema:
|
||||
type: string
|
||||
- name: end_at
|
||||
in: query
|
||||
required: false
|
||||
description: End date (ISO 8601)
|
||||
schema:
|
||||
type: string
|
||||
- name: selection
|
||||
in: query
|
||||
required: false
|
||||
description: Set to "true" for area-based search
|
||||
schema:
|
||||
type: string
|
||||
- name: sw_lat
|
||||
in: query
|
||||
required: false
|
||||
description: Southwest latitude for area search
|
||||
schema:
|
||||
type: number
|
||||
- name: sw_lng
|
||||
in: query
|
||||
required: false
|
||||
description: Southwest longitude for area search
|
||||
schema:
|
||||
type: number
|
||||
- name: ne_lat
|
||||
in: query
|
||||
required: false
|
||||
description: Northeast latitude for area search
|
||||
schema:
|
||||
type: number
|
||||
- name: ne_lng
|
||||
in: query
|
||||
required: false
|
||||
description: Northeast longitude for area search
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
description: visits found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- suggested
|
||||
- confirmed
|
||||
- declined
|
||||
started_at:
|
||||
type: string
|
||||
format: datetime
|
||||
ended_at:
|
||||
type: string
|
||||
format: datetime
|
||||
duration:
|
||||
type: integer
|
||||
description: Duration in minutes
|
||||
place:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
longitude:
|
||||
type: number
|
||||
city:
|
||||
type: string
|
||||
country:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- status
|
||||
- started_at
|
||||
- ended_at
|
||||
- duration
|
||||
'401':
|
||||
description: unauthorized
|
||||
post:
|
||||
summary: Create visit
|
||||
tags:
|
||||
- Visits
|
||||
parameters:
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
description: Bearer token
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: visit created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
started_at:
|
||||
type: string
|
||||
format: datetime
|
||||
ended_at:
|
||||
type: string
|
||||
format: datetime
|
||||
duration:
|
||||
type: integer
|
||||
place:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
longitude:
|
||||
type: number
|
||||
'422':
|
||||
description: invalid request
|
||||
'401':
|
||||
description: unauthorized
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
visit:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
longitude:
|
||||
type: number
|
||||
started_at:
|
||||
type: string
|
||||
format: datetime
|
||||
ended_at:
|
||||
type: string
|
||||
format: datetime
|
||||
required:
|
||||
- name
|
||||
- latitude
|
||||
- longitude
|
||||
- started_at
|
||||
- ended_at
|
||||
"/api/v1/visits/{id}":
|
||||
patch:
|
||||
summary: Update visit
|
||||
tags:
|
||||
- Visits
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Visit ID
|
||||
schema:
|
||||
type: integer
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
description: Bearer token
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: visit updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
started_at:
|
||||
type: string
|
||||
format: datetime
|
||||
ended_at:
|
||||
type: string
|
||||
format: datetime
|
||||
duration:
|
||||
type: integer
|
||||
place:
|
||||
type: object
|
||||
'404':
|
||||
description: visit not found
|
||||
'401':
|
||||
description: unauthorized
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
visit:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
place_id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- suggested
|
||||
- confirmed
|
||||
- declined
|
||||
delete:
|
||||
summary: Delete visit
|
||||
tags:
|
||||
- Visits
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Visit ID
|
||||
schema:
|
||||
type: integer
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
description: Bearer token
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: visit deleted
|
||||
'404':
|
||||
description: visit not found
|
||||
'401':
|
||||
description: unauthorized
|
||||
"/api/v1/visits/{id}/possible_places":
|
||||
get:
|
||||
summary: Get possible places for visit
|
||||
tags:
|
||||
- Visits
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Visit ID
|
||||
schema:
|
||||
type: integer
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
description: Bearer token
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: possible places found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
longitude:
|
||||
type: number
|
||||
city:
|
||||
type: string
|
||||
country:
|
||||
type: string
|
||||
'404':
|
||||
description: visit not found
|
||||
'401':
|
||||
description: unauthorized
|
||||
"/api/v1/visits/merge":
|
||||
post:
|
||||
summary: Merge visits
|
||||
tags:
|
||||
- Visits
|
||||
parameters:
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
description: Bearer token
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: visits merged
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
started_at:
|
||||
type: string
|
||||
format: datetime
|
||||
ended_at:
|
||||
type: string
|
||||
format: datetime
|
||||
duration:
|
||||
type: integer
|
||||
place:
|
||||
type: object
|
||||
'422':
|
||||
description: invalid request
|
||||
'401':
|
||||
description: unauthorized
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
visit_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
minItems: 2
|
||||
description: Array of visit IDs to merge (minimum 2)
|
||||
required:
|
||||
- visit_ids
|
||||
"/api/v1/visits/bulk_update":
|
||||
post:
|
||||
summary: Bulk update visits
|
||||
tags:
|
||||
- Visits
|
||||
parameters:
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
description: Bearer token
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: visits updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
updated_count:
|
||||
type: integer
|
||||
'422':
|
||||
description: invalid request
|
||||
'401':
|
||||
description: unauthorized
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
visit_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
description: Array of visit IDs to update
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- suggested
|
||||
- confirmed
|
||||
- declined
|
||||
description: New status for the visits
|
||||
required:
|
||||
- visit_ids
|
||||
- status
|
||||
servers:
|
||||
- url: http://{defaultHost}
|
||||
variables:
|
||||
|
||||
Reference in New Issue
Block a user