From e1f16c98a20621930b12ed2ba7aec81fc3970eef Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 16 Nov 2025 17:50:24 +0100 Subject: [PATCH] Add some changes related to places management feature --- app/controllers/api/v1/places_controller.rb | 44 ++- app/controllers/tags_controller.rb | 5 + .../controllers/place_creation_controller.js | 150 ++++++++ app/javascript/maps/places.js | 239 ++++++++++++ app/models/concerns/taggable.rb | 4 - app/models/tag.rb | 1 - app/serializers/api/place_serializer.rb | 25 -- .../shared/_place_creation_modal.html.erb | 65 ++++ spec/factories/places.rb | 2 +- spec/models/tag_spec.rb | 7 +- spec/requests/api/v1/places_spec.rb | 2 +- spec/serializers/api/place_serializer_spec.rb | 67 ---- spec/swagger/api/v1/places_controller_spec.rb | 330 +++++++++++++++++ swagger/v1/swagger.yaml | 341 ++++++++++++++++++ 14 files changed, 1170 insertions(+), 112 deletions(-) create mode 100644 app/javascript/controllers/place_creation_controller.js create mode 100644 app/javascript/maps/places.js delete mode 100644 app/serializers/api/place_serializer.rb create mode 100644 app/views/shared/_place_creation_modal.html.erb delete mode 100644 spec/serializers/api/place_serializer_spec.rb create mode 100644 spec/swagger/api/v1/places_controller_spec.rb diff --git a/app/controllers/api/v1/places_controller.rb b/app/controllers/api/v1/places_controller.rb index 2a8c1e72..0c667b8c 100644 --- a/app/controllers/api/v1/places_controller.rb +++ b/app/controllers/api/v1/places_controller.rb @@ -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 diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 9a862a1a..e3e20fd3 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -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 diff --git a/app/javascript/controllers/place_creation_controller.js b/app/javascript/controllers/place_creation_controller.js new file mode 100644 index 00000000..514a7141 --- /dev/null +++ b/app/javascript/controllers/place_creation_controller.js @@ -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 = '

Failed to load suggestions

' + } finally { + this.loadingSpinnerTarget.classList.add('hidden') + } + } + + renderNearbyPlaces(places) { + if (!places || places.length === 0) { + this.nearbyListTarget.innerHTML = '

No nearby places found

' + return + } + + const html = places.map(place => ` +
+
+

${this.escapeHtml(place.name)}

+ ${place.street ? `

${this.escapeHtml(place.street)}

` : ''} + ${place.city ? `

${this.escapeHtml(place.city)}, ${this.escapeHtml(place.country || '')}

` : ''} +
+
+ `).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 + } +} diff --git a/app/javascript/maps/places.js b/app/javascript/maps/places.js new file mode 100644 index 00000000..b1e15e5b --- /dev/null +++ b/app/javascript/maps/places.js @@ -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 = ` +
+ ${emoji} +
+ `; + + 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 => + ` + ${tag.icon} ${tag.name} + ` + ).join(' '); + + return ` +
+

${place.name}

+ ${tags ? `
${tags}
` : ''} + ${place.visits_count ? `

Visits: ${place.visits_count}

` : ''} +
+ +
+
+ `; + } + + 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); + } +} diff --git a/app/models/concerns/taggable.rb b/app/models/concerns/taggable.rb index 1be2d2f9..6349cee6 100644 --- a/app/models/concerns/taggable.rb +++ b/app/models/concerns/taggable.rb @@ -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 diff --git a/app/models/tag.rb b/app/models/tag.rb index 4225a5b2..09b76240 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -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) } diff --git a/app/serializers/api/place_serializer.rb b/app/serializers/api/place_serializer.rb deleted file mode 100644 index db432128..00000000 --- a/app/serializers/api/place_serializer.rb +++ /dev/null @@ -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 diff --git a/app/views/shared/_place_creation_modal.html.erb b/app/views/shared/_place_creation_modal.html.erb new file mode 100644 index 00000000..0acf52c1 --- /dev/null +++ b/app/views/shared/_place_creation_modal.html.erb @@ -0,0 +1,65 @@ +
+ +
diff --git a/spec/factories/places.rb b/spec/factories/places.rb index f99c14e3..19263b8e 100644 --- a/spec/factories/places.rb +++ b/spec/factories/places.rb @@ -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 diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 2b2383ec..640a4ab1 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -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 diff --git a/spec/requests/api/v1/places_spec.rb b/spec/requests/api/v1/places_spec.rb index 6214286b..01e4c031 100644 --- a/spec/requests/api/v1/places_spec.rb +++ b/spec/requests/api/v1/places_spec.rb @@ -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 diff --git a/spec/serializers/api/place_serializer_spec.rb b/spec/serializers/api/place_serializer_spec.rb deleted file mode 100644 index d1703575..00000000 --- a/spec/serializers/api/place_serializer_spec.rb +++ /dev/null @@ -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 diff --git a/spec/swagger/api/v1/places_controller_spec.rb b/spec/swagger/api/v1/places_controller_spec.rb new file mode 100644 index 00000000..636771aa --- /dev/null +++ b/spec/swagger/api/v1/places_controller_spec.rb @@ -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 diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 86d72768..a59dffbf 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -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