diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 00000000..89f547ae --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,64 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: false +:exclude_fixtures: false +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: false +:exclude_sti_subclasses: false +:exclude_tests: false +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:grouped_polymorphic: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:show_indexes_include: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:position_of_column_comment: :with_name +:active_admin: false +:command: +:debug: false +:hide_default_column_types: '' +:hide_limit_column_types: '' +:timestamp_columns: +- created_at +- updated_at +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +:require: [] +:root_dir: +- '' diff --git a/Gemfile b/Gemfile index c0dbfdfc..4680373d 100644 --- a/Gemfile +++ b/Gemfile @@ -71,7 +71,7 @@ group :development, :test do end group :development do - gem 'annotate' # Annotate models with schema + gem 'annotaterb' # Annotate models with schema (Rails 8 compatible fork) gem 'brakeman' # Static analysis security vulnerability scanner for Ruby on Rails applications gem 'letter_opener' # Preview emails in the browser instead of sending them gem 'web-console' # Rails console for the browser diff --git a/Gemfile.lock b/Gemfile.lock index fa844181..08858961 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -74,9 +74,9 @@ GEM uri (>= 0.13.1) addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) - annotate (2.6.5) - activerecord (>= 2.3.0) - rake (>= 0.8.7) + annotaterb (4.20.0) + activerecord (>= 6.0.0) + activesupport (>= 6.0.0) ast (2.4.2) autoprefixer-rails (10.4.16.0) execjs (~> 2) @@ -451,7 +451,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - annotate + annotaterb autoprefixer-rails bootsnap bootstrap (~> 5.2) diff --git a/Procfile.dev b/Procfile.dev index 21e70575..a14581f3 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1 +1,2 @@ web: bin/rails server -p 3000 +ngrok: ngrok http 3000 --log=stdout diff --git a/README.md b/README.md index 6f155e92..45fca11c 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ These instructions will get you a copy of the project up and running on your loc Ensure that you have the following installed on your local machine: -* [Ruby](https://www.ruby-lang.org/en/documentation/installation/) - 3.1.2 -* [Rails](https://guides.rubyonrails.org/v5.0/getting_started.html) - 7.0.6 +* [Ruby](https://www.ruby-lang.org/en/documentation/installation/) - 3.4.4 +* [Rails](https://guides.rubyonrails.org/v5.0/getting_started.html) - 8.0.3 * PostgreSQL ### Installation @@ -62,6 +62,36 @@ rspec # run all tests rspec spec/requests/users_spec.rb # run a specific test ``` +### Development Server + +**Option 1: Using Foreman (recommended for production-like environment)** + +Start both Rails server and ngrok simultaneously: + +```bash +bin/dev +``` + +This will start: +- Rails server on `http://localhost:3000` +- ngrok tunnel (URL will be shown in the logs) + +**Option 2: Manual setup (recommended for debugging with binding.pry)** + +Start Rails server and ngrok separately: + +**Terminal 1 - Rails Server:** +```bash +rails s +``` + +**Terminal 2 - ngrok (for mobile testing):** +```bash +ngrok http 3000 +``` + +The ngrok URL will be displayed in Terminal 2 under "Forwarding" (e.g., `https://abc123.ngrok-free.app`). Use this URL to test the app on your mobile device. + ### Services - Poetry Service: Fetches a random poem to display on the page. diff --git a/app/assets/stylesheets/components/_map.scss b/app/assets/stylesheets/components/_map.scss index 87e5c5fa..38ff30ac 100644 --- a/app/assets/stylesheets/components/_map.scss +++ b/app/assets/stylesheets/components/_map.scss @@ -1,17 +1,7 @@ .rounded-map { - border-radius: 15px; /* Or use a SASS variable $border-radius if it's defined in your environment */ - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); /* Example shadow, adjust as needed */ + border-radius: $border-radius; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden; - margin: 5px auto; - padding: 10px; /* Example padding, adjust using $space-xs if needed */ - - /* Default to smaller size */ - width: 340px !important; - height: 300px; - - /* Larger devices */ - @media (min-width: 768px) { - width: 100% !important; - height: 90vh; - } + width: 100%; + height: 600px; } diff --git a/app/assets/stylesheets/components/_navbar.scss b/app/assets/stylesheets/components/_navbar.scss index b98f7b51..f4f536c0 100644 --- a/app/assets/stylesheets/components/_navbar.scss +++ b/app/assets/stylesheets/components/_navbar.scss @@ -5,7 +5,8 @@ } .navbar .navbar-brand img { - width: 40px; + height: 50px; + width: auto; } .offcanvas { diff --git a/app/assets/stylesheets/components/_turbo_progress_bar.scss b/app/assets/stylesheets/components/_turbo_progress_bar.scss index b4027274..99de6cb7 100644 --- a/app/assets/stylesheets/components/_turbo_progress_bar.scss +++ b/app/assets/stylesheets/components/_turbo_progress_bar.scss @@ -1,3 +1,3 @@ .turbo-progress-bar { - background: linear-gradient(to right, $color-primary, $color-primary-rotate); + background: linear-gradient(to right, $soft-purple, $pink); } diff --git a/app/assets/stylesheets/config/_reset.scss b/app/assets/stylesheets/config/_reset.scss index 16ceed97..fc664b76 100644 --- a/app/assets/stylesheets/config/_reset.scss +++ b/app/assets/stylesheets/config/_reset.scss @@ -13,13 +13,22 @@ html { overflow-y: scroll; + overflow-x: hidden; height: 100%; + height: 100dvh; /* Modern dynamic viewport height */ + height: -webkit-fill-available; /* Fallback for older Safari */ + background-color: $off-white; } body { display: flex; flex-direction: column; min-height: 100%; + min-height: 100vh; /* Fallback for older browsers */ + min-height: 100dvh; /* Modern dynamic viewport height */ + min-height: -webkit-fill-available; /* Fallback for older Safari */ + overflow-x: hidden; + width: 100%; background-color: $color-background; color: $color-text-body; diff --git a/app/controllers/friendships_controller.rb b/app/controllers/friendships_controller.rb index fc9b0c4b..d00a6bda 100644 --- a/app/controllers/friendships_controller.rb +++ b/app/controllers/friendships_controller.rb @@ -12,26 +12,26 @@ def index def create @friendship = current_user.friendships.build(friend_id: params[:friend_id]) flash[:notice] = if @friendship.save - 'Friend request sent. 👻' + t('friendships.created') else - 'Unable to send friend request. 🤔' + t('friendships.create_failed') end - redirect_to users_path + redirect_to friends_path end def update flash[:notice] = if @friendship.update(accepted: true) - 'Friend request accepted. 🫱🏻🫲🏾' + t('friendships.accepted') else - 'Unable to accept friend request. 🙈' + t('friendships.accept_failed') end - redirect_to users_path + redirect_to friends_path end def destroy @friendship.destroy - flash[:notice] = 'Friendship removed. 😭' - redirect_to users_path + flash[:notice] = t('friendships.destroyed') + redirect_to friends_path end private @@ -41,7 +41,7 @@ def destroy # which could otherwise happen via ID guessing (e.g. /friendships/42) def set_friendship @friendship = Friendship.where(id: params[:id]) - .where('user_id = ? OR friend_id = ?', current_user.id, current_user.id) + .where('user_id = :user_id OR friend_id = :user_id', user_id: current_user.id) .first end end diff --git a/app/controllers/happy_things_controller.rb b/app/controllers/happy_things_controller.rb index 24ef8498..79aeca30 100644 --- a/app/controllers/happy_things_controller.rb +++ b/app/controllers/happy_things_controller.rb @@ -101,7 +101,7 @@ def fetch_words_for_wordcloud end def fetch_visited_places - @visited_places_count = @visited_places_count = HappyThing.where(user_id: current_user.id).distinct.count(:place) + @visited_places_count = HappyThing.where(user_id: current_user.id).distinct.count(:place) end def fetch_label_count diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7b5cab2d..c94978d0 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -5,20 +5,25 @@ class UsersController < ApplicationController helper FriendshipsHelper include WordAggregator + attr_reader :user + def index @users = fetch_users end def friends - @friends = fetch_users + @friends = current_user.friends + @pending_requests = current_user.pending_friends + @friend_requests = fetch_incoming_friend_requests + @users = fetch_users end def show @user = User.find(params[:id]) - @happy_count = happy_count(@user) - @words_for_wordcloud = words_for_wordcloud(@user) - @visited_places_count = visited_places_count(@user) - @markers = markers(@user) + @happy_count = happy_count(user) + @words_for_wordcloud = words_for_wordcloud(user) + @visited_places_count = visited_places_count(user) + @markers = markers(user) end def profile @@ -26,11 +31,19 @@ def profile fetch_words_for_wordcloud fetch_visited_places fetch_label_count - @markers = current_user.happy_things.geocoded.map { |ht| { lat: ht.latitude, lng: ht.longitude } } + @markers = fetch_markers_for_map end private + def fetch_incoming_friend_requests + User.joins(:friendships).where(friendships: + { + friend_id: current_user.id, + accepted: false + }) + end + def fetch_users if params[:query].present? User.search(params[:query]).all_except(current_user) @@ -67,7 +80,7 @@ def fetch_words_for_wordcloud end def fetch_visited_places - @visited_places_count = @visited_places_count = HappyThing.where(user_id: current_user.id).distinct.count(:place) + @visited_places_count = current_user.happy_things.distinct.count(:place) end def fetch_label_count @@ -75,4 +88,8 @@ def fetch_label_count @label_counts = {} end + + def fetch_markers_for_map + current_user.happy_things.geocoded.pluck(:latitude, :longitude).map { |lat, lng| { lat:, lng: } } + end end diff --git a/app/helpers/friendships_helper.rb b/app/helpers/friendships_helper.rb index 6618bb30..6d9fa198 100644 --- a/app/helpers/friendships_helper.rb +++ b/app/helpers/friendships_helper.rb @@ -2,11 +2,23 @@ module FriendshipsHelper def can_add_as_friend?(current_user, potential_friend) - return false if current_user.friends.include?(potential_friend) + !current_user.friends.include?(potential_friend) && + !current_user.friendships.exists?(friend_id: potential_friend.id) + end + + def filter_users_by_query(users, query) + return users if query.blank? + + users.select { |user| matches_query?(user, query.downcase) } + end - sent_request = current_user.friendships.find_by(friend_id: potential_friend.id) - received_request = current_user.received_friend_requests.find_by(user_id: potential_friend.id) + private - !(sent_request || received_request) + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def matches_query?(user, query_downcase) + [user.first_name, user.last_name, user.username, user.email] + .compact + .any? { |field| field.downcase.include?(query_downcase) } end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity end diff --git a/app/models/comment.rb b/app/models/comment.rb index bd5c7b10..70226a5e 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -5,11 +5,21 @@ # Table name: comments # # id :bigint not null, primary key -# user_id :bigint not null -# happy_thing_id :bigint not null # content :text # created_at :datetime not null # updated_at :datetime not null +# happy_thing_id :bigint not null +# user_id :bigint not null +# +# Indexes +# +# index_comments_on_happy_thing_id (happy_thing_id) +# index_comments_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (happy_thing_id => happy_things.id) +# fk_rails_... (user_id => users.id) # class Comment < ApplicationRecord belongs_to :user diff --git a/app/models/friendship.rb b/app/models/friendship.rb index 8b5c369e..edde8f72 100644 --- a/app/models/friendship.rb +++ b/app/models/friendship.rb @@ -5,13 +5,24 @@ # Table name: friendships # # id :bigint not null, primary key -# user_id :bigint -# friend_id :bigint # accepted :boolean default(FALSE) # created_at :datetime not null # updated_at :datetime not null +# friend_id :bigint +# user_id :bigint +# +# Indexes # -# Friendship Model which makes sure a friendship is always either false or true (accepted) +# index_friendships_on_friend_id (friend_id) +# index_friendships_on_user_id (user_id) +# index_friendships_on_user_id_and_friend_id (user_id,friend_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (friend_id => users.id) +# fk_rails_... (user_id => users.id) +# +# Friendship Model with bidirectional records for simplified queries class Friendship < ApplicationRecord belongs_to :user belongs_to :friend, class_name: 'User' @@ -20,4 +31,38 @@ class Friendship < ApplicationRecord scope :pending, -> { where(accepted: false) } validates :accepted, inclusion: { in: [true, false] } + + after_create :create_inverse_friendship + after_update :update_inverse_friendship, if: :saved_change_to_accepted? + after_destroy :destroy_inverse_friendship + + private + + def create_inverse_friendship + return if inverse_friendship_exists? + + self.class.create!( + user_id: friend_id, + friend_id: user_id, + accepted: accepted + ) + end + + def update_inverse_friendship + inverse = find_inverse_friendship + inverse&.update(accepted: accepted) + end + + def destroy_inverse_friendship + inverse = find_inverse_friendship + inverse&.destroy + end + + def find_inverse_friendship + self.class.find_by(user_id: friend_id, friend_id: user_id) + end + + def inverse_friendship_exists? + self.class.exists?(user_id: friend_id, friend_id: user_id) + end end diff --git a/app/models/group.rb b/app/models/group.rb index 6c76bb3f..e35f9ce4 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -6,9 +6,17 @@ # # id :bigint not null, primary key # name :string -# user_id :bigint not null # created_at :datetime not null # updated_at :datetime not null +# user_id :bigint not null +# +# Indexes +# +# index_groups_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) # class Group < ApplicationRecord belongs_to :user diff --git a/app/models/group_membership.rb b/app/models/group_membership.rb index 62228a5b..642eadca 100644 --- a/app/models/group_membership.rb +++ b/app/models/group_membership.rb @@ -5,10 +5,20 @@ # Table name: group_memberships # # id :bigint not null, primary key -# group_id :bigint not null -# friend_id :bigint not null # created_at :datetime not null # updated_at :datetime not null +# friend_id :bigint not null +# group_id :bigint not null +# +# Indexes +# +# index_group_memberships_on_friend_id (friend_id) +# index_group_memberships_on_group_id (group_id) +# +# Foreign Keys +# +# fk_rails_... (friend_id => users.id) +# fk_rails_... (group_id => groups.id) # class GroupMembership < ApplicationRecord belongs_to :group diff --git a/app/models/happy_thing.rb b/app/models/happy_thing.rb index 49212a4d..fe30962a 100644 --- a/app/models/happy_thing.rb +++ b/app/models/happy_thing.rb @@ -5,18 +5,30 @@ # Table name: happy_things # # id :bigint not null, primary key -# title :string not null # body :text +# latitude :float +# longitude :float +# place :string +# share_location :boolean +# start_time :datetime # status :integer -# user_id :bigint not null +# title :string not null +# visibility :string default("public") # created_at :datetime not null # updated_at :datetime not null -# start_time :datetime -# place :string -# latitude :float -# longitude :float # category_id :bigint -# share_location :boolean +# user_id :bigint not null +# +# Indexes +# +# index_happy_things_on_category_id (category_id) +# index_happy_things_on_user_id (user_id) +# index_happy_things_on_visibility (visibility) +# +# Foreign Keys +# +# fk_rails_... (category_id => categories.id) +# fk_rails_... (user_id => users.id) # class HappyThing < ApplicationRecord geocoded_by :place @@ -33,7 +45,7 @@ class HappyThing < ApplicationRecord validates :title, presence: true before_validation :set_default_category, on: :create - after_validation :geocode, if: :will_save_change_to_place? + after_validation :geocode, if: -> { will_save_change_to_place? && !Rails.env.test? && geocoding_enabled? } before_create :add_date_time_to_happy_thing, unless: :start_time_present? after_create :check_happy_things_count @@ -67,8 +79,14 @@ def check_happy_things_count end def notify_friends_about_happy_things - user.friends_and_friends_who_added_me.each do |friend| + user.all_friends.each do |friend| UserMailer.happy_things_notification(friend).deliver_later end end + + def geocoding_enabled? + return false if Rails.env.development? && ENV['GEOCODER_API_KEY'].blank? + + true + end end diff --git a/app/models/happy_thing_group_share.rb b/app/models/happy_thing_group_share.rb index e3f986c8..81bebabb 100644 --- a/app/models/happy_thing_group_share.rb +++ b/app/models/happy_thing_group_share.rb @@ -5,10 +5,20 @@ # Table name: happy_thing_group_shares # # id :bigint not null, primary key -# happy_thing_id :bigint not null -# group_id :bigint not null # created_at :datetime not null # updated_at :datetime not null +# group_id :bigint not null +# happy_thing_id :bigint not null +# +# Indexes +# +# index_happy_thing_group_shares_on_group_id (group_id) +# index_happy_thing_group_shares_on_happy_thing_id (happy_thing_id) +# +# Foreign Keys +# +# fk_rails_... (group_id => groups.id) +# fk_rails_... (happy_thing_id => happy_things.id) # class HappyThingGroupShare < ApplicationRecord belongs_to :happy_thing diff --git a/app/models/happy_thing_user_share.rb b/app/models/happy_thing_user_share.rb index 46c06a05..e1e29eb2 100644 --- a/app/models/happy_thing_user_share.rb +++ b/app/models/happy_thing_user_share.rb @@ -5,10 +5,20 @@ # Table name: happy_thing_user_shares # # id :bigint not null, primary key -# happy_thing_id :bigint not null -# friend_id :bigint not null # created_at :datetime not null # updated_at :datetime not null +# friend_id :bigint not null +# happy_thing_id :bigint not null +# +# Indexes +# +# index_happy_thing_user_shares_on_friend_id (friend_id) +# index_happy_thing_user_shares_on_happy_thing_id (happy_thing_id) +# +# Foreign Keys +# +# fk_rails_... (friend_id => users.id) +# fk_rails_... (happy_thing_id => happy_things.id) # class HappyThingUserShare < ApplicationRecord belongs_to :happy_thing diff --git a/app/models/user.rb b/app/models/user.rb index ea6e4cce..3aba1a1e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,30 +1,36 @@ # frozen_string_literal: true - +# # == Schema Information # # Table name: users # # id :bigint not null, primary key -# first_name :string -# last_name :string +# confirmation_sent_at :datetime +# confirmation_token :string +# confirmed_at :datetime # email :string default(""), not null +# email_opt_in :boolean default(FALSE) +# emoji :string # encrypted_password :string default(""), not null -# reset_password_token :string -# reset_password_sent_at :datetime +# first_name :string +# last_name :string +# location_opt_in :boolean default(FALSE) +# provider :string # remember_created_at :datetime +# reset_password_sent_at :datetime +# reset_password_token :string # sign_in_count :integer default(0), not null +# uid :string +# unconfirmed_email :string +# username :string # created_at :datetime not null # updated_at :datetime not null -# emoji :string -# email_opt_in :boolean default(FALSE) -# location_opt_in :boolean default(FALSE) -# username :string -# confirmation_token :string -# confirmed_at :datetime -# confirmation_sent_at :datetime -# unconfirmed_email :string -# provider :string -# uid :string +# +# Indexes +# +# index_users_on_confirmation_token (confirmation_token) UNIQUE +# index_users_on_email (email) UNIQUE +# index_users_on_reset_password_token (reset_password_token) UNIQUE # class User < ApplicationRecord # rubocop:disable Metrics/ClassLength PASSWORD_REGEX = /\A @@ -70,13 +76,10 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength has_many :group_memberships, foreign_key: :friend_id, dependent: :destroy has_many :groups_as_member, through: :group_memberships, source: :group - # Friendships + # Friendships - Bidirectional: each friendship exists as two records has_many :friendships, dependent: :destroy - has_many :received_friend_requests, class_name: 'Friendship', foreign_key: 'friend_id', dependent: :destroy has_many :friends, -> { where(friendships: { accepted: true }) }, through: :friendships, source: :friend - has_many :friends_who_added_me, lambda { - where(friendships: { accepted: true }) - }, through: :received_friend_requests, source: :user + has_many :pending_friends, -> { where(friendships: { accepted: false }) }, through: :friendships, source: :friend has_one_attached :avatar @@ -85,21 +88,13 @@ def self.search(query) query: "%#{query}%") end - def friends_and_friends_who_added_me_ids - friends.pluck(:id) + friends_who_added_me.pluck(:id) + def friend_ids + friends.pluck(:id) end - def all_friends - friends + friends_who_added_me - end - - def accepted_friends - friendships.where(status: :accepted) - end - - def pending_friends - friendships.pending + received_friend_requests.pending - end + # Aliases for backward compatibility + alias_method :friends_and_friends_who_added_me_ids, :friend_ids + alias_method :all_friends, :friends def happy_streak return 0 if happy_things.empty? @@ -110,10 +105,6 @@ def happy_streak calculate_streak(dates) end - def friends_and_friends_who_added_me - User.where(id: friends_and_friends_who_added_me_ids) - end - def self.from_omniauth(auth) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength # Returning user signing in via OAuth user = where(provider: auth.provider, uid: auth.uid).first diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b3731188..4d4a9bb8 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -6,9 +6,17 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> - <%# PWA %> + <%# PWA - iOS %> + + + + + <%# PWA - Android %> + + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> diff --git a/app/views/users/friends.html.erb b/app/views/users/friends.html.erb index 03936b27..b927f4fe 100644 --- a/app/views/users/friends.html.erb +++ b/app/views/users/friends.html.erb @@ -1,121 +1,150 @@ -
+ <%= t('friends.no_results') %> "<%= params[:query] %>" +
+ <% end %> +