diff --git a/app/controllers/api/v1/places_controller.rb b/app/controllers/api/v1/places_controller.rb new file mode 100644 index 00000000..2a8c1e72 --- /dev/null +++ b/app/controllers/api/v1/places_controller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Api + module V1 + class PlacesController < ApiController + before_action :set_place, only: [:show, :update, :destroy] + + def index + @places = policy_scope(Place).includes(:tags) + @places = @places.with_tags(params[:tag_ids]) if params[:tag_ids].present? + + render json: Api::PlaceSerializer.new(@places).serialize + end + + def show + authorize @place + render json: Api::PlaceSerializer.new(@place).serialize + 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 + else + render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity + end + end + + def update + authorize @place + + if @place.update(place_params) + sync_tags if params[:place][:tag_ids] + render json: Api::PlaceSerializer.new(@place).serialize + else + render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity + end + end + + 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 + + results = Places::NearbySearch.new( + latitude: params[:latitude].to_f, + longitude: params[:longitude].to_f, + radius: params[:radius]&.to_f || 0.5, + limit: params[:limit]&.to_i || 10 + ).call + + render json: { places: results } + end + + private + + def set_place + @place = current_api_user.places.find(params[:id]) + end + + def place_params + params.require(:place).permit(:name, :latitude, :longitude, :source) + end + + def tag_ids + params.dig(:place, :tag_ids) || [] + end + + def add_tags + return if tag_ids.empty? + + tags = current_api_user.tags.where(id: tag_ids) + @place.tags << tags + end + + def sync_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 + end + end +end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 00000000..9a862a1a --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class TagsController < ApplicationController + before_action :authenticate_user! + before_action :set_tag, only: [:edit, :update, :destroy] + + 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 + redirect_to tags_path, notice: 'Tag was successfully created.' + else + render :new, status: :unprocessable_entity + end + end + + def edit + authorize @tag + end + + def update + authorize @tag + + if @tag.update(tag_params) + redirect_to tags_path, notice: 'Tag was successfully updated.' + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + authorize @tag + @tag.destroy! + redirect_to tags_path, notice: 'Tag was successfully deleted.', status: :see_other + end + + private + + def set_tag + @tag = current_user.tags.find(params[:id]) + end + + def tag_params + params.require(:tag).permit(:name, :icon, :color) + end +end diff --git a/app/models/place.rb b/app/models/place.rb index e9fe3637..b2d7525c 100644 --- a/app/models/place.rb +++ b/app/models/place.rb @@ -12,7 +12,10 @@ class Place < ApplicationRecord has_many :place_visits, dependent: :destroy has_many :suggested_visits, -> { distinct }, through: :place_visits, source: :visit - validates :name, :lonlat, presence: true + validates :name, presence: true + validates :latitude, :longitude, presence: true + + before_validation :build_lonlat, if: -> { latitude.present? && longitude.present? } enum :source, { manual: 0, photon: 1 } @@ -42,4 +45,10 @@ class Place < ApplicationRecord def osm_type geodata.dig('properties', 'osm_type') end + + private + + def build_lonlat + self.lonlat = "POINT(#{longitude} #{latitude})" + end end diff --git a/app/policies/place_policy.rb b/app/policies/place_policy.rb new file mode 100644 index 00000000..cc04a504 --- /dev/null +++ b/app/policies/place_policy.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class PlacePolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.where(user: user) + end + end + + def index? + true + end + + def show? + owner? + end + + def create? + true + end + + def new? + create? + end + + def update? + owner? + end + + def edit? + update? + end + + def destroy? + owner? + end + + def nearby? + true + end + + private + + def owner? + record.user_id == user.id + end +end diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb new file mode 100644 index 00000000..c812772a --- /dev/null +++ b/app/policies/tag_policy.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class TagPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.where(user: user) + end + end + + def index? + true + end + + def show? + owner? + end + + def create? + true + end + + def new? + create? + end + + def update? + owner? + end + + def edit? + update? + end + + def destroy? + owner? + end + + private + + def owner? + record.user_id == user.id + end +end diff --git a/app/serializers/api/place_serializer.rb b/app/serializers/api/place_serializer.rb index ae91bbdb..db432128 100644 --- a/app/serializers/api/place_serializer.rb +++ b/app/serializers/api/place_serializer.rb @@ -1,27 +1,25 @@ # frozen_string_literal: true -class Api::PlaceSerializer - def initialize(place) - @place = place +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 - - def call - { - id: place.id, - name: place.name, - longitude: place.lon, - latitude: place.lat, - city: place.city, - country: place.country, - source: place.source, - geodata: place.geodata, - created_at: place.created_at, - updated_at: place.updated_at, - reverse_geocoded_at: place.reverse_geocoded_at - } - end - - private - - attr_reader :place end diff --git a/app/services/places/nearby_search.rb b/app/services/places/nearby_search.rb new file mode 100644 index 00000000..ef865797 --- /dev/null +++ b/app/services/places/nearby_search.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Places + class NearbySearch + RADIUS_KM = 0.5 + MAX_RESULTS = 10 + + def initialize(latitude:, longitude:, radius: RADIUS_KM, limit: MAX_RESULTS) + @latitude = latitude + @longitude = longitude + @radius = radius + @limit = limit + end + + def call + return [] unless reverse_geocoding_enabled? + + results = Geocoder.search( + [latitude, longitude], + limit: limit, + distance_sort: true, + radius: radius, + units: :km + ) + + format_results(results) + rescue StandardError => e + Rails.logger.error("Nearby places search error: #{e.message}") + [] + end + + private + + attr_reader :latitude, :longitude, :radius, :limit + + def reverse_geocoding_enabled? + DawarichSettings.reverse_geocoding_enabled? + end + + def format_results(results) + results.map do |result| + properties = result.data['properties'] || {} + coordinates = result.data.dig('geometry', 'coordinates') || [longitude, latitude] + + { + name: extract_name(result.data), + latitude: coordinates[1], + longitude: coordinates[0], + osm_id: properties['osm_id'], + osm_type: properties['osm_type'], + osm_key: properties['osm_key'], + osm_value: properties['osm_value'], + city: properties['city'], + country: properties['country'], + street: properties['street'], + housenumber: properties['housenumber'], + postcode: properties['postcode'] + } + end + end + + def extract_name(data) + properties = data['properties'] || {} + + properties['name'] || + [properties['street'], properties['housenumber']].compact.join(' ').presence || + properties['city'] || + 'Unknown Place' + end + end +end diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb new file mode 100644 index 00000000..0ecdf099 --- /dev/null +++ b/app/views/tags/_form.html.erb @@ -0,0 +1,79 @@ +<%= form_with(model: tag, class: "space-y-4") do |f| %> + <% if tag.errors.any? %> +
+
+

