Add some changes related to places management feature

This commit is contained in:
Eugene Burmakin 2025-11-16 17:50:24 +01:00
parent 78851c5f98
commit e1f16c98a2
14 changed files with 1170 additions and 112 deletions

View file

@ -6,24 +6,26 @@ module Api
before_action :set_place, only: [:show, :update, :destroy]
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?
render json: Api::PlaceSerializer.new(@places).serialize
render json: @places.map { |place| serialize_place(place) }
end
def show
authorize @place
render json: Api::PlaceSerializer.new(@place).serialize
render json: serialize_place(@place)
end
def create
@place = current_api_user.places.build(place_params)
authorize @place
if @place.save
add_tags if tag_ids.present?
render json: Api::PlaceSerializer.new(@place).serialize, status: :created
render json: serialize_place(@place), status: :created
else
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
end
@ -33,8 +35,8 @@ module Api
authorize @place
if @place.update(place_params)
sync_tags if params[:place][:tag_ids]
render json: Api::PlaceSerializer.new(@place).serialize
set_tags if params[:place][:tag_ids]
render json: serialize_place(@place)
else
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
end
@ -42,13 +44,15 @@ module Api
def destroy
authorize @place
@place.destroy!
head :no_content
end
def nearby
authorize Place, :nearby?
unless params[:latitude].present? && params[:longitude].present?
return render json: { error: 'latitude and longitude are required' }, status: :bad_request
end
@ -84,11 +88,33 @@ module Api
@place.tags << tags
end
def sync_tags
def set_tags
tag_ids_param = Array(params.dig(:place, :tag_ids)).compact
tags = current_api_user.tags.where(id: tag_ids_param)
@place.tags = tags
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

View file

@ -6,16 +6,19 @@ class TagsController < ApplicationController
def index
@tags = policy_scope(Tag).ordered
authorize Tag
end
def new
@tag = current_user.tags.build
authorize @tag
end
def create
@tag = current_user.tags.build(tag_params)
authorize @tag
if @tag.save
@ -41,7 +44,9 @@ class TagsController < ApplicationController
def destroy
authorize @tag
@tag.destroy!
redirect_to tags_path, notice: 'Tag was successfully deleted.', status: :see_other
end

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

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

View file

@ -13,22 +13,18 @@ module Taggable
}
end
# Add a tag to this taggable record
def add_tag(tag)
tags << tag unless tags.include?(tag)
end
# Remove a tag from this taggable record
def remove_tag(tag)
tags.delete(tag)
end
# Get all tag names for this taggable record
def tag_names
tags.pluck(:name)
end
# Check if tagged with specific tag
def tagged_with?(tag)
tags.include?(tag)
end

View file

@ -6,7 +6,6 @@ class Tag < ApplicationRecord
has_many :places, through: :taggings, source: :taggable, source_type: 'Place'
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 }
scope :for_user, ->(user) { where(user: user) }

View file

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

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

View file

@ -5,7 +5,7 @@ FactoryBot.define do
sequence(:name) { |n| "Place #{n}" }
latitude { 54.2905245 }
longitude { 13.0948638 }
lonlat { "SRID=4326;POINT(#{longitude} #{latitude})" }
# lonlat is auto-generated by before_validation callback in Place model
association :user
trait :with_geodata do

View file

@ -8,13 +8,12 @@ RSpec.describe Tag, type: :model do
it { is_expected.to have_many(:places).through(:taggings) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:user) }
describe 'validations' do
subject { create(:tag) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:user_id) }
it 'validates hex color' do
expect(build(:tag, color: '#FF5733')).to be_valid
expect(build(:tag, color: 'invalid')).not_to be_valid

View file

@ -6,7 +6,7 @@ RSpec.describe 'Api::V1::Places', type: :request do
let(:user) { create(:user) }
let!(:place) { create(:place, user: user, name: 'Home', latitude: 40.7128, longitude: -74.0060) }
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
it 'returns user places' do

View file

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

View 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

View file

@ -686,6 +686,347 @@ paths:
- photoprism
'404':
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":
get:
summary: Returns list of tracked years and months