Class: Bible270::Reader

Inherits:
ApplicationRecord show all
Defined in:
app/models/bible270/reader.rb

Overview

A reader identity. Either self-contained (created via OmniAuth) or bridged to one of the host application's users through the polymorphic :owner.

Constant Summary collapse

AVATAR_UPLOADS =

Active Storage is optional: an app may have it disabled, and the engine has to keep working there — readers simply can't upload, and any avatar from their sign-in provider is used instead.

respond_to?(:has_one_attached)
PASSAGE_SOURCES =
%w[bible_gateway blue_letter_bible].freeze
DEFAULT_PASSAGE_SOURCE =
'bible_gateway'
DAILY_REMINDER_TIME_FORMAT =
%r{\A(?:[01]\d|2[0-3]):[0-5]\d\z}
DAILY_REMINDER_MINUTES =
%w[00 15 30 45].freeze
DAILY_REMINDER_COLUMNS =
%w[daily_reminders daily_reminder_time last_daily_reminder_sent_on].freeze
REFLECTIONS_SEEN_COLUMN =
'reflections_seen_at'

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.avatar_uploads?Boolean

Returns:

  • (Boolean)


20
# File 'app/models/bible270/reader.rb', line 20

def self.avatar_uploads? = AVATAR_UPLOADS

.completed_days_by_idObject



58
59
60
61
62
# File 'app/models/bible270/reader.rb', line 58

def self.completed_days_by_id
  Checkoff.group(:reader_id, :day).count.each_with_object(Hash.new(0)) do |((reader_id, day), done), totals|
    totals[reader_id] += 1 if done >= Plan.total_parts(day)
  end
end

.daily_reminder_columns?Boolean

Returns:

  • (Boolean)


48
49
50
51
52
# File 'app/models/bible270/reader.rb', line 48

def self.daily_reminder_columns?
  (DAILY_REMINDER_COLUMNS - column_names).empty?
rescue ActiveRecord::StatementInvalid
  false
end

.email_reader_exists?(email) ⇒ Boolean

Find or create the reader behind a verified email address. Uses the same provider/uid identity columns as OmniAuth, with provider "email", so an email reader is indistinguishable from any other downstream. Used when enrolment is closed: an existing reader may still sign in, a new one may not be created.

Returns:

  • (Boolean)


144
145
146
147
148
149
# File 'app/models/bible270/reader.rb', line 144

def self.email_reader_exists?(email)
  address = EmailSignIn.normalize_email(email)
  return false if address.nil?

  exists?(provider: 'email', uid: address)
end

.for_owner(owner, display_name:, email: nil, avatar_url: nil) ⇒ Object

Find or create a reader bridged to a host user (or any model).



190
191
192
193
194
195
196
197
# File 'app/models/bible270/reader.rb', line 190

def self.for_owner(owner, display_name:, email: nil, avatar_url: nil)
  reader = find_or_initialize_by(owner: owner)
  reader.display_name = display_name.presence || reader.display_name || 'Reader'
  reader.email      ||= email
  reader.avatar_url ||= avatar_url
  reader.save!
  reader
end

.from_email(email, first_name: nil, last_name: nil, display_name: nil) ⇒ Object



157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'app/models/bible270/reader.rb', line 157

def self.from_email(email, first_name: nil, last_name: nil, display_name: nil)
  address = EmailSignIn.normalize_email(email)
  return nil if address.nil?

  reader = find_or_initialize_by(provider: 'email', uid: address)
  reader.first_name = first_name.to_s.strip if first_name.present?
  reader.last_name  = last_name.to_s.strip  if last_name.present?
  reader.display_name = first_present(reader.full_name, display_name, reader.display_name,
                                      EmailSignIn.display_name_from(address), 'Reader')
  reader.email = address
  reader.save
  reader
end

.from_omniauth(auth) ⇒ Object

Build/refresh a reader from an OmniAuth auth hash. Tolerant of the various shapes strategies return (OmniAuth::AuthHash, plain Hash, missing info).



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'app/models/bible270/reader.rb', line 93

