Merge pull request #1666 from Freika/dev

0.30.10
This commit is contained in:
Evgenii Burmakin
2025-08-22 22:14:25 +02:00
committed by GitHub
34 changed files with 2595 additions and 152 deletions

View File

@@ -1 +1 @@
0.30.9
0.30.10

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View 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);
}
}
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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|

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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']

View File

@@ -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")}"

View 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

View File

@@ -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

View 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

View File

@@ -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|

View File

@@ -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

View 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

View File

@@ -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>

View File

@@ -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 %>"

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View File

@@ -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: