Add places management API and tags feature

This commit is contained in:
Eugene Burmakin 2025-11-16 17:28:40 +01:00
parent 69c8779164
commit 78851c5f98
15 changed files with 929 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,79 @@
<%= form_with(model: tag, class: "space-y-4") do |f| %>
<% if tag.errors.any? %>
<div class="alert alert-error">
<div>
<h3 class="font-bold"><%= pluralize(tag.errors.count, "error") %> prohibited this tag from being saved:</h3>
<ul class="list-disc list-inside">
<% tag.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
<% end %>
<div class="form-control">
<%= f.label :name, class: "label" %>
<%= f.text_field :name, class: "input input-bordered w-full", placeholder: "Home, Work, Restaurant..." %>
</div>
<div class="form-control">
<%= f.label :icon, class: "label" %>
<div class="flex gap-2">
<%= f.text_field :icon, class: "input input-bordered flex-1", placeholder: "🏠" %>
<div class="dropdown dropdown-end">
<button type="button" tabindex="0" class="btn btn-outline">
Pick Icon
</button>
<div tabindex="0" class="dropdown-content card card-compact w-72 p-2 shadow bg-base-100 z-10">
<div class="card-body">
<div class="grid grid-cols-6 gap-2" data-action="click->icon-picker#select">
<% %w[📍 🏠 🏢 🍴 ☕ 🏨 🎭 🏛️ 🌳 ⛰️ 🏖️ 🎪 🏪 🏬 🏭 🏯 🏰 🗼 🗽 ⛪ 🕌 🛕 🕍 ⛩️].each do |emoji| %>
<button type="button" class="btn btn-sm text-2xl hover:bg-base-200" data-icon="<%= emoji %>">
<%= emoji %>
</button>
<% end %>
</div>
</div>
</div>
</div>
</div>
<label class="label">
<span class="label-text-alt">Click an emoji or paste any emoji</span>
</label>
</div>
<div class="form-control">
<%= f.label :color, class: "label" %>
<div class="flex gap-2 items-center">
<%= f.color_field :color, class: "w-16 h-12 rounded cursor-pointer" %>
<%= f.text_field :color, class: "input input-bordered flex-1", placeholder: "#FF5733" %>
</div>
<label class="label">
<span class="label-text-alt">Optional - Choose a color for this tag</span>
</label>
</div>
<div class="form-control mt-6">
<div class="flex gap-2">
<%= f.submit class: "btn btn-primary" %>
<%= link_to "Cancel", tags_path, class: "btn btn-ghost" %>
</div>
</div>
<% end %>
<script>
document.addEventListener('DOMContentLoaded', () => {
const iconButtons = document.querySelectorAll('[data-icon]');
const iconInput = document.querySelector('input[name="tag[icon]"]');
iconButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
if (iconInput) {
iconInput.value = button.dataset.icon;
}
});
});
});
</script>

View file

@ -0,0 +1,12 @@
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="mb-6">
<h1 class="text-3xl font-bold">Edit Tag</h1>
<p class="text-gray-600 mt-2">Update your tag details</p>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<%= render "form", tag: @tag %>
</div>
</div>
</div>

View file

@ -0,0 +1,57 @@
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Tags</h1>
<%= link_to "New Tag", new_tag_path, class: "btn btn-primary" %>
</div>
<% if @tags.any? %>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Icon</th>
<th>Name</th>
<th>Color</th>
<th>Places Count</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<% @tags.each do |tag| %>
<tr>
<td class="text-2xl"><%= tag.icon %></td>
<td class="font-semibold"><%= tag.name %></td>
<td>
<% if tag.color.present? %>
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded" style="background-color: <%= tag.color %>;"></div>
<span class="text-sm"><%= tag.color %></span>
</div>
<% else %>
<span class="text-gray-400">No color</span>
<% end %>
</td>
<td><%= tag.places.count %></td>
<td class="text-right">
<div class="flex gap-2 justify-end">
<%= 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" %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="alert alert-info">
<div>
<p>No tags yet. Create your first tag to organize your places!</p>
<%= link_to "Create Tag", new_tag_path, class: "btn btn-sm btn-primary mt-2" %>
</div>
</div>
<% end %>
</div>

View file

@ -0,0 +1,12 @@
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="mb-6">
<h1 class="text-3xl font-bold">New Tag</h1>
<p class="text-gray-600 mt-2">Create a new tag to organize your places</p>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<%= render "form", tag: @tag %>
</div>
</div>
</div>

View file

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

View file

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

View file

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

166
spec/requests/tags_spec.rb Normal file
View file

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