def self.from_omniauth(auth)
  provider = dig_auth(auth, :provider).to_s
  uid      = dig_auth(auth, :uid).to_s
  return nil if provider.empty? || uid.empty?

  info = dig_auth(auth, :info) || {}
  name = first_present(dig_auth(info, :name), dig_auth(info, :nickname),
                       dig_auth(info, :first_name), dig_auth(info, :email))

  reader = find_or_initialize_by(provider: provider, uid: uid)
  reader.display_name = first_present(name, reader.display_name, 'Reader')
  email = dig_auth(info, :email)
  image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
  reader.email      = email if email.present?
  reader.avatar_url = image if image.present?
  assign_names_from(reader, info)
  reader.save
  reader
end


290
291
292
293
294
295
296
297
298
299
300
# File 'app/models/bible270/reader.rb', line 290

def self.from_remember_cookie(reader_id, token)
  return nil if reader_id.blank? || token.blank?

  reader = find_by(id: reader_id)
  return nil if reader.nil? || reader.remember_token.blank?

  # Constant-time comparison: the token is a credential.
  return nil unless ActiveSupport::SecurityUtils.secure_compare(reader.remember_token, token.to_s)

  reader
end

.mentioned_in(text) ⇒ Object

The readers a piece of text mentions. A handle matching more than one reader resolves to nobody: mailing the wrong person is worse than mailing none, and the writer sees the mention left unlinked.



257
258
259
260
261
262
263
264
265
266
# File 'app/models/bible270/reader.rb', line 257

def self.mentioned_in(text)
  handles = Mentions.extract(text)
  return [] if handles.empty?

  candidates = where.not(first_name: [nil, '']).to_a
  handles.filter_map do |handle|
    matches = candidates.select { |reader| reader.answers_to?(handle) }
    matches.first if matches.one?
  end.uniq
end

.omniauth_reader_exists?(provider, uid) ⇒ Boolean

Returns:

  • (Boolean)


151
152
153
154
155
# File 'app/models/bible270/reader.rb', line 151

def self.omniauth_reader_exists?(provider, uid)
  return false if provider.blank? || uid.blank?

  exists?(provider: provider.to_s, uid: uid.to_s)
end

.reflections_seen_column?Boolean

Returns:

  • (Boolean)


64
65
66
67
68
# File 'app/models/bible270/reader.rb', line 64

def self.reflections_seen_column?
  column_names.include?(REFLECTIONS_SEEN_COLUMN)
rescue ActiveRecord::StatementInvalid
  false
end

.valid_daily_reminder_time?(value) ⇒ Boolean

Returns:

  • (Boolean)


54
55
56
# File 'app/models/bible270/reader.rb', line 54

def self.valid_daily_reminder_time?(value)
  value.to_s.match?(DAILY_REMINDER_TIME_FORMAT)
end

Instance Method Details

#answers_to?(handle) ⇒ Boolean

Returns:

  • (Boolean)


268
269
270
# File 'app/models/bible270/reader.rb', line 268

def answers_to?(handle)
  Mentions.handles_for(first_name, last_name).include?(handle)
end

#attach_avatar(upload) ⇒ Object

Returns false and sets an error when the upload isn't acceptable.



307
308
309
310
311
312
313
314
315
316
317
318
# File 'app/models/bible270/reader.rb', line 307

def attach_avatar(upload)
  return false unless self.class.avatar_uploads?

  problem = Avatars.problem_with(content_type: upload.content_type, byte_size: upload.size)
  if problem
    errors.add(:avatar, problem)
    return false
  end

  avatar.attach(upload)
  true
end

#avatar_uploaded?Boolean

Returns:

  • (Boolean)


302
303
304
# File 'app/models/bible270/reader.rb', line 302

def avatar_uploaded?
  self.class.avatar_uploads? && avatar.attached?
end

#bible_gateway?Boolean

Returns:

  • (Boolean)


