mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Add some changes related to places management feature
This commit is contained in:
parent
78851c5f98
commit
e1f16c98a2
14 changed files with 1170 additions and 112 deletions
|
|
@ -6,24 +6,26 @@ module Api
|
||||||
before_action :set_place, only: [:show, :update, :destroy]
|
before_action :set_place, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@places = policy_scope(Place).includes(:tags)
|
@places = policy_scope(Place).includes(:tags, :visits)
|
||||||
@places = @places.with_tags(params[:tag_ids]) if params[:tag_ids].present?
|
@places = @places.with_tags(params[:tag_ids]) if params[:tag_ids].present?
|
||||||
|
|
||||||
render json: Api::PlaceSerializer.new(@places).serialize
|
render json: @places.map { |place| serialize_place(place) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
authorize @place
|
authorize @place
|
||||||
render json: Api::PlaceSerializer.new(@place).serialize
|
|
||||||
|
render json: serialize_place(@place)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@place = current_api_user.places.build(place_params)
|
@place = current_api_user.places.build(place_params)
|
||||||
|
|
||||||
authorize @place
|
authorize @place
|
||||||
|
|
||||||
if @place.save
|
if @place.save
|
||||||
add_tags if tag_ids.present?
|
add_tags if tag_ids.present?
|
||||||
render json: Api::PlaceSerializer.new(@place).serialize, status: :created
|
render json: serialize_place(@place), status: :created
|
||||||
else
|
else
|
||||||
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
|
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
@ -33,8 +35,8 @@ module Api
|
||||||
authorize @place
|
authorize @place
|
||||||
|
|
||||||
if @place.update(place_params)
|
if @place.update(place_params)
|
||||||
sync_tags if params[:place][:tag_ids]
|
set_tags if params[:place][:tag_ids]
|
||||||
render json: Api::PlaceSerializer.new(@place).serialize
|
render json: serialize_place(@place)
|
||||||
else
|
else
|
||||||
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
|
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
@ -42,13 +44,15 @@ module Api
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
authorize @place
|
authorize @place
|
||||||
|
|
||||||
@place.destroy!
|
@place.destroy!
|
||||||
|
|
||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
|
|
||||||
def nearby
|
def nearby
|
||||||
authorize Place, :nearby?
|
authorize Place, :nearby?
|
||||||
|
|
||||||
unless params[:latitude].present? && params[:longitude].present?
|
unless params[:latitude].present? && params[:longitude].present?
|
||||||
return render json: { error: 'latitude and longitude are required' }, status: :bad_request
|
return render json: { error: 'latitude and longitude are required' }, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
@ -84,11 +88,33 @@ module Api
|
||||||
@place.tags << tags
|
@place.tags << tags
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_tags
|
def set_tags
|
||||||
tag_ids_param = Array(params.dig(:place, :tag_ids)).compact
|
tag_ids_param = Array(params.dig(:place, :tag_ids)).compact
|
||||||
tags = current_api_user.tags.where(id: tag_ids_param)
|
tags = current_api_user.tags.where(id: tag_ids_param)
|
||||||
@place.tags = tags
|
@place.tags = tags
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def serialize_place(place)
|
||||||
|
{
|
||||||
|
id: place.id,
|
||||||
|
name: place.name,
|
||||||
|
latitude: place.latitude,
|
||||||
|
longitude: place.longitude,
|
||||||
|
source: place.source,
|
||||||
|
icon: place.tags.first&.icon,
|
||||||
|
color: place.tags.first&.color,
|
||||||
|
visits_count: place.visits.count,
|
||||||
|
created_at: place.created_at,
|
||||||
|
tags: place.tags.map do |tag|
|
||||||
|
{
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
icon: tag.icon,
|
||||||
|
color: tag.color
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,19 @@ class TagsController < ApplicationController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@tags = policy_scope(Tag).ordered
|
@tags = policy_scope(Tag).ordered
|
||||||
|
|
||||||
authorize Tag
|
authorize Tag
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@tag = current_user.tags.build
|
@tag = current_user.tags.build
|
||||||
|
|
||||||
authorize @tag
|
authorize @tag
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@tag = current_user.tags.build(tag_params)
|
@tag = current_user.tags.build(tag_params)
|
||||||
|
|
||||||
authorize @tag
|
authorize @tag
|
||||||
|
|
||||||
if @tag.save
|
if @tag.save
|
||||||
|
|
@ -41,7 +44,9 @@ class TagsController < ApplicationController
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
authorize @tag
|
authorize @tag
|
||||||
|
|
||||||
@tag.destroy!
|
@tag.destroy!
|
||||||
|
|
||||||
redirect_to tags_path, notice: 'Tag was successfully deleted.', status: :see_other
|
redirect_to tags_path, notice: 'Tag was successfully deleted.', status: :see_other
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
150
app/javascript/controllers/place_creation_controller.js
Normal file
150
app/javascript/controllers/place_creation_controller.js
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput",
|
||||||
|
"nearbyList", "loadingSpinner", "tagCheckboxes"]
|
||||||
|
static values = {
|
||||||
|
apiKey: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.setupEventListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
document.addEventListener('place:create', (e) => {
|
||||||
|
this.open(e.detail.latitude, e.detail.longitude)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(latitude, longitude) {
|
||||||
|
this.latitudeInputTarget.value = latitude
|
||||||
|
this.longitudeInputTarget.value = longitude
|
||||||
|
|
||||||
|
this.modalTarget.classList.add('modal-open')
|
||||||
|
this.nameInputTarget.focus()
|
||||||
|
|
||||||
|
await this.loadNearbyPlaces(latitude, longitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.modalTarget.classList.remove('modal-open')
|
||||||
|
this.formTarget.reset()
|
||||||
|
this.nearbyListTarget.innerHTML = ''
|
||||||
|
|
||||||
|
const event = new CustomEvent('place:create:cancelled')
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNearbyPlaces(latitude, longitude) {
|
||||||
|
this.loadingSpinnerTarget.classList.remove('hidden')
|
||||||
|
this.nearbyListTarget.innerHTML = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/places/nearby?latitude=${latitude}&longitude=${longitude}&limit=5`,
|
||||||
|
{ headers: { 'APIKEY': this.apiKeyValue } }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to load nearby places')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
this.renderNearbyPlaces(data.places)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading nearby places:', error)
|
||||||
|
this.nearbyListTarget.innerHTML = '<p class="text-error">Failed to load suggestions</p>'
|
||||||
|
} finally {
|
||||||
|
this.loadingSpinnerTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNearbyPlaces(places) {
|
||||||
|
if (!places || places.length === 0) {
|
||||||
|
this.nearbyListTarget.innerHTML = '<p class="text-sm text-gray-500">No nearby places found</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = places.map(place => `
|
||||||
|
<div class="card card-compact bg-base-200 cursor-pointer hover:bg-base-300 transition"
|
||||||
|
data-action="click->place-creation#selectNearby"
|
||||||
|
data-place-name="${this.escapeHtml(place.name)}"
|
||||||
|
data-place-latitude="${place.latitude}"
|
||||||
|
data-place-longitude="${place.longitude}">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="font-semibold">${this.escapeHtml(place.name)}</h4>
|
||||||
|
${place.street ? `<p class="text-sm">${this.escapeHtml(place.street)}</p>` : ''}
|
||||||
|
${place.city ? `<p class="text-xs text-gray-500">${this.escapeHtml(place.city)}, ${this.escapeHtml(place.country || '')}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
|
||||||
|
this.nearbyListTarget.innerHTML = html
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNearby(event) {
|
||||||
|
const element = event.currentTarget
|
||||||
|
this.nameInputTarget.value = element.dataset.placeName
|
||||||
|
this.latitudeInputTarget.value = element.dataset.placeLatitude
|
||||||
|
this.longitudeInputTarget.value = element.dataset.placeLongitude
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const formData = new FormData(this.formTarget)
|
||||||
|
const tagIds = Array.from(this.formTarget.querySelectorAll('input[name="tag_ids[]"]:checked'))
|
||||||
|
.map(cb => cb.value)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
place: {
|
||||||
|
name: formData.get('name'),
|
||||||
|
latitude: parseFloat(formData.get('latitude')),
|
||||||
|
longitude: parseFloat(formData.get('longitude')),
|
||||||
|
source: 'manual',
|
||||||
|
tag_ids: tagIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/places', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'APIKEY': this.apiKeyValue
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.errors?.join(', ') || 'Failed to create place')
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = await response.json()
|
||||||
|
|
||||||
|
this.close()
|
||||||
|
this.showNotification('Place created successfully!', 'success')
|
||||||
|
|
||||||
|
const event = new CustomEvent('place:created', { detail: { place } })
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating place:', error)
|
||||||
|
this.showNotification(error.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
const event = new CustomEvent('notification:show', {
|
||||||
|
detail: { message, type },
|
||||||
|
bubbles: true
|
||||||
|
})
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (!text) return ''
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = text
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
}
|
||||||
239
app/javascript/maps/places.js
Normal file
239
app/javascript/maps/places.js
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
// Maps Places Layer Manager
|
||||||
|
// Handles displaying user places with tag icons and colors on the map
|
||||||
|
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
export class PlacesManager {
|
||||||
|
constructor(map, apiKey) {
|
||||||
|
this.map = map;
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.placesLayer = null;
|
||||||
|
this.places = [];
|
||||||
|
this.markers = {};
|
||||||
|
this.selectedTags = new Set();
|
||||||
|
this.creationMode = false;
|
||||||
|
this.creationMarker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
this.placesLayer = L.layerGroup().addTo(this.map);
|
||||||
|
await this.loadPlaces();
|
||||||
|
this.setupMapClickHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPlaces(tagIds = null) {
|
||||||
|
try {
|
||||||
|
const url = new URL('/api/v1/places', window.location.origin);
|
||||||
|
if (tagIds && tagIds.length > 0) {
|
||||||
|
tagIds.forEach(id => url.searchParams.append('tag_ids[]', id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'APIKEY': this.apiKey }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to load places');
|
||||||
|
|
||||||
|
this.places = await response.json();
|
||||||
|
this.renderPlaces();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading places:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPlaces() {
|
||||||
|
// Clear existing markers
|
||||||
|
this.placesLayer.clearLayers();
|
||||||
|
this.markers = {};
|
||||||
|
|
||||||
|
this.places.forEach(place => {
|
||||||
|
const marker = this.createPlaceMarker(place);
|
||||||
|
if (marker) {
|
||||||
|
this.markers[place.id] = marker;
|
||||||
|
marker.addTo(this.placesLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlaceMarker(place) {
|
||||||
|
if (!place.latitude || !place.longitude) return null;
|
||||||
|
|
||||||
|
const icon = this.createPlaceIcon(place);
|
||||||
|
const marker = L.marker([place.latitude, place.longitude], { icon });
|
||||||
|
|
||||||
|
const popupContent = this.createPopupContent(place);
|
||||||
|
marker.bindPopup(popupContent);
|
||||||
|
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlaceIcon(place) {
|
||||||
|
const emoji = place.icon || place.tags[0]?.icon || '📍';
|
||||||
|
const color = place.color || place.tags[0]?.color || '#4CAF50';
|
||||||
|
|
||||||
|
const iconHtml = `
|
||||||
|
<div class="place-marker" style="
|
||||||
|
background-color: ${color};
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50% 50% 50% 0;
|
||||||
|
border: 2px solid white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
">
|
||||||
|
<span style="transform: rotate(45deg); font-size: 16px;">${emoji}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
html: iconHtml,
|
||||||
|
className: 'place-icon',
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 32],
|
||||||
|
popupAnchor: [0, -32]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createPopupContent(place) {
|
||||||
|
const tags = place.tags.map(tag =>
|
||||||
|
`<span class="badge badge-sm" style="background-color: ${tag.color}">
|
||||||
|
${tag.icon} ${tag.name}
|
||||||
|
</span>`
|
||||||
|
).join(' ');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="place-popup" style="min-width: 200px;">
|
||||||
|
<h3 class="font-bold text-lg mb-2">${place.name}</h3>
|
||||||
|
${tags ? `<div class="mb-2">${tags}</div>` : ''}
|
||||||
|
${place.visits_count ? `<p class="text-sm">Visits: ${place.visits_count}</p>` : ''}
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<button class="btn btn-xs btn-error" data-place-id="${place.id}" data-action="delete-place">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupMapClickHandler() {
|
||||||
|
this.map.on('click', (e) => {
|
||||||
|
if (this.creationMode) {
|
||||||
|
this.handleMapClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delegate event handling for delete buttons
|
||||||
|
this.map.on('popupopen', (e) => {
|
||||||
|
const popup = e.popup;
|
||||||
|
const deleteBtn = popup.getElement()?.querySelector('[data-action="delete-place"]');
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
const placeId = deleteBtn.dataset.placeId;
|
||||||
|
await this.deletePlace(placeId);
|
||||||
|
popup.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleMapClick(e) {
|
||||||
|
const { lat, lng } = e.latlng;
|
||||||
|
|
||||||
|
// Remove existing creation marker
|
||||||
|
if (this.creationMarker) {
|
||||||
|
this.map.removeLayer(this.creationMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add temporary marker
|
||||||
|
this.creationMarker = L.marker([lat, lng], {
|
||||||
|
icon: this.createPlaceIcon({ icon: '📍', color: '#FF9800' })
|
||||||
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
// Trigger place creation modal
|
||||||
|
this.triggerPlaceCreation(lat, lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerPlaceCreation(lat, lng) {
|
||||||
|
const event = new CustomEvent('place:create', {
|
||||||
|
detail: { latitude: lat, longitude: lng },
|
||||||
|
bubbles: true
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlace(placeId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this place?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/places/${placeId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'APIKEY': this.apiKey }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete place');
|
||||||
|
|
||||||
|
// Remove marker and reload
|
||||||
|
if (this.markers[placeId]) {
|
||||||
|
this.placesLayer.removeLayer(this.markers[placeId]);
|
||||||
|
delete this.markers[placeId];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.places = this.places.filter(p => p.id !== parseInt(placeId));
|
||||||
|
|
||||||
|
this.showNotification('Place deleted successfully', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting place:', error);
|
||||||
|
this.showNotification('Failed to delete place', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enableCreationMode() {
|
||||||
|
this.creationMode = true;
|
||||||
|
this.map.getContainer().style.cursor = 'crosshair';
|
||||||
|
this.showNotification('Click on the map to add a place', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
disableCreationMode() {
|
||||||
|
this.creationMode = false;
|
||||||
|
this.map.getContainer().style.cursor = '';
|
||||||
|
|
||||||
|
if (this.creationMarker) {
|
||||||
|
this.map.removeLayer(this.creationMarker);
|
||||||
|
this.creationMarker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterByTags(tagIds) {
|
||||||
|
this.selectedTags = new Set(tagIds);
|
||||||
|
this.loadPlaces(tagIds.length > 0 ? tagIds : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshPlaces() {
|
||||||
|
const tagIds = this.selectedTags.size > 0 ? Array.from(this.selectedTags) : null;
|
||||||
|
await this.loadPlaces(tagIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
if (this.placesLayer) {
|
||||||
|
this.map.addLayer(this.placesLayer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
if (this.placesLayer) {
|
||||||
|
this.map.removeLayer(this.placesLayer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
const event = new CustomEvent('notification:show', {
|
||||||
|
detail: { message, type },
|
||||||
|
bubbles: true
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,22 +13,18 @@ module Taggable
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add a tag to this taggable record
|
|
||||||
def add_tag(tag)
|
def add_tag(tag)
|
||||||
tags << tag unless tags.include?(tag)
|
tags << tag unless tags.include?(tag)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Remove a tag from this taggable record
|
|
||||||
def remove_tag(tag)
|
def remove_tag(tag)
|
||||||
tags.delete(tag)
|
tags.delete(tag)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get all tag names for this taggable record
|
|
||||||
def tag_names
|
def tag_names
|
||||||
tags.pluck(:name)
|
tags.pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if tagged with specific tag
|
|
||||||
def tagged_with?(tag)
|
def tagged_with?(tag)
|
||||||
tags.include?(tag)
|
tags.include?(tag)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ class Tag < ApplicationRecord
|
||||||
has_many :places, through: :taggings, source: :taggable, source_type: 'Place'
|
has_many :places, through: :taggings, source: :taggable, source_type: 'Place'
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||||
validates :user, presence: true
|
|
||||||
validates :color, format: { with: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true }
|
validates :color, format: { with: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true }
|
||||||
|
|
||||||
scope :for_user, ->(user) { where(user: user) }
|
scope :for_user, ->(user) { where(user: user) }
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Api
|
|
||||||
class PlaceSerializer
|
|
||||||
include Alba::Resource
|
|
||||||
|
|
||||||
attributes :id, :name, :latitude, :longitude, :source, :created_at
|
|
||||||
|
|
||||||
attribute :icon do |place|
|
|
||||||
place.tags.first&.icon
|
|
||||||
end
|
|
||||||
|
|
||||||
attribute :color do |place|
|
|
||||||
place.tags.first&.color
|
|
||||||
end
|
|
||||||
|
|
||||||
many :tags do
|
|
||||||
attributes :id, :name, :icon, :color
|
|
||||||
end
|
|
||||||
|
|
||||||
attribute :visits_count do |place|
|
|
||||||
place.visits.count
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
65
app/views/shared/_place_creation_modal.html.erb
Normal file
65
app/views/shared/_place_creation_modal.html.erb
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<div data-controller="place-creation" data-place-creation-api-key-value="<%= current_user.api_key %>">
|
||||||
|
<div class="modal" data-place-creation-target="modal">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="font-bold text-lg mb-4">Create New Place</h3>
|
||||||
|
|
||||||
|
<form data-place-creation-target="form" data-action="submit->place-creation#submit">
|
||||||
|
<input type="hidden" name="latitude" data-place-creation-target="latitudeInput">
|
||||||
|
<input type="hidden" name="longitude" data-place-creation-target="longitudeInput">
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Nearby Places Suggestions</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="loading loading-spinner loading-sm absolute top-2 right-2 hidden" data-place-creation-target="loadingSpinner"></div>
|
||||||
|
<div class="space-y-2 max-h-48 overflow-y-auto" data-place-creation-target="nearbyList">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Place Name *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="Enter place name..."
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
data-place-creation-target="nameInput"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Tags</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2" data-place-creation-target="tagCheckboxes">
|
||||||
|
<% current_user.tags.ordered.each do |tag| %>
|
||||||
|
<label class="cursor-pointer">
|
||||||
|
<input type="checkbox" name="tag_ids[]" value="<%= tag.id %>" class="checkbox checkbox-sm hidden peer">
|
||||||
|
<span class="badge badge-lg peer-checked:badge-primary" style="background-color: <%= tag.color %>">
|
||||||
|
<%= tag.icon %> <%= tag.name %>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Click tags to select them for this place</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost" data-action="click->place-creation#close">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Place</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" data-action="click->place-creation#close"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -5,7 +5,7 @@ FactoryBot.define do
|
||||||
sequence(:name) { |n| "Place #{n}" }
|
sequence(:name) { |n| "Place #{n}" }
|
||||||
latitude { 54.2905245 }
|
latitude { 54.2905245 }
|
||||||
longitude { 13.0948638 }
|
longitude { 13.0948638 }
|
||||||
lonlat { "SRID=4326;POINT(#{longitude} #{latitude})" }
|
# lonlat is auto-generated by before_validation callback in Place model
|
||||||
association :user
|
association :user
|
||||||
|
|
||||||
trait :with_geodata do
|
trait :with_geodata do
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,12 @@ RSpec.describe Tag, type: :model do
|
||||||
it { is_expected.to have_many(:places).through(:taggings) }
|
it { is_expected.to have_many(:places).through(:taggings) }
|
||||||
|
|
||||||
it { is_expected.to validate_presence_of(:name) }
|
it { is_expected.to validate_presence_of(:name) }
|
||||||
it { is_expected.to validate_presence_of(:user) }
|
|
||||||
|
|
||||||
describe 'validations' do
|
describe 'validations' do
|
||||||
subject { create(:tag) }
|
subject { create(:tag) }
|
||||||
|
|
||||||
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:user_id) }
|
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:user_id) }
|
||||||
|
|
||||||
it 'validates hex color' do
|
it 'validates hex color' do
|
||||||
expect(build(:tag, color: '#FF5733')).to be_valid
|
expect(build(:tag, color: '#FF5733')).to be_valid
|
||||||
expect(build(:tag, color: 'invalid')).not_to be_valid
|
expect(build(:tag, color: 'invalid')).not_to be_valid
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ RSpec.describe 'Api::V1::Places', type: :request do
|
||||||
let(:user) { create(:user) }
|
let(:user) { create(:user) }
|
||||||
let!(:place) { create(:place, user: user, name: 'Home', latitude: 40.7128, longitude: -74.0060) }
|
let!(:place) { create(:place, user: user, name: 'Home', latitude: 40.7128, longitude: -74.0060) }
|
||||||
let!(:tag) { create(:tag, user: user, name: 'Favorite') }
|
let!(:tag) { create(:tag, user: user, name: 'Favorite') }
|
||||||
let(:headers) { { 'APIKEY' => user.api_key } }
|
let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
|
||||||
|
|
||||||
describe 'GET /api/v1/places' do
|
describe 'GET /api/v1/places' do
|
||||||
it 'returns user places' do
|
it 'returns user places' do
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Api::PlaceSerializer do
|
|
||||||
describe '#call' do
|
|
||||||
let(:place) do
|
|
||||||
create(
|
|
||||||
:place,
|
|
||||||
:with_geodata,
|
|
||||||
name: 'Central Park',
|
|
||||||
longitude: -73.9665,
|
|
||||||
latitude: 40.7812,
|
|
||||||
lonlat: 'SRID=4326;POINT(-73.9665 40.7812)',
|
|
||||||
city: 'New York',
|
|
||||||
country: 'United States',
|
|
||||||
source: 'photon',
|
|
||||||
geodata: { 'amenity' => 'park', 'leisure' => 'park' }, reverse_geocoded_at: Time.zone.parse('2023-01-15T12:00:00Z')
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
subject(:serializer) { described_class.new(place) }
|
|
||||||
|
|
||||||
it 'serializes a place into a hash with all attributes' do
|
|
||||||
result = serializer.call
|
|
||||||
|
|
||||||
expect(result).to be_a(Hash)
|
|
||||||
expect(result[:id]).to eq(place.id)
|
|
||||||
expect(result[:name]).to eq('Central Park')
|
|
||||||
expect(result[:longitude]).to eq(-73.9665)
|
|
||||||
expect(result[:latitude]).to eq(40.7812)
|
|
||||||
expect(result[:city]).to eq('New York')
|
|
||||||
expect(result[:country]).to eq('United States')
|
|
||||||
expect(result[:source]).to eq('photon')
|
|
||||||
expect(result[:geodata]).to eq({ 'amenity' => 'park', 'leisure' => 'park' })
|
|
||||||
expect(result[:reverse_geocoded_at]).to eq(Time.zone.parse('2023-01-15T12:00:00Z'))
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with nil values' do
|
|
||||||
let(:place_with_nils) do
|
|
||||||
create(
|
|
||||||
:place,
|
|
||||||
name: 'Unknown Place',
|
|
||||||
city: nil,
|
|
||||||
country: nil,
|
|
||||||
source: nil,
|
|
||||||
geodata: {},
|
|
||||||
reverse_geocoded_at: nil
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
subject(:serializer_with_nils) { described_class.new(place_with_nils) }
|
|
||||||
|
|
||||||
it 'handles nil values correctly' do
|
|
||||||
result = serializer_with_nils.call
|
|
||||||
|
|
||||||
expect(result[:id]).to eq(place_with_nils.id)
|
|
||||||
expect(result[:name]).to eq('Unknown Place')
|
|
||||||
expect(result[:city]).to be_nil
|
|
||||||
expect(result[:country]).to be_nil
|
|
||||||
expect(result[:source]).to be_nil
|
|
||||||
expect(result[:geodata]).to eq({})
|
|
||||||
expect(result[:reverse_geocoded_at]).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
330
spec/swagger/api/v1/places_controller_spec.rb
Normal file
330
spec/swagger/api/v1/places_controller_spec.rb
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Places API', type: :request do
|
||||||
|
path '/api/v1/places' do
|
||||||
|
get 'Retrieves all places for the authenticated user' do
|
||||||
|
tags 'Places'
|
||||||
|
produces 'application/json'
|
||||||
|
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||||
|
parameter name: :tag_ids, in: :query, type: :array, items: { type: :integer }, required: false, description: 'Filter places by tag IDs'
|
||||||
|
|
||||||
|
response '200', 'places found' do
|
||||||
|
schema type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
name: { type: :string },
|
||||||
|
latitude: { type: :number, format: :float },
|
||||||
|
longitude: { type: :number, format: :float },
|
||||||
|
source: { type: :string },
|
||||||
|
icon: { type: :string, nullable: true },
|
||||||
|
color: { type: :string, nullable: true },
|
||||||
|
visits_count: { type: :integer },
|
||||||
|
created_at: { type: :string, format: 'date-time' },
|
||||||
|
tags: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
name: { type: :string },
|
||||||
|
icon: { type: :string },
|
||||||
|
color: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[id name latitude longitude]
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let!(:place) { create(:place, user: user) }
|
||||||
|
|
||||||
|
run_test! do |response|
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
expect(data).to be_an(Array)
|
||||||
|
expect(data.first['id']).to eq(place.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response '401', 'unauthorized' do
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post 'Creates a place' do
|
||||||
|
tags 'Places'
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||||
|
parameter name: :place, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
latitude: { type: :number, format: :float },
|
||||||
|
longitude: { type: :number, format: :float },
|
||||||
|
source: { type: :string },
|
||||||
|
tag_ids: { type: :array, items: { type: :integer } }
|
||||||
|
},
|
||||||
|
required: %w[name latitude longitude]
|
||||||
|
}
|
||||||
|
|
||||||
|
response '201', 'place created' do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
name: { type: :string },
|
||||||
|
latitude: { type: :number, format: :float },
|
||||||
|
longitude: { type: :number, format: :float },
|
||||||
|
source: { type: :string },
|
||||||
|
icon: { type: :string, nullable: true },
|
||||||
|
color: { type: :string, nullable: true },
|
||||||
|
visits_count: { type: :integer },
|
||||||
|
created_at: { type: :string, format: 'date-time' },
|
||||||
|
tags: { type: :array }
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:tag) { create(:tag, user: user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:place) do
|
||||||
|
{
|
||||||
|
name: 'Coffee Shop',
|
||||||
|
latitude: 40.7589,
|
||||||
|
longitude: -73.9851,
|
||||||
|
source: 'manual',
|
||||||
|
tag_ids: [tag.id]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
run_test! do |response|
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
expect(data['name']).to eq('Coffee Shop')
|
||||||
|
expect(data['tags']).not_to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response '422', 'invalid request' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:place) { { name: '' } }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response '401', 'unauthorized' do
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:place) { { name: 'Test', latitude: 40.0, longitude: -73.0 } }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/places/nearby' do
|
||||||
|
get 'Searches for nearby places using Photon geocoding API' do
|
||||||
|
tags 'Places'
|
||||||
|
produces 'application/json'
|
||||||
|
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||||
|
parameter name: :latitude, in: :query, type: :number, format: :float, required: true, description: 'Latitude coordinate'
|
||||||
|
parameter name: :longitude, in: :query, type: :number, format: :float, required: true, description: 'Longitude coordinate'
|
||||||
|
parameter name: :radius, in: :query, type: :number, format: :float, required: false, description: 'Search radius in kilometers (default: 0.5)'
|
||||||
|
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Maximum number of results (default: 10)'
|
||||||
|
|
||||||
|
response '200', 'nearby places found' do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
places: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
latitude: { type: :number, format: :float },
|
||||||
|
longitude: { type: :number, format: :float },
|
||||||
|
distance: { type: :number, format: :float },
|
||||||
|
type: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:latitude) { 40.7589 }
|
||||||
|
let(:longitude) { -73.9851 }
|
||||||
|
let(:radius) { 1.0 }
|
||||||
|
let(:limit) { 5 }
|
||||||
|
|
||||||
|
run_test! do |response|
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
expect(data).to have_key('places')
|
||||||
|
expect(data['places']).to be_an(Array)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response '401', 'unauthorized' do
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:latitude) { 40.7589 }
|
||||||
|
let(:longitude) { -73.9851 }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/places/{id}' do
|
||||||
|
parameter name: :id, in: :path, type: :integer, description: 'Place ID'
|
||||||
|
|
||||||
|
get 'Retrieves a specific place' do
|
||||||
|
tags 'Places'
|
||||||
|
produces 'application/json'
|
||||||
|
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||||
|
|
||||||
|
response '200', 'place found' do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
name: { type: :string },
|
||||||
|
latitude: { type: :number, format: :float },
|
||||||
|
longitude: { type: :number, format: :float },
|
||||||
|
source: { type: :string },
|
||||||
|
icon: { type: :string, nullable: true },
|
||||||
|
color: { type: :string, nullable: true },
|
||||||
|
visits_count: { type: :integer },
|
||||||
|
created_at: { type: :string, format: 'date-time' },
|
||||||
|
tags: { type: :array }
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:place) { create(:place, user: user) }
|
||||||
|
let(:id) { place.id }
|
||||||
|
|
||||||
|
run_test! do |response|
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
expect(data['id']).to eq(place.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response '404', 'place not found' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:id) { 'invalid' }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response '401', 'unauthorized' do
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:place) { create(:place) }
|
||||||
|
let(:id) { place.id }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
patch 'Updates a place' do
|
||||||
|
tags 'Places'
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||||
|
parameter name: :place, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
latitude: { type: :number, format: :float },
|
||||||
|
longitude: { type: :number, format: :float },
|
||||||
|
tag_ids: { type: :array, items: { type: :integer } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response '200', 'place updated' do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
name: { type: :string },
|
||||||
|
latitude: { type: :number, format: :float },
|
||||||
|
longitude: { type: :number, format: :float },
|
||||||
|
tags: { type: :array }
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:existing_place) { create(:place, user: user) }
|
||||||
|
let(:id) { existing_place.id }
|
||||||
|
let(:place) { { name: 'Updated Name' } }
|
||||||
|
|
||||||
|
run_test! do |response|
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
expect(data['name']).to eq('Updated Name')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response '404', 'place not found' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:id) { 'invalid' }
|
||||||
|
let(:place) { { name: 'Updated' } }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response '422', 'invalid request' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:existing_place) { create(:place, user: user) }
|
||||||
|
let(:id) { existing_place.id }
|
||||||
|
let(:place) { { name: '' } }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response '401', 'unauthorized' do
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:existing_place) { create(:place) }
|
||||||
|
let(:id) { existing_place.id }
|
||||||
|
let(:place) { { name: 'Updated' } }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
delete 'Deletes a place' do
|
||||||
|
tags 'Places'
|
||||||
|
produces 'application/json'
|
||||||
|
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||||
|
|
||||||
|
response '204', 'place deleted' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:place) { create(:place, user: user) }
|
||||||
|
let(:id) { place.id }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response '404', 'place not found' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:api_key) { user.api_key }
|
||||||
|
let(:id) { 'invalid' }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response '401', 'unauthorized' do
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:place) { create(:place) }
|
||||||
|
let(:id) { place.id }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -686,6 +686,347 @@ paths:
|
||||||
- photoprism
|
- photoprism
|
||||||
'404':
|
'404':
|
||||||
description: photo not found
|
description: photo not found
|
||||||
|
"/api/v1/places":
|
||||||
|
get:
|
||||||
|
summary: Retrieves all places for the authenticated user
|
||||||
|
tags:
|
||||||
|
- Places
|
||||||
|
parameters:
|
||||||
|
- name: api_key
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: API key for authentication
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: tag_ids
|
||||||
|
in: query
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
required: false
|
||||||
|
description: Filter places by tag IDs
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: places found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
icon:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
visits_count:
|
||||||
|
type: integer
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
icon:
|
||||||
|
type: string
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- latitude
|
||||||
|
- longitude
|
||||||
|
'401':
|
||||||
|
description: unauthorized
|
||||||
|
post:
|
||||||
|
summary: Creates a place
|
||||||
|
tags:
|
||||||
|
- Places
|
||||||
|
parameters:
|
||||||
|
- name: api_key
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: API key for authentication
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: place created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
icon:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
visits_count:
|
||||||
|
type: integer
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
'422':
|
||||||
|
description: invalid request
|
||||||
|
'401':
|
||||||
|
description: unauthorized
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
tag_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- latitude
|
||||||
|
- longitude
|
||||||
|
"/api/v1/places/nearby":
|
||||||
|
get:
|
||||||
|
summary: Searches for nearby places using Photon geocoding API
|
||||||
|
tags:
|
||||||
|
- Places
|
||||||
|
parameters:
|
||||||
|
- name: api_key
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: API key for authentication
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: latitude
|
||||||
|
in: query
|
||||||
|
format: float
|
||||||
|
required: true
|
||||||
|
description: Latitude coordinate
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
- name: longitude
|
||||||
|
in: query
|
||||||
|
format: float
|
||||||
|
required: true
|
||||||
|
description: Longitude coordinate
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
- name: radius
|
||||||
|
in: query
|
||||||
|
format: float
|
||||||
|
required: false
|
||||||
|
description: 'Search radius in kilometers (default: 0.5)'
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: 'Maximum number of results (default: 10)'
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: nearby places found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
places:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
distance:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
'401':
|
||||||
|
description: unauthorized
|
||||||
|
"/api/v1/places/{id}":
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
description: Place ID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
get:
|
||||||
|
summary: Retrieves a specific place
|
||||||
|
tags:
|
||||||
|
- Places
|
||||||
|
parameters:
|
||||||
|
- name: api_key
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: API key for authentication
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: place found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
icon:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
visits_count:
|
||||||
|
type: integer
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
'404':
|
||||||
|
description: place not found
|
||||||
|
'401':
|
||||||
|
description: unauthorized
|
||||||
|
patch:
|
||||||
|
summary: Updates a place
|
||||||
|
tags:
|
||||||
|
- Places
|
||||||
|
parameters:
|
||||||
|
- name: api_key
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: API key for authentication
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: place updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
'404':
|
||||||
|
description: place not found
|
||||||
|
'422':
|
||||||
|
description: invalid request
|
||||||
|
'401':
|
||||||
|
description: unauthorized
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
tag_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
delete:
|
||||||
|
summary: Deletes a place
|
||||||
|
tags:
|
||||||
|
- Places
|
||||||
|
parameters:
|
||||||
|
- name: api_key
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: API key for authentication
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: place deleted
|
||||||
|
'404':
|
||||||
|
description: place not found
|
||||||
|
'401':
|
||||||
|
description: unauthorized
|
||||||
"/api/v1/points/tracked_months":
|
"/api/v1/points/tracked_months":
|
||||||
get:
|
get:
|
||||||
summary: Returns list of tracked years and months
|
summary: Returns list of tracked years and months
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue