diff --git a/app/models/concerns/taggable.rb b/app/models/concerns/taggable.rb new file mode 100644 index 00000000..1be2d2f9 --- /dev/null +++ b/app/models/concerns/taggable.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Taggable + extend ActiveSupport::Concern + + included do + has_many :taggings, as: :taggable, dependent: :destroy + has_many :tags, through: :taggings + + scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct } + scope :tagged_with, ->(tag_name, user) { + joins(:tags).where(tags: { name: tag_name, user: user }).distinct + } + 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 +end diff --git a/app/models/place.rb b/app/models/place.rb index 96f6a874..e9fe3637 100644 --- a/app/models/place.rb +++ b/app/models/place.rb @@ -3,17 +3,22 @@ class Place < ApplicationRecord include Nearable include Distanceable + include Taggable DEFAULT_NAME = 'Suggested place' - validates :name, :lonlat, presence: true - + belongs_to :user, optional: true # Optional during migration period has_many :visits, dependent: :destroy has_many :place_visits, dependent: :destroy has_many :suggested_visits, -> { distinct }, through: :place_visits, source: :visit + validates :name, :lonlat, presence: true + enum :source, { manual: 0, photon: 1 } + scope :for_user, ->(user) { where(user: user) } + scope :ordered, -> { order(:name) } + def lon lonlat.x end diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 00000000..4225a5b2 --- /dev/null +++ b/app/models/tag.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Tag < ApplicationRecord + belongs_to :user + has_many :taggings, dependent: :destroy + 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) } + scope :ordered, -> { order(:name) } +end diff --git a/app/models/tagging.rb b/app/models/tagging.rb new file mode 100644 index 00000000..5248752c --- /dev/null +++ b/app/models/tagging.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Tagging < ApplicationRecord + belongs_to :taggable, polymorphic: true + belongs_to :tag + + validates :taggable, presence: true + validates :tag, presence: true + validates :tag_id, uniqueness: { scope: [:taggable_type, :taggable_id] } +end diff --git a/app/models/user.rb b/app/models/user.rb index d328cb20..7063ee69 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,7 +15,9 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength has_many :notifications, dependent: :destroy has_many :areas, dependent: :destroy has_many :visits, dependent: :destroy - has_many :places, through: :visits + has_many :visited_places, through: :visits, source: :place + has_many :places, dependent: :destroy + has_many :tags, dependent: :destroy has_many :trips, dependent: :destroy has_many :tracks, dependent: :destroy diff --git a/db/migrate/20251116134506_add_user_id_to_places.rb b/db/migrate/20251116134506_add_user_id_to_places.rb new file mode 100644 index 00000000..aec620d8 --- /dev/null +++ b/db/migrate/20251116134506_add_user_id_to_places.rb @@ -0,0 +1,6 @@ +class AddUserIdToPlaces < ActiveRecord::Migration[8.0] + def change + # Add nullable for backward compatibility, will enforce later via data migration + add_reference :places, :user, null: true, foreign_key: true, index: true + end +end diff --git a/db/migrate/20251116134514_create_tags.rb b/db/migrate/20251116134514_create_tags.rb new file mode 100644 index 00000000..f54d3f83 --- /dev/null +++ b/db/migrate/20251116134514_create_tags.rb @@ -0,0 +1,14 @@ +class CreateTags < ActiveRecord::Migration[8.0] + def change + create_table :tags do |t| + t.string :name, null: false + t.string :icon + t.string :color + t.references :user, null: false, foreign_key: true, index: true + + t.timestamps + end + + add_index :tags, [:user_id, :name], unique: true + end +end diff --git a/db/migrate/20251116134520_create_taggings.rb b/db/migrate/20251116134520_create_taggings.rb new file mode 100644 index 00000000..c8cc51bd --- /dev/null +++ b/db/migrate/20251116134520_create_taggings.rb @@ -0,0 +1,12 @@ +class CreateTaggings < ActiveRecord::Migration[8.0] + def change + create_table :taggings do |t| + t.references :taggable, polymorphic: true, null: false, index: true + t.references :tag, null: false, foreign_key: true, index: true + + t.timestamps + end + + add_index :taggings, [:taggable_type, :taggable_id, :tag_id], unique: true, name: 'index_taggings_on_taggable_and_tag' + end +end diff --git a/db/schema.rb b/db/schema.rb index 99e437d8..c0fdcd36 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do +ActiveRecord::Schema[8.0].define(version: 2025_11_16_134520) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -180,8 +180,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true} + t.bigint "user_id" t.index "(((geodata -> 'properties'::text) ->> 'osm_id'::text))", name: "index_places_on_geodata_osm_id" t.index ["lonlat"], name: "index_places_on_lonlat", using: :gist + t.index ["user_id"], name: "index_places_on_user_id" end create_table "points", force: :cascade do |t| @@ -265,6 +267,28 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do t.index ["year"], name: "index_stats_on_year" end + create_table "taggings", force: :cascade do |t| + t.string "taggable_type", null: false + t.bigint "taggable_id", null: false + t.bigint "tag_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["tag_id"], name: "index_taggings_on_tag_id" + t.index ["taggable_type", "taggable_id", "tag_id"], name: "index_taggings_on_taggable_and_tag", unique: true + t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable" + end + + create_table "tags", force: :cascade do |t| + t.string "name", null: false + t.string "icon" + t.string "color" + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "name"], name: "index_tags_on_user_id_and_name", unique: true + t.index ["user_id"], name: "index_tags_on_user_id" + end + create_table "tracks", force: :cascade do |t| t.datetime "start_at", null: false t.datetime "end_at", null: false @@ -359,9 +383,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do add_foreign_key "notifications", "users" add_foreign_key "place_visits", "places" add_foreign_key "place_visits", "visits" + add_foreign_key "places", "users" add_foreign_key "points", "users" add_foreign_key "points", "visits" add_foreign_key "stats", "users" + add_foreign_key "taggings", "tags" + add_foreign_key "tags", "users" add_foreign_key "tracks", "users" add_foreign_key "trips", "users" add_foreign_key "visits", "areas" diff --git a/spec/factories/places.rb b/spec/factories/places.rb index e9c86f96..f99c14e3 100644 --- a/spec/factories/places.rb +++ b/spec/factories/places.rb @@ -2,10 +2,11 @@ FactoryBot.define do factory :place do - name { 'MyString' } + sequence(:name) { |n| "Place #{n}" } latitude { 54.2905245 } longitude { 13.0948638 } lonlat { "SRID=4326;POINT(#{longitude} #{latitude})" } + association :user trait :with_geodata do geodata do diff --git a/spec/factories/taggings.rb b/spec/factories/taggings.rb new file mode 100644 index 00000000..74582242 --- /dev/null +++ b/spec/factories/taggings.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :tagging do + association :taggable, factory: :place + association :tag + end +end diff --git a/spec/factories/tags.rb b/spec/factories/tags.rb new file mode 100644 index 00000000..05dc0cac --- /dev/null +++ b/spec/factories/tags.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :tag do + sequence(:name) { |n| "Tag #{n}" } + icon { %w[📍 🏠 🏢 🍴 ☕ 🏨 🎭 🏛️ 🌳 ⛰️].sample } + color { "##{SecureRandom.hex(3)}" } + association :user + + trait :home do + name { 'Home' } + icon { '🏠' } + color { '#4CAF50' } + end + + trait :work do + name { 'Work' } + icon { '🏢' } + color { '#2196F3' } + end + + trait :restaurant do + name { 'Restaurant' } + icon { '🍴' } + color { '#FF9800' } + end + + trait :without_color do + color { nil } + end + + trait :without_icon do + icon { nil } + end + end +end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb new file mode 100644 index 00000000..2b2383ec --- /dev/null +++ b/spec/models/tag_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tag, type: :model do + it { is_expected.to belong_to(:user) } + it { is_expected.to have_many(:taggings).dependent(:destroy) } + 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 + expect(build(:tag, color: nil)).to be_valid + end + end + + describe 'scopes' do + let!(:tag1) { create(:tag, name: 'A') } + let!(:tag2) { create(:tag, name: 'B', user: tag1.user) } + + it '.for_user' do + expect(Tag.for_user(tag1.user)).to contain_exactly(tag1, tag2) + end + + it '.ordered' do + expect(Tag.for_user(tag1.user).ordered).to eq([tag1, tag2]) + end + end +end diff --git a/spec/models/tagging_spec.rb b/spec/models/tagging_spec.rb new file mode 100644 index 00000000..9679b758 --- /dev/null +++ b/spec/models/tagging_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tagging, type: :model do + it { is_expected.to belong_to(:taggable) } + it { is_expected.to belong_to(:tag) } + + it { is_expected.to validate_presence_of(:taggable) } + it { is_expected.to validate_presence_of(:tag) } + + describe 'uniqueness' do + subject { create(:tagging) } + + it { is_expected.to validate_uniqueness_of(:tag_id).scoped_to([:taggable_type, :taggable_id]) } + end + + it 'prevents duplicate taggings' do + tagging = create(:tagging) + duplicate = build(:tagging, taggable: tagging.taggable, tag: tagging.tag) + + expect(duplicate).not_to be_valid + end +end