<%= pluralize(tag.errors.count, "error") %> prohibited this tag from being saved:

+ +
+
+ <% end %> + +
+ <%= f.label :name, class: "label" %> + <%= f.text_field :name, class: "input input-bordered w-full", placeholder: "Home, Work, Restaurant..." %> +
+ +
+ <%= f.label :icon, class: "label" %> +
+ <%= f.text_field :icon, class: "input input-bordered flex-1", placeholder: "🏠" %> + +
+ +
+ +
+ <%= f.label :color, class: "label" %> +
+ <%= f.color_field :color, class: "w-16 h-12 rounded cursor-pointer" %> + <%= f.text_field :color, class: "input input-bordered flex-1", placeholder: "#FF5733" %> +
+ +
+ +
+
+ <%= f.submit class: "btn btn-primary" %> + <%= link_to "Cancel", tags_path, class: "btn btn-ghost" %> +
+
+<% end %> + + diff --git a/app/views/tags/edit.html.erb b/app/views/tags/edit.html.erb new file mode 100644 index 00000000..bf04f3b3 --- /dev/null +++ b/app/views/tags/edit.html.erb @@ -0,0 +1,12 @@ +
+
+

Edit Tag

+

Update your tag details

+
+ +
+
+ <%= render "form", tag: @tag %> +
+
+
diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb new file mode 100644 index 00000000..535af9c6 --- /dev/null +++ b/app/views/tags/index.html.erb @@ -0,0 +1,57 @@ +
+
+

