bikeindex/bike_index

View on GitHub
app/models/organization.rb

Summary

Maintainability
C
1 day
Test Coverage
A
96%
class Organization < ApplicationRecord
  include ActionView::Helpers::SanitizeHelper
  include SearchRadiusMetricable

  KIND_ENUM = {
    bike_shop: 0,
    bike_advocacy: 1,
    law_enforcement: 2,
    school: 3,
    bike_manufacturer: 4,
    software: 5,
    property_management: 6,
    other: 7,
    ambassador: 8,
    bike_depot: 9
  }.freeze

  POS_KIND_ENUM = {
    no_pos: 0,
    other_pos: 1,
    lightspeed_pos: 2,
    ascend_pos: 3,
    broken_lightspeed_pos: 4,
    does_not_need_pos: 5,
    broken_ascend_pos: 6
  }.freeze

  acts_as_paranoid

  mount_uploader :avatar, AvatarUploader

  belongs_to :parent_organization, class_name: "Organization"
  belongs_to :auto_user, class_name: "User"
  belongs_to :manufacturer

  has_many :bike_organizations
  has_many :bikes, through: :bike_organizations
  has_many :bike_organizations_ever_registered, -> { with_deleted }, class_name: "BikeOrganization"
  has_many :bikes_ever_registered, through: :bike_organizations_ever_registered, source: :bike
  has_many :recovered_records, through: :bikes_ever_registered

  has_many :memberships, dependent: :destroy
  has_many :users, through: :memberships

  has_many :admin_memberships, -> { admin }, class_name: "Membership"
  has_many :admins, through: :admin_memberships, source: :user

  has_many :ownerships
  has_many :created_bikes, through: :ownerships, source: :bike

  has_many :organization_manufacturers
  has_many :locations, inverse_of: :organization, dependent: :destroy
  has_many :mail_snippets
  has_many :parking_notifications
  has_many :impound_records
  has_many :impound_claims
  has_many :b_params
  has_many :invoices
  has_many :payments
  has_many :graduated_notifications
  has_many :organization_statuses
  has_many :calculated_children, class_name: "Organization", foreign_key: :parent_organization_id
  has_many :public_images, as: :imageable, dependent: :destroy # For organization landings and other organization features
  has_one :hot_sheet_configuration
  has_one :organization_stolen_message
  has_one :impound_configuration
  has_many :hot_sheets
  has_many :organization_model_audits
  accepts_nested_attributes_for :mail_snippets
  accepts_nested_attributes_for :organization_stolen_message
  accepts_nested_attributes_for :locations, allow_destroy: true

  enum kind: KIND_ENUM
  enum pos_kind: POS_KIND_ENUM
  enum manual_pos_kind: POS_KIND_ENUM, _prefix: :manual

  validates_presence_of :name
  validates_uniqueness_of :short_name, case_sensitive: false, message: I18n.t(:duplicate_short_name, scope: [:activerecord, :errors, :organization])
  validates_with OrganizationNameValidator
  validates_uniqueness_of :slug, message: "Slug error. You shouldn't see this - please contact support@bikeindex.org"
  validates_uniqueness_of :manufacturer_id, allow_blank: true

  default_scope { order(:name) }
  scope :name_ordered, -> { order(arel_table["name"].lower) }
  scope :show_on_map, -> { where(show_on_map: true, approved: true) }
  scope :paid, -> { where(is_paid: true) }
  scope :paid_money, -> { where(is_paid: true) } # TODO: make this actually show paid money, rather than just paid
  scope :unpaid, -> { where(is_paid: false) }
  scope :approved, -> { where(approved: true) }
  scope :broken_pos, -> { where(pos_kind: broken_pos_kinds) }
  scope :with_pos, -> { where(pos_kind: with_pos_kinds) }
  scope :with_stolen_message, -> { left_joins(:organization_stolen_message).where.not(organization_stolen_message: {body: nil}) }
  # Eventually there will be other actions beside organization_messages, but for now it's just messages
  scope :bike_actions, -> { where("enabled_feature_slugs ?| array[:keys]", keys: %w[unstolen_notifications parking_notifications impound_bikes]) }
  # Regional orgs have to have the organization feature slug AND the search location set
  scope :regional, -> { where.not(location_latitude: nil).where.not(location_longitude: nil).where("enabled_feature_slugs ?| array[:keys]", keys: ["regional_bike_counts"]) }

  before_validation :set_calculated_attributes
  after_commit :update_associations

  delegate \
    :address,
    :city,
    :country,
    :country_id,
    :latitude,
    :longitude,
    :state,
    :state_id,
    :street,
    :zipcode,
    :metric_units?,
    to: :default_location,
    allow_nil: true

  geocoded_by nil, latitude: :location_latitude, longitude: :location_longitude

  attr_accessor :embedable_user_email, :skip_update

  def self.kinds
    KIND_ENUM.keys.map(&:to_s)
  end

  def self.pos_kinds
    POS_KIND_ENUM.keys.map(&:to_s)
  end

  def self.broken_pos_kinds
    %w[broken_ascend_pos broken_lightspeed_pos].freeze
  end

  def self.without_pos_kinds
    %w[no_pos does_not_need_pos].freeze
  end

  def self.ascend_or_broken_ascend_kinds
    %w[ascend_pos broken_ascend_pos].freeze
  end

  def self.lightspeed_or_broken_lightspeed_kinds
    %w[lightspeed_pos broken_lightspeed_pos].freeze
  end

  def self.with_pos_kinds
    pos_kinds - broken_pos_kinds - without_pos_kinds
  end

  def self.pos?(kind = nil)
    kind.present? && !without_pos_kinds.include?(kind)
  end

  def self.admin_required_kinds
    %w[ambassador bike_depot].freeze
  end

  def self.user_creatable_kinds
    kinds - admin_required_kinds
  end

  def self.kind_humanized(str)
    str.blank? ? nil : str.to_s.titleize
  end

  def self.friendly_find(n)
    return nil unless n.present?
    return n if n.is_a?(Organization)
    return find_by_id(n) if integer_slug?(n)
    slug = Slugifyer.slugify(n)
    # First try slug, then previous slug, and finally, just give finding by name a shot
    find_by_slug(slug) || find_by_previous_slug(slug) || where("LOWER(name) = LOWER(?)", n.downcase).first
  end

  def self.friendly_find_id(n)
    friendly_find(n)&.id
  end

  def self.integer_slug?(n)
    n.is_a?(Integer) || n.match(/\A\d+\z/).present?
  end

  def self.admin_text_search(n)
    return nil unless n.present?
    str = "%#{n.strip}%"
    match_cols = %w[organizations.name organizations.short_name organizations.ascend_name locations.name locations.city]
    joins("LEFT OUTER JOIN locations AS locations ON organizations.id = locations.organization_id")
      .distinct
      .where(match_cols.map { |col| "#{col} ILIKE :str" }.join(" OR "), {str: str})
  end

  def self.with_enabled_feature_slugs(slugs)
    matching_slugs = OrganizationFeature.matching_slugs(slugs)
    return none unless matching_slugs.present?
    where("enabled_feature_slugs ?& array[:keys]", keys: matching_slugs)
  end

  def self.with_any_enabled_feature_slugs(slugs)
    matching_slugs = OrganizationFeature.matching_slugs(slugs)
    return none unless matching_slugs.present?
    where("enabled_feature_slugs ?| array[:keys]", keys: matching_slugs)
  end

  def self.permitted_domain_passwordless_signin
    where.not(passwordless_user_domain: nil).with_enabled_feature_slugs("passwordless_users")
  end

  def self.passwordless_email_matching(str)
    str = EmailNormalizer.normalize(str)
    return nil unless str.present? && str.count("@") == 1 && str.match?(/.@.*\../)
    domain = str.split("@").last
    permitted_domain_passwordless_signin.detect { |o| o.passwordless_user_domain == domain }
  end

  def self.example
    Organization.find_by_id(92) || Organization.create(name: "Example organization")
  end

  # never geocode, use default_location lat/long
  def should_be_geocoded?
    false
  end

  def to_param
    slug
  end

  def landing_html?
    landing_html.present?
  end

  def restrict_invitations?
    !enabled?("passwordless_users") && !passwordless_user_domain.present?
  end

  def sent_invitation_count
    memberships.count
  end

  def remaining_invitation_count
    available_invitation_count - sent_invitation_count
  end

  def kind_humanized
    self.class.kind_humanized(kind)
  end

  def lightspeed_or_broken_lightspeed?
    self.class.lightspeed_or_broken_lightspeed_kinds.include?(pos_kind)
  end

  def ascend_or_broken_ascend?
    self.class.ascend_or_broken_ascend_kinds.include?(pos_kind)
  end

  # Enable this if they have paid for showing it, or if they use ascend
  def show_bulk_import?
    ascend_or_broken_ascend? || any_enabled?(%w[show_bulk_import show_bulk_import_impound show_bulk_import_stolen])
  end

  def show_multi_serial?
    enabled?("show_multi_serial") || %w[law_enforcement].include?(kind)
  end

  def public_impound_bikes?
    enabled?("impound_bikes_public") # feature slug applied in calculated_enabled_feature_slugs
  end

  # WARNING! This is not efficient
  def law_enforcement_features_enabled?
    law_enforcement? && current_invoices.any? { |i| i.law_enforcement_functionality_invoice? }
  end

  # Stub for now, but it might be more sophisticated later
  def impound_claims?
    public_impound_bikes?
  end

  def broken_pos?
    self.class.broken_pos_kinds.include?(pos_kind)
  end

  def pos?
    self.class.pos?(pos_kind)
  end

  def allowed_show?
    show_on_map && approved
  end

  # TODO: rename - actually should be "enabled_features?" - because many orgs haven't actually paid
  def paid?
    is_paid
  end

  # For now - just using paid
  def user_registration_all_bikes?
    paid? && !official_manufacturer? &&
      [36, 1].exclude?(id) # Exclude SBR and BikeIndex
  end

  def paid_money?
    paid? && current_invoices.any? { |i| i.paid_money_in_full? }
  end

  def paid_previously?
    !paid_money? && invoices.expired.any? { |i| i.was_active? }
  end

  def fetch_impound_configuration
    impound_configuration.present? ? impound_configuration : ImpoundConfiguration.create(organization_id: id)
  end

  def hot_sheet_on?
    hot_sheet_configuration.present? && hot_sheet_configuration.on?
  end

  def current_invoices
    invoices.active
  end

  def current_parent_invoices
    Invoice.where(organization_id: parent_organization_id).active
  end

  def parent?
    child_ids.present?
  end

  def child_organizations
    Organization.where(id: child_ids)
  end

  def regional_parents
    self.class.regional.where("regional_ids @> ?", [id].to_json)
  end

  def default_impound_location
    enabled?("impound_bikes_locations") ? locations.default_impound_locations.first : nil
  end

  # Try for publicly_visible, fall back to whatever - TODO: make this configurable
  def default_location
    locations.publicly_visible.order(id: :asc).first || locations.order(id: :asc).first
  end

  def search_coordinates
    [location_latitude, location_longitude]
  end

  def search_coordinates_set?
    search_coordinates.all?(&:present?)
  end

  # Many, many things are triggered off of this, so using a method, since we'll probably change logic later
  def regional?
    enabled?("regional_bike_counts")
  end

  def official_manufacturer?
    enabled?("official_manufacturer")
  end

  def overview_dashboard?
    regional? || enabled?("claimed_ownerships") || official_manufacturer?
  end

  def bike_stickers
    BikeSticker.where(organization_id: id).or(BikeSticker.where(secondary_organization_id: id))
  end

  def nearby_organizations
    # Have to do fancy dance to match null parent_organization_id values
    @nearby_organizations = nearby_organizations_including_siblings
      .where("parent_organization_id != ? or parent_organization_id is null", parent_organization_id)
  end

  def nearby_and_partner_organization_ids
    [id, parent_organization_id].compact + child_ids + nearby_organizations_including_siblings.pluck(:id)
  end

  def organization_view_counts
    return Organization.none unless manufacturer_id.present?
    Organization.left_joins(:organization_manufacturers)
      .where(organization_manufacturers: {can_view_counts: true, manufacturer_id: manufacturer_id})
  end

  def mail_snippet_body(snippet_kind)
    return nil unless MailSnippet.organization_snippet_kinds.include?(snippet_kind)
    snippet = mail_snippets.enabled.where(kind: snippet_kind).first
    snippet&.body
  end

  def current_organization_status
    organization_statuses.current.order(:start_at).limit(1).first
  end

  def additional_registration_fields
    OrganizationFeature::REG_FIELDS.select { |f| enabled?(f) }
  end

  def organization_affiliation_options
    translation_scope =
      [:activerecord, :select_options, self.class.name.underscore, __method__]

    %w[student graduate_student employee community_member]
      .map { |e| [I18n.t(e, scope: translation_scope), e] }
  end

  def block_short_name_edit?
    paid? # Prevent url changes breaking landing pages, etc
  end

  def bike_actions?
    any_enabled?(OrganizationFeature::BIKE_ACTIONS)
  end

  # bikes_member is slow - it's for graduated_notifications and shouldn't be called inline
  def bikes_member
    bikes.left_joins(:ownerships).where(ownerships: {current: true, user_id: users.pluck(:id)})
  end

  # bikes_not_member is slow - it's for graduated_notifications and shouldn't be called inline
  def bikes_not_member
    bikes.joins(:ownerships).where(ownerships: {current: true})
      .where.not(ownerships: {user_id: users.pluck(:id)})
      .or(bikes.joins(:ownerships).where(ownerships: {current: true, user_id: nil}))
  end

  # Bikes geolocated within `search_radius` miles.
  def nearby_bikes
    return Bike.none unless regional? && search_coordinates_set?
    # Need to unscope it so that we can call group-by on it
    Bike.unscoped.current.within_bounding_box(bounding_box)
  end

  def nearby_recovered_records
    return StolenRecord.none unless regional? && search_coordinates_set?
    # Don't use recovered scope because it orders them
    StolenRecord.recovered.within_bounding_box(bounding_box)
  end

  def deliver_graduated_notifications?
    enabled?("graduated_notifications") && graduated_notification_interval.present?
  end

  def graduated_notification_interval_days
    return nil unless graduated_notification_interval.present?
    graduated_notification_interval / ActiveSupport::Duration::SECONDS_PER_DAY
  end

  def graduated_notification_interval_days=(val)
    val_i = val.to_i
    self.graduated_notification_interval = val_i.days.to_i if val_i.present?
  end

  # Accepts string or array, tests that ALL are enabled
  def enabled?(feature_name)
    features = OrganizationFeature.matching_slugs(feature_name)
    return false unless features.present? && enabled_feature_slugs.is_a?(Array)
    features.all? { |feature| enabled_feature_slugs.include?(feature) }
  end

  # Done multiple places, so consolidating. Might be worth optimizing
  def any_enabled?(features)
    features.detect { |f| enabled?(f) }.present?
  end

  def set_calculated_attributes
    return true unless name.present?
    self.name = strip_name_tags(name)
    self.name = "Stop messing about" unless name[/\d|\w/].present?
    self.website = Urlifyer.urlify(website) if website.present?
    self.short_name = name_shortener(short_name || name)
    self.ascend_name = nil if ascend_name.blank?
    self.is_paid = current_invoices.any? || current_parent_invoices.any?
    self.kind ||= "other" # We need to always have a kind specified - generally we catch this, but just in case...
    self.passwordless_user_domain = EmailNormalizer.normalize(passwordless_user_domain)
    self.graduated_notification_interval = nil unless graduated_notification_interval.to_i > 0
    # For now, just use them. However - nesting organizations probably need slightly modified organization_feature slugs
    self.enabled_feature_slugs = calculated_enabled_feature_slugs.compact.sort
    new_slug = Slugifyer.slugify(short_name).delete_prefix("admin")
    if new_slug != slug
      # If the organization exists, don't invalidate because of it's own slug
      orgs = id.present? ? Organization.unscoped.where("id != ?", id) : Organization.unscoped.all
      # Force update the deleted short_names and slugs
      orgs.deleted.where.not("short_name ILIKE ?", "%-deleted")
        .each { |o| o.update_columns(short_name: "#{o.short_name}-deleted", slug: "#{o.slug}-deleted") }
      while orgs.where(slug: new_slug).exists?
        i = i.present? ? i + 1 : 2
        new_slug = "#{new_slug}-#{i}"
      end
      self.slug = new_slug
    end
    self.access_token ||= SecurityTokenizer.new_token
    # NOTE: only organizations with child_organizations feature can be selected in admin view, but this doesn't block assignment
    self.child_ids = calculated_children.pluck(:id).presence || []
    self.regional_ids = nearby_organizations_including_siblings.pluck(:id) || []
    set_auto_user
    self.location_latitude = default_location&.latitude
    self.location_longitude = default_location&.longitude
    set_ambassador_organization_defaults if ambassador?
  end

  def ensure_auto_user
    return true if auto_user.present?
    self.embedable_user_email = users.first && users.first.email || ENV["AUTO_ORG_MEMBER"]
    save
  end

  def incomplete_b_params
    BParam.where(organization_id: [child_ids, id].flatten.compact).partial_registrations.without_bike
  end

  # Can be improved later, for now just always get a location for the map
  def map_focus_coordinates
    {
      latitude: default_location&.latitude || 37.7870322,
      longitude: default_location&.longitude || -122.4061122
    }
  end

  def set_auto_user
    if embedable_user_email.present?
      u = User.fuzzy_email_find(embedable_user_email)
      self.auto_user_id = u.id if u&.member_of?(self)
      if auto_user_id.blank? && embedable_user_email == ENV["AUTO_ORG_MEMBER"]
        Membership.create(user_id: u.id, organization_id: id, role: "member")
        self.auto_user_id = u.id
      end
    elsif auto_user_id.blank?
      return nil unless users.any?
      self.auto_user_id = users.first.id
    end
  end

  def update_associations
    return true if skip_update
    UpdateOrganizationAssociationsWorker.perform_async(id)
  end

  private

  def nearby_organizations_including_siblings
    return self.class.none unless regional? && search_coordinates_set?
    self.class.within_bounding_box(bounding_box).where.not(id: child_ids + [id, parent_organization_id])
      .reorder(id: :asc)
  end

  def strip_name_tags(str)
    strip_tags(name&.strip).gsub("&amp;", "&")
  end

  def name_shortener(str)
    # Remove parens if the name is too long
    if str.length > 30 && str.match?(/\(.*\)/)
      str = str.gsub(/\(.*\)/, "")
    end
    str = str.gsub(/\s+/, " ").strip.truncate(30, omission: "", separator: " ").strip
    return str unless deleted_at.present?
    str.match?("-deleted") ? str : "#{str}-deleted"
  end

  def calculated_enabled_feature_slugs
    fslugs = current_invoices.feature_slugs
    # If part of a region with bike_stickers, the organization receives the stickers organization feature
    if regional_parents.any?
      if regional_parents.any? { |o| o.enabled?("bike_stickers") }
        fslugs += ["bike_stickers"]
        fslugs += ["bike_stickers_user_editable"] if regional_parents.any? { |o| o.enabled?("bike_stickers_user_editable") }
      end
    end
    # Ambassador orgs get unstolen_notifications
    fslugs += ["unstolen_notifications"] if ambassador?
    # Pull in the parent invoice features
    if parent_organization_id.present?
      fslugs += current_parent_invoices.map(&:child_enabled_feature_slugs).flatten
    end
    # If it has stickers, add reg_bike_sticker field
    fslugs += ["reg_bike_sticker"] if fslugs.include?("bike_stickers")

    if fslugs.include?("impound_bikes")
      # If impound_bikes enabled and there is a default location for impounding bikes, add impound_bikes_locations
      fslugs += ["impound_bikes_locations"] if locations.impound_locations.any?

      # Avoid loading impound_configuration on every request (since the menu needs to know)
      # Add a special feature, not included in organization_features
      fslugs += ["impound_bikes_public"] if impound_configuration&.public_view?
      # Also - don't fetch to avoid creating impound_configurations all the time
    end
    fslugs.uniq
  end

  def set_ambassador_organization_defaults
    self.show_on_map = false
    self.lock_show_on_map = false
    self.api_access_approved = false
    self.approved = true
    self.website = nil
    self.ascend_name = nil
    self.parent_organization_id = nil
  end
end