-
Notifications
You must be signed in to change notification settings - Fork 9
/
team.rb
483 lines (388 loc) · 13.4 KB
/
team.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
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(team_id: id, user_id: activated_user_id)
.or(team_id: id, is_admin: true)
.or(team_id: id, 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 = users.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