Tags

+ <%= link_to "New Tag", new_tag_path, class: "btn btn-primary" %> +
+ + <% if @tags.any? %> +
+ + + + + + + + + + + + <% @tags.each do |tag| %> + + + + + + + + <% end %> + +
IconNameColorPlaces CountActions
<%= tag.icon %><%= tag.name %> + <% if tag.color.present? %> +
+
+ <%= tag.color %> +
+ <% else %> + No color + <% end %> +
<%= tag.places.count %> +
+ <%= link_to "Edit", edit_tag_path(tag), class: "btn btn-sm btn-ghost" %> + <%= button_to "Delete", tag_path(tag), method: :delete, + data: { turbo_confirm: "Are you sure?" }, + class: "btn btn-sm btn-error" %> +
+
+
+ + <% else %> +
+
+

No tags yet. Create your first tag to organize your places!

+ <%= link_to "Create Tag", new_tag_path, class: "btn btn-sm btn-primary mt-2" %> +
+
+ <% end %> +
diff --git a/app/views/tags/new.html.erb b/app/views/tags/new.html.erb new file mode 100644 index 00000000..3c489c56 --- /dev/null +++ b/app/views/tags/new.html.erb @@ -0,0 +1,12 @@ +
+
+

New Tag

+

Create a new tag to organize your places