234
# File 'app/models/bible270/reader.rb', line 234

def bible_gateway? = passage_source == 'bible_gateway'

#bible_version_labelObject



217
218
219
# File 'app/models/bible270/reader.rb', line 217

def bible_version_label
  Translations.label(effective_bible_version)
end

#blue_letter_bible?Boolean

Returns:

  • (Boolean)


235
# File 'app/models/bible270/reader.rb', line 235

def blue_letter_bible? = passage_source == 'blue_letter_bible'

#calendar_dayObject

The plan day that today corresponds to (clamped into range), or nil when undated.



544
545
546
# File 'app/models/bible270/reader.rb', line 544

def calendar_day
  Plan.day_for(Bible270.today, effective_start_date)
end

#checked_count(day) ⇒ Object

Tracks ticked on a day, counted from the single grouped query in checked_counts. Use this rather than read_tracks_for when rendering a grid: read_tracks_for costs a query per day, which is 270 of them per page.



337
338
339
# File 'app/models/bible270/reader.rb', line 337

def checked_count(day)
  checked_counts[day].to_i
end

#checked_countsObject

=> number of tracks checked off



330
331
332
# File 'app/models/bible270/reader.rb', line 330

def checked_counts
  @checked_counts ||= checkoffs.group(:day).count
end

#clear_day!(day) ⇒ Object



461
462
463
464
465
466
467
# File 'app/models/bible270/reader.rb', line 461

def clear_day!(day)
  return false unless Plan.valid_day?(day)

  checkoffs.where(day: day).destroy_all
  reload_progress
  true
end

#completion_percentObject



397
398
399
# File 'app/models/bible270/reader.rb', line 397

def completion_percent
  (days_completed.to_f / Plan::DAYS * 100).round
end

#current_dayObject

First day not yet fully complete (where the reader "is").



402
403
404
# File 'app/models/bible270/reader.rb', line 402

def current_day
  (1..Plan::DAYS).find { |d| !day_complete?(d) } || Plan::DAYS
end

#daily_reminder_due_at?(local_time) ⇒ Boolean

Returns:

  • (Boolean)


79
80
81
82
83
84
85
86
87
88
89
# File 'app/models/bible270/reader.rb', line 79

def daily_reminder_due_at?(local_time)
  return false unless self.class.daily_reminder_columns?
  return false unless daily_reminders?
  return false unless self.class.valid_daily_reminder_time?(daily_reminder_time)
  return false unless local_time.respond_to?(:hour) && local_time.respond_to?(:min)

  hour, minute = daily_reminder_time.split(':').map(&:to_i)
  (local_time.hour * 60) + local_time.min >= (hour * 60) + minute
rescue StandardError
  false
end

#date_for_day(day) ⇒ Object



579
580
581
# File 'app/models/bible270/reader.rb', line 579

def date_for_day(day)
  Plan.date_for(day, effective_start_date)
end

#dated?Boolean

Returns:

  • (Boolean)


534
535
536
# File 'app/models/bible270/reader.rb', line 534

def dated?
  effective_start_date.present?
end

#day_complete?(day) ⇒ Boolean

Returns:

  • (Boolean)


381
382
383
384
# File 'app/models/bible270/reader.rb', line 381

def day_complete?(day)
  n = checked_counts[day].to_i
  n.positive? && n >= Plan.total_parts(day)
end

#day_status(day) ⇒ Object



341
342
343
344
345
346
# File 'app/models/bible270/reader.rb', line 341

def day_status(day)
  done = checked_count(day)
  return :none if done.zero?

  done >= Plan.total_parts(day) ? :complete : :partial
end

#days_completedObject



386
387
388
# File 'app/models/bible270/reader.rb', line 386

def days_completed
  checked_counts.count { |day, n| n >= Plan.total_parts(day) }
end

#days_off_paceObject

How many days behind (positive) or ahead (negative) of the calendar the reader's actual progress is. Nil when undated.



