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)

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.avatar_uploads?Boolean

Returns:

  • (Boolean)


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

def self.avatar_uploads? = AVATAR_UPLOADS

.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)


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

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).



102
103
104
105
106
107
108
109
# File 'app/models/bible270/reader.rb', line 102

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



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

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).



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'app/models/bible270/reader.rb', line 32

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?
  reader.save
  reader
end


161
162
163
164
165
166
167
168
169
170
171
# File 'app/models/bible270/reader.rb', line 161

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

.omniauth_reader_exists?(provider, uid) ⇒ Boolean

Returns:

  • (Boolean)


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

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

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

Instance Method Details

#attach_avatar(upload) ⇒ Object

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



178
179
180
181
182
183
184
185
186
187
188
189
# File 'app/models/bible270/reader.rb', line 178

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)


173
174
175
# File 'app/models/bible270/reader.rb', line 173

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

#bible_version_labelObject



129
130
131
# File 'app/models/bible270/reader.rb', line 129

def bible_version_label
  Translations.label(effective_bible_version)
end

#calendar_dayObject

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



404
405
406
# File 'app/models/bible270/reader.rb', line 404

def calendar_day
  Plan.day_for(Date.current, 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.



208
209
210
# File 'app/models/bible270/reader.rb', line 208

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

#checked_countsObject

=> number of tracks checked off



201
202
203
# File 'app/models/bible270/reader.rb', line 201

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

#clear_day!(day) ⇒ Object



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

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

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

#clear_start_date!Object



479
480
481
# File 'app/models/bible270/reader.rb', line 479

def clear_start_date!
  update!(started_on: nil)
end

#completion_percentObject



256
257
258
# File 'app/models/bible270/reader.rb', line 256

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

#current_dayObject

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



261
262
263
# File 'app/models/bible270/reader.rb', line 261

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

#date_for_day(day) ⇒ Object



439
440
441
# File 'app/models/bible270/reader.rb', line 439

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

#dated?Boolean

Returns:

  • (Boolean)


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

def dated?
  effective_start_date.present?
end

#day_complete?(day) ⇒ Boolean

Returns:

  • (Boolean)


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

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

#day_status(day) ⇒ Object



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

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

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

#days_completedObject



245
246
247
# File 'app/models/bible270/reader.rb', line 245

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.



445
446
447
448
449
450
# File 'app/models/bible270/reader.rb', line 445

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.



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

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.



125
126
127
# File 'app/models/bible270/reader.rb', line 125

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 community-wide config.start_date applies. Nil means the plan is undated for this reader and no calendar mapping exists.



385
386
387
388
389
390
391
392
# File 'app/models/bible270/reader.rb', line 385

def effective_start_date
  config = Bible270.config
  if config.allow_reader_start_date && started_on
    started_on
  else
    config.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.



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

def ensure_started!
  config = Bible270.config
  return unless config.allow_reader_start_date
  return if started_on.present?
  return if config.start_date.present?

  update!(started_on: Date.current)
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.



117
118
119
120
121
# File 'app/models/bible270/reader.rb', line 117

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.



156
157
158
159
# File 'app/models/bible270/reader.rb', line 156

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

#full_nameObject



111
112
113
# File 'app/models/bible270/reader.rb', line 111

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

#initialsObject



196
197
198
# File 'app/models/bible270/reader.rb', line 196

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.



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

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_through!(day) ⇒ Object

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



333
334
335
336
337
338
339
340
341
342
343
# File 'app/models/bible270/reader.rb', line 333

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)


427
428
429
# File 'app/models/bible270/reader.rb', line 427

def not_started_yet?
  Plan.before_start?(Date.current, effective_start_date)
end

#own_start_date?Boolean

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

Returns:

  • (Boolean)


399
400
401
# File 'app/models/bible270/reader.rb', line 399

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

#past_end_date?Boolean

Returns:

  • (Boolean)


431
432
433
# File 'app/models/bible270/reader.rb', line 431

def past_end_date?
  Plan.after_end?(Date.current, effective_start_date)
end

#plan_end_dateObject



435
436
437
# File 'app/models/bible270/reader.rb', line 435

def plan_end_date
  Plan.end_date_for(effective_start_date)
end

#raw_calendar_dayObject



423
424
425
# File 'app/models/bible270/reader.rb', line 423

def raw_calendar_day
  Plan.day_for(Date.current, effective_start_date, clamp: false)
end

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

Returns:

  • (Boolean)


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

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.



224
225
226
# File 'app/models/bible270/reader.rb', line 224

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

#read_tracks_for(day) ⇒ Object



219
220
221
# File 'app/models/bible270/reader.rb', line 219

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

#reload_progressObject



374
375
376
377
# File 'app/models/bible270/reader.rb', line 374

def reload_progress
  @checked_counts = nil
  self
end

#remember_token!Object

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



147
148
149
150
151
152
153
# File 'app/models/bible270/reader.rb', line 147

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



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

def remove_avatar!
  avatar.purge if avatar_uploaded?
  true
end

#restart_on!(day:, on: Date.current) ⇒ 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.



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

def restart_on!(day:, on: Date.current)
  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 the start date regardless of whether readers are allowed to set their own. For administrative use: config.allow_reader_start_date governs what a reader may do to themselves, not what an admin may do on their behalf. Returns false only when the value isn't a date.



465
466
467
468
469
470
# File 'app/models/bible270/reader.rb', line 465

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

  update!(started_on: date)
end

#sort_nameObject



139
140
141
# File 'app/models/bible270/reader.rb', line 139

def sort_name
  [last_name, first_name].map { |n| n.to_s.strip.downcase }.join(' ').strip.presence || display_name.to_s.downcase
end

#start_dateObject



457
458
459
# File 'app/models/bible270/reader.rb', line 457

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.



453
454
455
# File 'app/models/bible270/reader.rb', line 453

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.



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

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)


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

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.



412
413
414
415
416
417
# File 'app/models/bible270/reader.rb', line 412

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

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

#toggle_day!(day) ⇒ Object



328
329
330
# File 'app/models/bible270/reader.rb', line 328

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

#track_partially_read?(day, track) ⇒ Boolean

Returns:

  • (Boolean)


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

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



133
134
135
136
137
# File 'app/models/bible270/reader.rb', line 133

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.



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

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

  update(**names)
end

#update_start_date!(value) ⇒ Object

The reader-facing version, which does respect that permission.



473
474
475
476
477
# File 'app/models/bible270/reader.rb', line 473

def update_start_date!(value)
  return false unless Bible270.config.allow_reader_start_date

  set_start_date!(value)
end