+
+ +
+
+ <%= render "form", tag: @tag %> +
+
+
diff --git a/config/routes.rb b/config/routes.rb index 60684cfb..7fea7016 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,7 @@ Rails.application.routes.draw do resources :places, only: %i[index destroy] resources :exports, only: %i[index create destroy] resources :trips + resources :tags, except: [:show] # Family management routes (only if feature is enabled) if DawarichSettings.family_feature_enabled? @@ -120,6 +121,11 @@ Rails.application.routes.draw do get 'users/me', to: 'users#me' resources :areas, only: %i[index create update destroy] + resources :places, only: %i[index show create update destroy] do + collection do + get 'nearby' + end + end resources :locations, only: %i[index] do collection do get 'suggestions' diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb new file mode 100644 index 00000000..ea2ef61f --- /dev/null +++ b/spec/policies/tag_policy_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TagPolicy, type: :policy do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let(:tag) { create(:tag, user: user) } + let(:other_tag) { create(:tag, user: other_user) } + + describe 'index?' do + it 'allows any authenticated user' do + expect(TagPolicy.new(user, Tag).index?).to be true + end + end + + describe 'create? and new?' do + it 'allows any authenticated user to create' do + new_tag = user.tags.build + expect(TagPolicy.new(user, new_tag).create?).to be true + expect(TagPolicy.new(user, new_tag).new?).to be true + end + end + + describe 'show?, edit?, update?, destroy?' do + context 'when user owns the tag' do + it 'allows all actions' do + policy = TagPolicy.new(user, tag) + expect(policy.show?).to be true + expect(policy.edit?).to be true + expect(policy.update?).to be true + expect(policy.destroy?).to be true + end + end + + context 'when user does not own the tag' do + it 'denies all actions' do + policy = TagPolicy.new(user, other_tag) + expect(policy.show?).to be false + expect(policy.edit?).to be false + expect(policy.update?).to be false + expect(policy.destroy?).to be false + end + end + end + + describe 'Scope' do + let!(:user_tags) { create_list(:tag, 3, user: user) } + let!(:other_tags) { create_list(:tag, 2, user: other_user) } + + it 'returns only user-owned tags' do + scope = TagPolicy::Scope.new(user, Tag).resolve + expect(scope).to match_array(user_tags) + expect(scope).not_to include(*other_tags) + end + end +end diff --git a/spec/requests/api/v1/places_spec.rb b/spec/requests/api/v1/places_spec.rb new file mode 100644 index 00000000..6214286b --- /dev/null +++ b/spec/requests/api/v1/places_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'rails_helper' + +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 } } + + describe 'GET /api/v1/places' do + it 'returns user places' do + get '/api/v1/places', headers: headers + + expect(response).to have_http_status(:success) + json = JSON.parse(response.body) + expect(json.size).to eq(1) + expect(json.first['name']).to eq('Home') + end + + it 'filters by tag_ids' do + tagged_place = create(:place, user: user) + create(:tagging, taggable: tagged_place, tag: tag) + + get '/api/v1/places', params: { tag_ids: [tag.id] }, headers: headers + + json = JSON.parse(response.body) + expect(json.size).to eq(1) + expect(json.first['id']).to eq(tagged_place.id) + end + + it 'does not return other users places' do + other_user = create(:user) + create(:place, user: other_user, name: 'Private Place') + + get '/api/v1/places', headers: headers + + json = JSON.parse(response.body) + expect(json.map { |p| p['name'] }).not_to include('Private Place') + end + end + + describe 'GET /api/v1/places/:id' do + it 'returns the place' do + get "/api/v1/places/#{place.id}", headers: headers + + expect(response).to have_http_status(:success) + json = JSON.parse(response.body) + expect(json['name']).to eq('Home') + expect(json['latitude']).to eq(40.7128) + end + + it 'returns 404 for other users place' do + other_place = create(:place, user: create(:user)) + + expect { + get "/api/v1/places/#{other_place.id}", headers: headers + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe 'POST /api/v1/places' do + let(:valid_params) do + { + place: { + name: 'Central Park', + latitude: 40.785091, + longitude: -73.968285, + source: 'manual', + tag_ids: [tag.id] + } + } + end + + it 'creates a place' do + expect { + post '/api/v1/places', params: valid_params, headers: headers + }.to change(Place, :count).by(1) + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json['name']).to eq('Central Park') + end + + it 'associates tags with the place' do + post '/api/v1/places', params: valid_params, headers: headers + + place = Place.last + expect(place.tags).to include(tag) + end + + it 'returns errors for invalid params' do + post '/api/v1/places', params: { place: { name: '' } }, headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json['errors']).to be_present + end + end + + describe 'PATCH /api/v1/places/:id' do + it 'updates the place' do + patch "/api/v1/places/#{place.id}", + params: { place: { name: 'Updated Home' } }, + headers: headers + + expect(response).to have_http_status(:success) + expect(place.reload.name).to eq('Updated Home') + end + + it 'updates tags' do + new_tag = create(:tag, user: user, name: 'Work') + + patch "/api/v1/places/#{place.id}", + params: { place: { tag_ids: [new_tag.id] } }, + headers: headers + + expect(place.reload.tags).to contain_exactly(new_tag) + end + + it 'prevents updating other users places' do + other_place = create(:place, user: create(:user)) + + expect { + patch "/api/v1/places/#{other_place.id}", + params: { place: { name: 'Hacked' } }, + headers: headers + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe 'DELETE /api/v1/places/:id' do + it 'destroys the place' do + expect { + delete "/api/v1/places/#{place.id}", headers: headers + }.to change(Place, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + + it 'prevents deleting other users places' do + other_place = create(:place, user: create(:user)) + + expect { + delete "/api/v1/places/#{other_place.id}", headers: headers + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe 'GET /api/v1/places/nearby' do + before do + allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) + end + + it 'returns nearby places from geocoder', :vcr do + get '/api/v1/places/nearby', + params: { latitude: 40.7128, longitude: -74.0060 }, + headers: headers + + expect(response).to have_http_status(:success) + json = JSON.parse(response.body) + expect(json['places']).to be_an(Array) + end + + it 'requires latitude and longitude' do + get '/api/v1/places/nearby', headers: headers + + expect(response).to have_http_status(:bad_request) + json = JSON.parse(response.body) + expect(json['error']).to include('latitude and longitude') + end + + it 'accepts custom radius and limit' do + service_double = instance_double(Places::NearbySearch) + allow(Places::NearbySearch).to receive(:new) + .with(latitude: 40.7128, longitude: -74.0060, radius: 1.0, limit: 5) + .and_return(service_double) + allow(service_double).to receive(:call).and_return([]) + + get '/api/v1/places/nearby', + params: { latitude: 40.7128, longitude: -74.0060, radius: 1.0, limit: 5 }, + headers: headers + + expect(response).to have_http_status(:success) + end + end + + describe 'authentication' do + it 'requires API key for all endpoints' do + get '/api/v1/places' + expect(response).to have_http_status(:unauthorized) + + post '/api/v1/places', params: { place: { name: 'Test' } } + expect(response).to have_http_status(:unauthorized) + end + end +end diff --git a/spec/requests/tags_spec.rb b/spec/requests/tags_spec.rb new file mode 100644 index 00000000..e267af16 --- /dev/null +++ b/spec/requests/tags_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe "Tags", type: :request do + let(:user) { create(:user) } + let(:tag) { create(:tag, user: user) } + let(:valid_attributes) { { name: 'Home', icon: '🏠', color: '#4CAF50' } } + let(:invalid_attributes) { { name: '', icon: 'X', color: 'invalid' } } + + before { sign_in user } + + describe "GET /tags" do + it "returns success" do + get tags_path + expect(response).to be_successful + end + + it "displays user's tags" do + tag1 = create(:tag, user: user, name: 'Work') + tag2 = create(:tag, user: user, name: 'Home') + + get tags_path + expect(response.body).to include('Work') + expect(response.body).to include('Home') + end + + it "does not display other users' tags" do + other_user = create(:user) + other_tag = create(:tag, user: other_user, name: 'Private') + + get tags_path + expect(response.body).not_to include('Private') + end + end + + describe "GET /tags/new" do + it "returns success" do + get new_tag_path + expect(response).to be_successful + end + end + + describe "GET /tags/:id/edit" do + it "returns success" do + get edit_tag_path(tag) + expect(response).to be_successful + end + + it "prevents editing other users' tags" do + other_tag = create(:tag, user: create(:user)) + + expect { + get edit_tag_path(other_tag) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe "POST /tags" do + context "with valid parameters" do + it "creates a new tag" do + expect { + post tags_path, params: { tag: valid_attributes } + }.to change(Tag, :count).by(1) + end + + it "redirects to tags index" do + post tags_path, params: { tag: valid_attributes } + expect(response).to redirect_to(tags_path) + end + + it "associates tag with current user" do + post tags_path, params: { tag: valid_attributes } + expect(Tag.last.user).to eq(user) + end + end + + context "with invalid parameters" do + it "does not create a new tag" do + expect { + post tags_path, params: { tag: invalid_attributes } + }.not_to change(Tag, :count) + end + + it "returns unprocessable entity status" do + post tags_path, params: { tag: invalid_attributes } + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe "PATCH /tags/:id" do + context "with valid parameters" do + let(:new_attributes) { { name: 'Updated Name', color: '#FF0000' } } + + it "updates the tag" do + patch tag_path(tag), params: { tag: new_attributes } + tag.reload + expect(tag.name).to eq('Updated Name') + expect(tag.color).to eq('#FF0000') + end + + it "redirects to tags index" do + patch tag_path(tag), params: { tag: new_attributes } + expect(response).to redirect_to(tags_path) + end + end + + context "with invalid parameters" do + it "returns unprocessable entity status" do + patch tag_path(tag), params: { tag: invalid_attributes } + expect(response).to have_http_status(:unprocessable_entity) + end + end + + it "prevents updating other users' tags" do + other_tag = create(:tag, user: create(:user)) + + expect { + patch tag_path(other_tag), params: { tag: { name: 'Hacked' } } + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe "DELETE /tags/:id" do + it "destroys the tag" do + tag_to_delete = create(:tag, user: user) + + expect { + delete tag_path(tag_to_delete) + }.to change(Tag, :count).by(-1) + end + + it "redirects to tags index" do + delete tag_path(tag) + expect(response).to redirect_to(tags_path) + end + + it "prevents deleting other users' tags" do + other_tag = create(:tag, user: create(:user)) + + expect { + delete tag_path(other_tag) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context "when not authenticated" do + before { sign_out user } + + it "redirects to sign in for index" do + get tags_path + expect(response).to redirect_to(new_user_session_path) + end + + it "redirects to sign in for new" do + get new_tag_path + expect(response).to redirect_to(new_user_session_path) + end + + it "redirects to sign in for create" do + post tags_path, params: { tag: valid_attributes } + expect(response).to redirect_to(new_user_session_path) + end + end +end