Merge pull request #1665 from Freika/feature/visits-creation-api

Add visits manual creation
This commit is contained in:
Evgenii Burmakin 2025-08-21 21:04:19 +02:00 committed by GitHub
commit 8a716aaae1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1928 additions and 30 deletions

View file

@ -4,6 +4,15 @@ 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/).
# [UNRELEASED]
## 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.
# [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)
if service.call
render json: Api::VisitSerializer.new(service.visit).call
else
render json: {
error: 'Failed to create visit',
errors: service.errors
}, 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

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

@ -23,6 +23,8 @@ class Imports::Create
import.update!(status: :failed)
broadcast_status_update
ExceptionReporter.call(e, 'Import failed')
create_import_failed_notification(import, user, e)
ensure
if import.processing?

View file

@ -0,0 +1,80 @@
# 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
end
def call
ActiveRecord::Base.transaction do
place = find_or_create_place
return false unless place
create_visit(place)
end
rescue StandardError => e
ExceptionReporter.call(e, 'Failed to create visit')
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')
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

@ -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')
expect(json_response['errors']).to include("Name can't be blank")
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

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