Imlement visits deletion API

This commit is contained in:
Eugene Burmakin 2025-08-21 20:41:53 +02:00
parent 6e773b6b51
commit 550d20c555
8 changed files with 151 additions and 27 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `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.

File diff suppressed because one or more lines are too long

View file

@ -75,6 +75,21 @@ 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

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

@ -72,22 +72,21 @@ export default class extends Controller {
// Create the Add Visit control
const AddVisitControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'add-visit-button');
button.innerHTML = '📍 Add a Visit';
button.title = 'Click to add a visit to the map';
const button = L.DomUtil.create('button', 'leaflet-control-button add-visit-button');
button.innerHTML = '';
button.title = 'Add a visit';
// Style the button
button.style.backgroundColor = 'white';
button.style.padding = '8px 12px';
button.style.border = '2px solid #ccc';
button.style.borderRadius = '4px';
// 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.fontSize = '14px';
button.style.fontWeight = 'bold';
button.style.marginBottom = '5px';
button.style.display = 'block';
button.style.width = '100%';
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';
@ -135,10 +134,9 @@ export default class extends Controller {
this.isAddingVisit = true;
// Update button style to show active state
button.style.backgroundColor = '#007bff';
button.style.backgroundColor = '#dc3545';
button.style.color = 'white';
button.style.borderColor = '#0056b3';
button.innerHTML = '✕ Cancel';
button.innerHTML = '✕';
// Change cursor to crosshair
this.map.getContainer().style.cursor = 'crosshair';
@ -155,8 +153,7 @@ export default class extends Controller {
// Reset button style
button.style.backgroundColor = 'white';
button.style.color = 'black';
button.style.borderColor = '#ccc';
button.innerHTML = '📍 Add Visit';
button.innerHTML = '';
// Reset cursor
this.map.getContainer().style.cursor = '';

View file

@ -1357,7 +1357,7 @@ export class VisitsManager {
<label class="label">
<span class="label-text text-sm font-medium">Location</span>
</label>
<select class="select select-bordered select-sm w-full bg-base-200 text-base-content" name="place">
<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}
@ -1391,6 +1391,14 @@ export class VisitsManager {
</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>
`;
@ -1507,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));
}
}
@ -1551,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

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

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