585
586
587
588
589
590
# File 'app/models/bible270/reader.rb', line 585

def days_off_pace
  today = calendar_day
  return nil unless today

  today - days_completed
end

#days_read_in(track) ⇒ Object

Days on which this track is finished. Counting rows would count chapters now that an Old Testament reading has one per chapter.



392
393
394
395
# File 'app/models/bible270/reader.rb', line 392

def days_read_in(track)
  checkoffs.where(track: track.to_s).group(:day).count
    .count { |day, done| done >= Plan.part_count(day, track) }
end

#effective_bible_versionObject

The translation this reader reads in. Null means "whatever the site default is", so changing config.bible_version moves everyone who hasn't chosen.



213
214
215
# File 'app/models/bible270/reader.rb', line 213

def effective_bible_version
  Translations.resolve(bible_version)
end

#effective_start_dateObject

The start date that actually governs this reader. A reader's own started_on wins when per-reader dates are allowed; otherwise (or if they haven't got one) the current run's shared date applies. Nil means the plan is undated for this reader and no calendar mapping exists.



526
527
528
529
530
531
532
# File 'app/models/bible270/reader.rb', line 526

def effective_start_date
  if Bible270.config.allow_reader_start_date && started_on
    started_on
  else
    Setting.run_start_date
  end
end

#ensure_started!Object

Called when a reader first participates. Only stamps a personal start date when per-reader dates are enabled and a shared date isn't already in force.



614
615
616
617
618
619
620
# File 'app/models/bible270/reader.rb', line 614

def ensure_started!
  return unless Bible270.config.allow_reader_start_date
  return if started_on.present?
  return if Setting.run_start_date.present?

  update!(started_on: Bible270.today)
end

#first_with_last_initialObject

Shorter than the full name, for lists where a surname is more than needed. Falls back to the display name for readers who arrived with only one.



205
206
207
208
209
# File 'app/models/bible270/reader.rb', line 205

def first_with_last_initial
  return display_name if first_name.blank?

  Names.first_with_last_initial(first_name, last_name).presence || display_name
end

#forget!Object

Rotating the token invalidates every device at once.



285
286
287
288
# File 'app/models/bible270/reader.rb', line 285

def forget!
  update_column(:remember_token, nil)
  true
end

#full_nameObject



199
200
201
# File 'app/models/bible270/reader.rb', line 199

def full_name
  [first_name, last_name].map { |n| n.to_s.strip }.reject(&:empty?).join(' ').presence
end

#initialsObject



325
326
327
# File 'app/models/bible270/reader.rb', line 325

def initials
  display_name.to_s.split(%r{\s+}).first(2).map { |w| w[0] }.join.upcase.presence || '?'
end

#mark_day_complete!(day) ⇒ Object

Tick every track that has content on this day.



427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'app/models/bible270/reader.rb', line 427

def mark_day_complete!(day)
  return false unless Plan.valid_day?(day)

  # Deliberately not find_or_create_by!: since Rails 8.1 that creates first
  # and rescues RecordNotUnique, but Checkoff validates uniqueness, so a
  # duplicate raises RecordInvalid before the database is reached and is
  # never rescued. Reading what exists first is also one query per day rather
  # than one per chapter.
  missing = missing_parts_on(day)
  insert_checkoffs(day, missing) if missing.any?

  reload_progress
  true
end

#mark_reflections_seen!(at) ⇒ Object



70
71
72
73
74
75
76
77
# File 'app/models/bible270/reader.rb', line 70

def mark_reflections_seen!(at)
  return false unless self.class.reflections_seen_column?

  update_column(:reflections_seen_at, at)
  true
rescue ActiveRecord::StatementInvalid
  false
end

#mark_through!(day) ⇒ Object

Mark everything up to and including day complete, and clear anything after.



474
475
476
477
478
479
480
481
482
483
484
# File 'app/models/bible270/reader.rb', line 474

