dblock/slack-sup

View on GitHub
slack-sup/models/team.rb

Summary

Maintainability
D
2 days
Test Coverage
class Team
  # enable API for this team
  field :api, type: Boolean, default: false
  field :api_token, type: String

  # sup size
  field :sup_size, type: Integer, default: 3
  # sup remaining odd users
  field :sup_odd, type: Boolean, default: true
  # sup frequency in weeks
  field :sup_time_of_day, type: Integer, default: 9 * 60 * 60
  field :sup_every_n_weeks, type: Integer, default: 1
  field :sup_recency, type: Integer, default: 12
  # sup day of the week, defaults to Monday
  field :sup_wday, type: Integer, default: 1
  # sup day of the week we ask for sup results, defaults to Thursday
  field :sup_followup_wday, type: Integer, default: 4
  field :sup_tz, type: String, default: 'Eastern Time (US & Canada)'
  validates_presence_of :sup_tz

  field :sup_message, type: String

  field :opt_in, type: Boolean, default: true

  # sync
  field :sync, type: Boolean, default: false
  field :last_sync_at, type: DateTime

  # custom team field
  field :team_field_label, type: String
  field :team_field_label_id, type: String

  field :stripe_customer_id, type: String
  field :subscribed, type: Boolean, default: false
  field :subscribed_at, type: DateTime

  scope :api, -> { where(api: true) }

  has_many :users, dependent: :destroy
  has_many :rounds, dependent: :destroy
  has_many :sups, dependent: :destroy

  after_update :subscribed!
  after_save :activated!

  before_validation :validate_team_field_label
  before_validation :validate_team_field_label_id
  before_validation :validate_sup_time_of_day
  before_validation :validate_sup_every_n_weeks
  before_validation :validate_sup_size
  before_validation :validate_sup_recency

  def tags
    [
      subscribed? ? 'subscribed' : 'trial',
      stripe_customer_id? ? 'paid' : nil
    ].compact
  end

  def bot_name
    client = server.send(:client) if server
    name = client.self.name if client&.self
    name ||= 'sup'
    "@#{name}"
  end

  def api_url
    return unless api?

    "#{SlackRubyBotServer::Service.api_url}/teams/#{id}"
  end

  def short_lived_token
    JWT.encode({ dt: Time.now.utc.to_i }, token)
  end

  def short_lived_token_valid?(short_lived_token, dt = 30.minutes)
    return false unless short_lived_token

    data, = JWT.decode(short_lived_token, token)
    Time.at(data['dt']).utc + dt >= Time.now.utc
  end

  def api_s
    api? ? 'on' : 'off'
  end

  def opt_in_s
    opt_in? ? 'in' : 'out'
  end

  def asleep?(dt = 3.weeks)
    return false unless subscription_expired?

    time_limit = Time.now.utc - dt
    created_at <= time_limit
  end

  def sup!
    sync!
    rounds.create!
  end

  def ask!
    round = last_round
    return unless round&.ask?

    round.ask!
    round
  end

  def ask_again!
    round = last_round
    return unless round&.ask_again?

    round.ask_again!
    round
  end

  def remind!
    round = last_round
    return unless round&.remind?

    round.remind!
    round
  end

  def last_round
    rounds.desc(:created_at).first
  end

  def last_round_at
    round = last_round
    round ? round.created_at : nil
  end

  def sup_time_of_day_s
    Time.at(sup_time_of_day).utc.strftime('%l:%M %p').strip
  end

  def sup_every_n_weeks_s
    sup_every_n_weeks == 1 ? 'week' : "#{sup_every_n_weeks} weeks"
  end

  def sup_recency_s
    sup_recency == 1 ? 'week' : "#{sup_recency} weeks"
  end

  def sup_day
    Date::DAYNAMES[sup_wday]
  end

  def sup_followup_day
    Date::DAYNAMES[sup_followup_wday]
  end

  def sup_tzone
    ActiveSupport::TimeZone.new(sup_tz)
  end

  def sup_tzone_s
    Time.now.in_time_zone(sup_tzone).strftime('%Z')
  end

  # is it time to sup?
  def sup?
    # only sup on a certain day of the week
    now_in_tz = Time.now.utc.in_time_zone(sup_tzone)
    return false unless now_in_tz.wday == sup_wday
    # sup after 9am by default
    return false if now_in_tz < now_in_tz.beginning_of_day + sup_time_of_day

    # don't sup more than once a week
    time_limit = Time.now.utc - sup_every_n_weeks.weeks
    (last_round_at || time_limit) <= time_limit
  end

  def next_sup_at
    now_in_tz = Time.now.utc.in_time_zone(sup_tzone)
    loop do
      time_limit = now_in_tz.end_of_day - sup_every_n_weeks.weeks

      return now_in_tz.beginning_of_day + sup_time_of_day if (now_in_tz.wday == sup_wday) &&
                                                             ((last_round_at || time_limit) <= time_limit)

      now_in_tz = now_in_tz.beginning_of_day + 1.day
    end
  end

  def inform!(message)
    members = slack_client.paginate(:users_list, presence: false).map(&:members).flatten
    members.select(&:is_admin).each do |admin|
      channel = slack_client.conversations_open(users: admin.id.to_s)
      logger.info "Sending DM '#{message}' to #{admin.name}."
      slack_client.chat_postMessage(text: message, channel: channel.channel.id, as_user: true)
    end
  end

  def team_admins
    users.in(user_id: activated_user_id).or(is_admin: true).or(is_owner: true)
  end

  def team_admins_slack_mentions
    (["<@#{activated_user_id}>"] + team_admins.map(&:slack_mention)).uniq.or
  end

  def subscription_expired?
    return false if subscribed?

    (created_at + 2.weeks) < Time.now
  end

  def update_cc_text
    "Update your credit card info at #{SlackRubyBotServer::Service.url}/update_cc?team_id=#{team_id}."
  end

  def sync_user!(id)
    member = slack_client.users_info(user: id).user
    return unless active_member?(member)

    human = sync_member_from_slack!(member)
    state = if human.persisted?
              human.enabled? ? 'active' : 'back'
            else
              'new'
            end
    logger.info "Team #{self}: #{human} is #{state}."
    human.enabled = true
    human.save!
  rescue StandardError => e
    logger.warn "Error synchronizing user for #{self}, id=#{id}: #{e.message}."
  end

  # synchronize users with slack
  def sync!
    tt = Time.now.utc
    members = slack_client.paginate(:users_list, presence: false).map(&:members).flatten
    humans = members.select { |member| active_member?(member) }.map do |member|
      sync_member_from_slack!(member)
    end
    humans.each do |human|
      state = if human.persisted?
                human.enabled? ? 'active' : 'back'
              else
                'new'
              end
      logger.info "Team #{self}: #{human} is #{state}."
      human.enabled = true
      human.save!
    end
    (users - humans).each do |dead_user|
      next unless dead_user.enabled?

      logger.info "Team #{self}: #{dead_user} was disabled."
      dead_user.enabled = false
      dead_user.save!
    end
    update_attributes!(sync: false, last_sync_at: tt)
  end

  def slack_client
    @client ||= Slack::Web::Client.new(token: token)
  end

  def stripe_customer
    return unless stripe_customer_id

    @stripe_customer ||= Stripe::Customer.retrieve(stripe_customer_id)
  end

  def active_stripe_subscription?
    !active_stripe_subscription.nil?
  end

  def active_stripe_subscription
    return unless stripe_customer

    stripe_customer.subscriptions.detect do |subscription|
      subscription.status == 'active' && !subscription.cancel_at_period_end
    end
  end

  def stripe_subcriptions
    return unless stripe_customer

    stripe_customer.subscriptions
  end

  def stripe_customer_text
    "Customer since #{Time.at(stripe_customer.created).strftime('%B %d, %Y')}."
  end

  def subscriber_text
    return unless subscribed_at

    "Subscriber since #{subscribed_at.strftime('%B %d, %Y')}."
  end

  def stripe_customer_subscriptions_info(with_unsubscribe = false)
    stripe_customer.subscriptions.map do |subscription|
      amount = ActiveSupport::NumberHelper.number_to_currency(subscription.plan.amount.to_f / 100)
      current_period_end = Time.at(subscription.current_period_end).strftime('%B %d, %Y')
      if subscription.status == 'active'
        [
          "Subscribed to #{subscription.plan.name} (#{amount}), will#{subscription.cancel_at_period_end ? ' not' : ''} auto-renew on #{current_period_end}.",
          !subscription.cancel_at_period_end && with_unsubscribe ? "Send `unsubscribe #{subscription.id}` to unsubscribe." : nil
        ].compact.join("\n")
      else
        "#{subscription.status.titleize} subscription created #{Time.at(subscription.created).strftime('%B %d, %Y')} to #{subscription.plan.name} (#{amount})."
      end
    end
  end

  def stripe_customer_invoices_info
    stripe_customer.invoices.map do |invoice|
      amount = ActiveSupport::NumberHelper.number_to_currency(invoice.amount_due.to_f / 100)
      "Invoice for #{amount} on #{Time.at(invoice.date).strftime('%B %d, %Y')}, #{invoice.paid ? 'paid' : 'unpaid'}."
    end
  end

  def stripe_customer_sources_info
    stripe_customer.sources.map do |source|
      "On file #{source.brand} #{source.object}, #{source.name} ending with #{source.last4}, expires #{source.exp_month}/#{source.exp_year}."
    end
  end

  def trial_ends_at
    raise 'Team is subscribed.' if subscribed?

    created_at + 2.weeks
  end

  def remaining_trial_days
    raise 'Team is subscribed.' if subscribed?

    [0, (trial_ends_at.to_date - Time.now.utc.to_date).to_i].max
  end

  def trial_message
    [
      remaining_trial_days.zero? ? 'Your trial subscription has expired.' : "Your trial subscription expires in #{remaining_trial_days} day#{remaining_trial_days == 1 ? '' : 's'}.",
      subscribe_text
    ].join(' ')
  end

  def subscribe_text
    "Subscribe your team for $39.99 a year at #{SlackRubyBotServer::Service.url}/subscribe?team_id=#{team_id}."
  end

  def next_sup_at_text
    [
      'Next round is',
      Time.now > next_sup_at ? 'overdue' : nil,
      next_sup_at.strftime('%A, %B %e, %Y at %l:%M %p %Z').gsub('  ', ' '),
      '(' + next_sup_at.to_time.ago_or_future_in_words(highest_measure_only: true) + ').'
    ].compact.join(' ')
  end

  def last_sync_at_text
    tt = last_sync_at || last_round_at
    messages = []
    if tt
      messages << "Last users sync was #{tt.to_time.ago_in_words}."
      users = self.users.where(:updated_at.gte => last_sync_at)
      messages << "#{pluralize(users.count, 'user')} updated."
    end
    if sync
      messages << 'Users will sync in the next hour.'
    else
      messages << 'Users will sync before the next round.'
      messages << next_sup_at_text
    end
    messages.compact.join(' ')
  end

  private

  def pluralize(count, text)
    case count
    when 0
      "No #{text.pluralize}"
    when 1
      "#{count} #{text}"
    else
      "#{count} #{text.pluralize}"
    end
  end

  def sync_member_from_slack!(member)
    existing_user = User.where(user_id: member.id).first
    existing_user ||= User.new(user_id: member.id, team: self, opted_in: opt_in)
    existing_user.user_name = member.name
    existing_user.real_name = member.real_name
    existing_user.email = member.profile.email if member.profile
    begin
      existing_user.update_custom_profile
    rescue StandardError => e
      logger.warn "Error updating custom profile for #{existing_user}: #{e.message}."
    end
    existing_user
  end

  def active_member?(member)
    !member.is_bot &&
      !member.deleted &&
      !member.is_restricted &&
      !member.is_ultra_restricted &&
      !on_vacation?(member) &&
      member.id != 'USLACKBOT'
  end

  def on_vacation?(member)
    [member.name, member.real_name, member&.profile&.status_text].compact.join =~ /(ooo|vacationing)/i
  end

  def validate_team_field_label
    return unless team_field_label && team_field_label_changed?

    client = Slack::Web::Client.new(token: activated_user_access_token)
    profile_fields = client.team_profile_get.profile.fields
    label = profile_fields.detect { |field| field.label.casecmp(team_field_label.downcase).zero? }
    if label
      self.team_field_label_id = label.id
      self.team_field_label = label.label
    else
      errors.add(:team_field_label, "Custom profile team field _#{team_field_label}_ is invalid. Possible values are _#{profile_fields.map(&:label).join('_, _')}_.")
    end
  end

  def validate_team_field_label_id
    self.team_field_label_id = nil unless team_field_label
  end

  def validate_sup_time_of_day
    return if sup_time_of_day && sup_time_of_day > 0 && sup_time_of_day < 24 * 60 * 60

    errors.add(:sup_time_of_day, "S'Up time of day _#{sup_time_of_day}_ is invalid.")
  end

  def validate_sup_every_n_weeks
    return if sup_every_n_weeks >= 1

    errors.add(:sup_every_n_weeks, "S'Up every _#{sup_every_n_weeks}_ is invalid, must be at least 1.")
  end

  def validate_sup_recency
    return if sup_recency >= 1

    errors.add(:sup_recency, "Don't S'Up with the same people more than every _#{sup_recency_s}_ is invalid, must be at least 1.")
  end

  def validate_sup_size
    return if sup_size >= 2

    errors.add(:sup_size, "S'Up for _#{sup_size}_ is invalid, requires at least 2 people to meet.")
  end

  INSTALLED_TEXT =
    "Hi there! I'm your team's S'Up bot. " \
    'Thanks for trying me out. Type `help` for instructions. ' \
    "I plan to setup some S'Ups via Slack DM next Monday. " \
    'You may want to `set size`, `set day`, `set timezone`, or `set sync now` users before then.'.freeze

  SUBSCRIBED_TEXT =
    "Hi there! I'm your team's S'Up bot. " \
    'Your team has purchased a yearly subscription. ' \
    'Follow us on Twitter at https://twitter.com/playplayio for news and updates. ' \
    'Thanks for being a customer!'.freeze

  def subscribed!
    return unless subscribed? && subscribed_changed?

    inform! SUBSCRIBED_TEXT
  end

  def activated!
    return unless active? && activated_user_id && bot_user_id
    return unless active_changed? || activated_user_id_changed?
  end
end