mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
314 lines
8.6 KiB
Ruby
314 lines
8.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|
devise :database_authenticatable, :registerable,
|
|
:recoverable, :rememberable, :validatable, :trackable
|
|
|
|
has_many :points, dependent: :destroy
|
|
has_many :imports, dependent: :destroy
|
|
has_many :stats, dependent: :destroy
|
|
has_many :exports, dependent: :destroy
|
|
has_many :notifications, dependent: :destroy
|
|
has_many :areas, dependent: :destroy
|
|
has_many :visits, dependent: :destroy
|
|
has_many :places, through: :visits
|
|
has_many :trips, dependent: :destroy
|
|
has_many :tracks, dependent: :destroy
|
|
|
|
# Family associations
|
|
has_one :family_membership, dependent: :destroy
|
|
has_one :family, through: :family_membership
|
|
has_one :created_family, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy
|
|
has_many :sent_family_invitations, class_name: 'FamilyInvitation', foreign_key: 'invited_by_id',
|
|
inverse_of: :invited_by, dependent: :destroy
|
|
|
|
after_create :create_api_key
|
|
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
|
after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? }
|
|
|
|
before_save :sanitize_input
|
|
|
|
before_destroy :check_family_ownership
|
|
|
|
validates :email, presence: true
|
|
validates :reset_password_token, uniqueness: true, allow_nil: true
|
|
|
|
attribute :admin, :boolean, default: false
|
|
attribute :points_count, :integer, default: 0
|
|
|
|
scope :active_or_trial, -> { where(status: %i[active trial]) }
|
|
|
|
enum :status, { inactive: 0, active: 1, trial: 2 }
|
|
|
|
def safe_settings
|
|
Users::SafeSettings.new(settings)
|
|
end
|
|
|
|
def countries_visited
|
|
Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do
|
|
points
|
|
.without_raw_data
|
|
.where.not(country_name: [nil, ''])
|
|
.distinct
|
|
.pluck(:country_name)
|
|
.compact
|
|
end
|
|
end
|
|
|
|
def cities_visited
|
|
Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do
|
|
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
|
end
|
|
end
|
|
|
|
def total_distance
|
|
total_distance_meters = stats.sum(:distance)
|
|
Stat.convert_distance(total_distance_meters, safe_settings.distance_unit)
|
|
end
|
|
|
|
def total_countries
|
|
countries_visited.size
|
|
end
|
|
|
|
def total_cities
|
|
cities_visited.size
|
|
end
|
|
|
|
def total_reverse_geocoded_points
|
|
points.where.not(reverse_geocoded_at: nil).count
|
|
end
|
|
|
|
def total_reverse_geocoded_points_without_data
|
|
points.where(geodata: {}).count
|
|
end
|
|
|
|
def immich_integration_configured?
|
|
settings['immich_url'].present? && settings['immich_api_key'].present?
|
|
end
|
|
|
|
def photoprism_integration_configured?
|
|
settings['photoprism_url'].present? && settings['photoprism_api_key'].present?
|
|
end
|
|
|
|
def years_tracked
|
|
Rails.cache.fetch("dawarich/user_#{id}_years_tracked", expires_in: 1.day) do
|
|
# Use select_all for better performance with large datasets
|
|
sql = <<-SQL
|
|
SELECT DISTINCT
|
|
EXTRACT(YEAR FROM TO_TIMESTAMP(timestamp)) AS year,
|
|
TO_CHAR(TO_TIMESTAMP(timestamp), 'Mon') AS month
|
|
FROM points
|
|
WHERE user_id = #{id}
|
|
ORDER BY year DESC, month ASC
|
|
SQL
|
|
|
|
result = ActiveRecord::Base.connection.select_all(sql)
|
|
|
|
result
|
|
.map { |r| [r['year'].to_i, r['month']] }
|
|
.group_by { |year, _| year }
|
|
.transform_values { |year_data| year_data.map { |_, month| month } }
|
|
.map { |year, months| { year: year, months: months } }
|
|
end
|
|
end
|
|
|
|
def can_subscribe?
|
|
(trial? || !active_until&.future?) && !DawarichSettings.self_hosted?
|
|
end
|
|
|
|
def generate_subscription_token
|
|
payload = {
|
|
user_id: id,
|
|
email: email,
|
|
exp: 30.minutes.from_now.to_i
|
|
}
|
|
|
|
secret_key = ENV['JWT_SECRET_KEY']
|
|
|
|
JWT.encode(payload, secret_key, 'HS256')
|
|
end
|
|
|
|
def export_data
|
|
Users::ExportDataJob.perform_later(id)
|
|
end
|
|
|
|
def trial_state?
|
|
(points_count || 0).zero? && trial?
|
|
end
|
|
|
|
def timezone
|
|
Time.zone.name
|
|
end
|
|
|
|
def countries_visited_uncached
|
|
points
|
|
.without_raw_data
|
|
.where.not(country_name: [nil, ''])
|
|
.distinct
|
|
.pluck(:country_name)
|
|
.compact
|
|
end
|
|
|
|
def cities_visited_uncached
|
|
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
|
end
|
|
|
|
private
|
|
|
|
def create_api_key
|
|
self.api_key = SecureRandom.hex(16)
|
|
|
|
save
|
|
end
|
|
|
|
def activate
|
|
update(status: :active, active_until: 1000.years.from_now)
|
|
end
|
|
|
|
def sanitize_input
|
|
settings['immich_url']&.gsub!(%r{/+\z}, '')
|
|
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
|
|
settings.try(:[], 'maps')&.try(:[], 'url')&.strip!
|
|
end
|
|
|
|
def check_family_ownership
|
|
return if can_delete_account?
|
|
|
|
errors.add(:base, 'Cannot delete account while being a family owner with other members')
|
|
raise ActiveRecord::DeleteRestrictionError, 'Cannot delete user with family members'
|
|
end
|
|
|
|
def start_trial
|
|
update(status: :trial, active_until: 7.days.from_now)
|
|
schedule_welcome_emails
|
|
|
|
Users::TrialWebhookJob.perform_later(id)
|
|
end
|
|
|
|
def schedule_welcome_emails
|
|
Users::MailerSendingJob.perform_later(id, 'welcome')
|
|
Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features')
|
|
Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon')
|
|
Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired')
|
|
schedule_post_trial_emails
|
|
end
|
|
|
|
def schedule_post_trial_emails
|
|
Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early')
|
|
Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late')
|
|
end
|
|
|
|
public
|
|
|
|
# Family-related methods
|
|
def in_family?
|
|
family_membership.present?
|
|
end
|
|
|
|
def family_owner?
|
|
family_membership&.owner? == true
|
|
end
|
|
|
|
def can_delete_account?
|
|
return true unless family_owner?
|
|
return true unless family
|
|
|
|
family.members.count <= 1
|
|
end
|
|
|
|
def family_sharing_enabled?
|
|
# User must be in a family and have explicitly enabled location sharing
|
|
return false unless in_family?
|
|
|
|
sharing_settings = settings.dig('family', 'location_sharing')
|
|
return false if sharing_settings.blank?
|
|
|
|
# If it's a boolean (legacy support), return it
|
|
return sharing_settings if [true, false].include?(sharing_settings)
|
|
|
|
# If it's time-limited sharing, check if it's still active
|
|
if sharing_settings.is_a?(Hash)
|
|
return false unless sharing_settings['enabled'] == true
|
|
|
|
# Check if sharing has an expiration
|
|
expires_at = sharing_settings['expires_at']
|
|
return expires_at.blank? || Time.parse(expires_at) > Time.current
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def update_family_location_sharing!(enabled, duration: nil)
|
|
return false unless in_family?
|
|
|
|
current_settings = settings || {}
|
|
current_settings['family'] ||= {}
|
|
|
|
if enabled
|
|
sharing_config = { 'enabled' => true }
|
|
|
|
# Add expiration if duration is specified
|
|
if duration.present?
|
|
expiration_time = case duration
|
|
when '1h'
|
|
1.hour.from_now
|
|
when '6h'
|
|
6.hours.from_now
|
|
when '12h'
|
|
12.hours.from_now
|
|
when '24h'
|
|
24.hours.from_now
|
|
when 'permanent'
|
|
nil # No expiration
|
|
else
|
|
# Custom duration in hours
|
|
duration.to_i.hours.from_now if duration.to_i > 0
|
|
end
|
|
|
|
sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time
|
|
sharing_config['duration'] = duration
|
|
end
|
|
|
|
current_settings['family']['location_sharing'] = sharing_config
|
|
else
|
|
current_settings['family']['location_sharing'] = { 'enabled' => false }
|
|
end
|
|
|
|
update!(settings: current_settings)
|
|
end
|
|
|
|
def family_sharing_expires_at
|
|
sharing_settings = settings.dig('family', 'location_sharing')
|
|
return nil unless sharing_settings.is_a?(Hash)
|
|
|
|
expires_at = sharing_settings['expires_at']
|
|
Time.parse(expires_at) if expires_at.present?
|
|
rescue ArgumentError
|
|
nil
|
|
end
|
|
|
|
def family_sharing_duration
|
|
settings.dig('family', 'location_sharing', 'duration') || 'permanent'
|
|
end
|
|
|
|
def latest_location_for_family
|
|
return nil unless family_sharing_enabled?
|
|
|
|
# Use select to only fetch needed columns and limit to 1 for efficiency
|
|
latest_point = points.select(:latitude, :longitude, :timestamp)
|
|
.order(timestamp: :desc)
|
|
.limit(1)
|
|
.first
|
|
|
|
return nil unless latest_point
|
|
|
|
{
|
|
user_id: id,
|
|
email: email,
|
|
latitude: latest_point.latitude,
|
|
longitude: latest_point.longitude,
|
|
timestamp: latest_point.timestamp,
|
|
updated_at: Time.at(latest_point.timestamp)
|
|
}
|
|
end
|
|
end
|