def mark_through!(day)
  day = day.to_i
  return false unless day.between?(0, Plan::DAYS)

  transaction do
    checkoffs.where(day: (day + 1)..).delete_all
    (1..day).each { |d| mark_day_complete!(d) }
  end
  reload_progress
  true
end

#not_started_yet?Boolean

Returns:

  • (Boolean)


567
568
569
# File 'app/models/bible270/reader.rb', line 567

def not_started_yet?
  Plan.before_start?(Bible270.today, effective_start_date)
end

#own_start_date?Boolean

Whether this reader is following a personal date or the shared cohort one.

Returns:

  • (Boolean)


539
540
541
# File 'app/models/bible270/reader.rb', line 539

def own_start_date?
  Bible270.config.allow_reader_start_date && started_on.present?
end

#partial_daysObject



348
349
350
# File 'app/models/bible270/reader.rb', line 348

def partial_days
  checked_counts.keys.select { |day| day_status(day) == :partial }.sort
end

#past_end_date?Boolean

Returns:

  • (Boolean)


571
572
573
# File 'app/models/bible270/reader.rb', line 571

def past_end_date?
  Plan.after_end?(Bible270.today, effective_start_date)
end

#plan_end_dateObject



575
576
577
# File 'app/models/bible270/reader.rb', line 575

def plan_end_date
  Plan.end_date_for(effective_start_date)
end

#raw_calendar_dayObject



563
564
565
# File 'app/models/bible270/reader.rb', line 563

def raw_calendar_day
  Plan.day_for(Bible270.today, effective_start_date, clamp: false)
end

#read?(day, track, part = nil) ⇒ Boolean

Returns:

  • (Boolean)


369
370
371
372
373
374
# File 'app/models/bible270/reader.rb', line 369

def read?(day, track, part = nil)
  return read_parts_for(day, track).include?(part) if part

  # No part given: the track counts as read only when every chapter is.
  read_parts_for(day, track).size >= Plan.part_count(day, track)
end

#read_parts_for(day, track) ⇒ Object

Which chapters of a track the reader has ticked on this day.



365
366
367
# File 'app/models/bible270/reader.rb', line 365

def read_parts_for(day, track)
  checkoffs.where(day: day, track: track.to_s).pluck(:part)
end

#read_tracks_for(day) ⇒ Object



360
361
362
# File 'app/models/bible270/reader.rb', line 360

def read_tracks_for(day)
  checkoffs.where(day: day).pluck(:track).uniq
end

#reload_progressObject



515
516
517
518
# File 'app/models/bible270/reader.rb', line 515

def reload_progress
  @checked_counts = nil
  self
end

#remaining_parts_for(day) ⇒ Object



352
353
354
355
356
357
358
# File 'app/models/bible270/reader.rb', line 352

def remaining_parts_for(day)
  return [] unless Plan.valid_day?(day)

  missing_parts_on(day).map do |track, part|
    { track: track, part: part, reference: Plan.parts_for(day, track).fetch(part) }
  end
end

#remember_token!Object

Generated on first use rather than at sign-up, so readers who never stay signed in never carry one.



276
277
278
279
280
281
282
# File 'app/models/bible270/reader.rb', line 276

def remember_token!
  return remember_token if remember_token.present?

  token = SecureRandom.urlsafe_base64(32)
  update_column(:remember_token, token)
  token
end

#remove_avatar!Object



320
321
322
323
# File 'app/models/bible270/reader.rb', line 320

def remove_avatar!
  avatar.purge if avatar_uploaded?
  true
end

#restart_on!(day:, on: Bible270.today) ⇒ Object

Put this reader on day as of on — i.e. back-date the start so that the given date lands on the given day of the plan.



488
489
490
491
492
493
# File 'app/models/bible270/reader.rb', line 488

def restart_on!(day:, on: Bible270.today)
  day = day.to_i
  return false unless Plan.valid_day?(day)

  update!(started_on: Plan.to_date(on) - (day - 1))
end

#set_start_date!(value) ⇒ Object

Set an individual start date for administrative use. This remains ungated so an administrator can prepare or preserve a personal calendar even when config.allow_reader_start_date currently makes the community date authoritative. Returns false only when the value isn't a date.



605
606
607
608
609
610
# File 'app/models/bible270/reader.rb', line 605

def set_start_date!(value)
  date = Plan.to_date(value)
  return false if date.nil?

  update!(started_on: date)
end

#sort_nameObject

Readers are listed by first name, matching how they are shown — the display name is "First Last", so sorting on it needs only case folding. Surname order was inconsistent with the community page and read oddly next to names displayed first-name-first.



241
242
243
# File 'app/models/bible270/reader.rb', line 241

def sort_name
  display_name.to_s.strip.downcase
end

#start_dateObject



597
598
599
# File 'app/models/bible270/reader.rb', line 597

def start_date
  started_on
end

#start_date=(value) ⇒ Object

Set or change this reader's own start date. Accepts a Date or a string.



593
594
595
# File 'app/models/bible270/reader.rb', line 593

def start_date=(value)
  self.started_on = Plan.to_date(value)
end

#suggested_namesObject

Fill first/last from the display name where we only have one string, e.g. a reader who arrived through OmniAuth.



418
419
420
421
422
# File 'app/models/bible270/reader.rb', line 418

def suggested_names
  return { first_name: first_name, last_name: last_name } if full_name

  Names.split_display_name(display_name) || { first_name: display_name, last_name: nil }
end

#today?(day) ⇒ Boolean

Returns:

  • (Boolean)


559
560
561
# File 'app/models/bible270/reader.rb', line 559

def today?(day)
  today_day == day
end

#today_dayObject

Raw, unclamped — lets callers distinguish "not started yet" / "finished". The plan day that today actually is, or nil when today falls outside the plan's window. calendar_day clamps, so before the start date it reports day 1 — which made day 1 claim to be "today" for anyone whose plan hadn't begun.



552
553
554
555
556
557
# File 'app/models/bible270/reader.rb', line 552

def today_day
  raw = raw_calendar_day
  return nil if raw.nil?

  Plan.valid_day?(raw) ? raw : nil
end

#toggle_day!(day) ⇒ Object



469
470
471
# File 'app/models/bible270/reader.rb', line 469

def toggle_day!(day)
  day_complete?(day) ? clear_day!(day) : mark_day_complete!(day)
end

#track_partially_read?(day, track) ⇒ Boolean

Returns:

  • (Boolean)


376
377
378
379
# File 'app/models/bible270/reader.rb', line 376

def track_partially_read?(day, track)
  done = read_parts_for(day, track).size
  done.positive? && done < Plan.part_count(day, track)
end

#update_bible_version(code) ⇒ Object



221
222
223
224
225
# File 'app/models/bible270/reader.rb', line 221

def update_bible_version(code)
  return false unless Translations.valid?(code)

  update(bible_version: Translations.normalize(code))
end

#update_names(first, last) ⇒ Object

Day number implied by the calendar, if a start date is set. Set the name shown beside this reader's reflections. Returns false when either half is missing, so callers can re-render with a message.



409
410
411
412
413
414
# File 'app/models/bible270/reader.rb', line 409

def update_names(first, last)
  names = Names.normalize(first, last)
  return false if names.nil?

  update(**names)
end

#update_passage_source(source) ⇒ Object



227
228
229
230
231
232
# File 'app/models/bible270/reader.rb', line 227

def update_passage_source(source)
  source = source.to_s
  return false unless PASSAGE_SOURCES.include?(source)

  update(passage_source: source)
end

#wants_comment_notifications?Boolean

Older copied databases may briefly lack the preference column while their migrations are being reconciled. Preserve the historical opted-in behavior until the column is available rather than breaking reflection delivery.

Returns:

  • (Boolean)


250
251
252
# File 'app/models/bible270/reader.rb', line 250

def wants_comment_notifications?
  !has_attribute?(:notify_on_mention) || self[:notify_on_mention] != false
end