bible270 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +181 -0
  3. data/MIT-LICENSE +18 -0
  4. data/README.md +373 -0
  5. data/Rakefile +11 -0
  6. data/app/controllers/bible270/application_controller.rb +48 -0
  7. data/app/controllers/bible270/checkoffs_controller.rb +32 -0
  8. data/app/controllers/bible270/comments_controller.rb +46 -0
  9. data/app/controllers/bible270/days_controller.rb +42 -0
  10. data/app/controllers/bible270/readers_controller.rb +46 -0
  11. data/app/controllers/bible270/sessions_controller.rb +126 -0
  12. data/app/helpers/bible270/plan_helper.rb +45 -0
  13. data/app/mailers/bible270/application_mailer.rb +7 -0
  14. data/app/mailers/bible270/sign_in_mailer.rb +14 -0
  15. data/app/models/bible270/application_record.rb +6 -0
  16. data/app/models/bible270/checkoff.rb +23 -0
  17. data/app/models/bible270/comment.rb +16 -0
  18. data/app/models/bible270/reader.rb +211 -0
  19. data/app/models/bible270/sign_in_token.rb +74 -0
  20. data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +4 -0
  21. data/app/views/bible270/comments/_comment.html.erb +15 -0
  22. data/app/views/bible270/comments/_form.html.erb +13 -0
  23. data/app/views/bible270/comments/create.turbo_stream.erb +7 -0
  24. data/app/views/bible270/comments/destroy.turbo_stream.erb +1 -0
  25. data/app/views/bible270/days/_reading.html.erb +25 -0
  26. data/app/views/bible270/days/_start_date.html.erb +43 -0
  27. data/app/views/bible270/days/index.html.erb +72 -0
  28. data/app/views/bible270/days/show.html.erb +50 -0
  29. data/app/views/bible270/readers/index.html.erb +24 -0
  30. data/app/views/bible270/readers/show.html.erb +41 -0
  31. data/app/views/bible270/sessions/new.html.erb +52 -0
  32. data/app/views/bible270/shared/_header.html.erb +20 -0
  33. data/app/views/bible270/shared/_styles.html.erb +147 -0
  34. data/app/views/bible270/sign_in_mailer/magic_link.html.erb +20 -0
  35. data/app/views/bible270/sign_in_mailer/magic_link.text.erb +7 -0
  36. data/app/views/layouts/bible270/application.html.erb +28 -0
  37. data/config/routes.rb +30 -0
  38. data/db/migrate/20260101000001_create_bible270_readers.rb +17 -0
  39. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +15 -0
  40. data/db/migrate/20260101000003_create_bible270_comments.rb +14 -0
  41. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +18 -0
  42. data/lib/bible270/configuration.rb +177 -0
  43. data/lib/bible270/email_sign_in.rb +68 -0
  44. data/lib/bible270/engine.rb +21 -0
  45. data/lib/bible270/plan.rb +352 -0
  46. data/lib/bible270/versification.rb +83 -0
  47. data/lib/bible270/version.rb +4 -0
  48. data/lib/bible270.rb +9 -0
  49. data/lib/generators/bible270/install/install_generator.rb +66 -0
  50. data/lib/generators/bible270/install/templates/bible270.rb.tt +41 -0
  51. data/lib/generators/bible270/install/templates/omniauth.rb.tt +25 -0
  52. metadata +158 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ class CommentsController < ApplicationController
4
+ def create
5
+ return unless require_reader!
6
+
7
+ @day = params[:day].to_i
8
+ head :bad_request and return unless Plan.valid_day?(@day)
9
+
10
+ @comment = current_reader.comments.new(comment_params.merge(day: @day))
11
+ @readings = Plan.readings_for(@day)
12
+
13
+ if @comment.save
14
+ respond_to do |format|
15
+ format.turbo_stream
16
+ format.html { redirect_to day_path(@day, anchor: "comment-#{@comment.id}") }
17
+ end
18
+ else
19
+ respond_to do |format|
20
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("new_comment_form", partial: "bible270/comments/form", locals: { day: @day, comment: @comment }) }
21
+ format.html { redirect_to day_path(@day), alert: @comment.errors.full_messages.to_sentence }
22
+ end
23
+ end
24
+ end
25
+
26
+ def destroy
27
+ return unless require_reader!
28
+
29
+ @comment = current_reader.comments.find_by(id: params[:id])
30
+ head :not_found and return unless @comment
31
+
32
+ @day = @comment.day
33
+ @comment.destroy
34
+ respond_to do |format|
35
+ format.turbo_stream
36
+ format.html { redirect_to day_path(@day) }
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def comment_params
43
+ params.require(:comment).permit(:body, :track)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ class DaysController < ApplicationController
4
+ def index
5
+ @plan_totals = Plan.totals
6
+
7
+ # Community leaderboard (small-community friendly; see README on scaling).
8
+ @readers = Reader.order(updated_at: :desc).to_a
9
+ counts = Checkoff.group(:reader_id, :day).count
10
+ @days_completed = Hash.new(0)
11
+ counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.required_track_count(day) }
12
+ @readers = @readers.sort_by { |r| -@days_completed[r.id] }
13
+
14
+ @start_day = current_reader&.current_day || 1
15
+ @today_day = current_reader&.calendar_day
16
+ @community_start_date = Bible270.config.start_date
17
+ @allow_reader_start_date = Bible270.config.allow_reader_start_date
18
+ end
19
+
20
+ def show
21
+ @day = params[:day].to_i
22
+ unless Plan.valid_day?(@day)
23
+ redirect_to(root_path, alert: "That day is outside the plan.") and return
24
+ end
25
+
26
+ @readings = Plan.readings_for(@day)
27
+ @comments = Comment.for_day(@day).includes(:reader)
28
+ @new_comment = Comment.new(day: @day)
29
+ @reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
30
+
31
+ req = Plan.required_track_count(@day)
32
+ completer_ids = Checkoff.where(day: @day)
33
+ .group(:reader_id)
34
+ .having("COUNT(*) >= ?", req)
35
+ .pluck(:reader_id)
36
+ @completers = Reader.where(id: completer_ids).order(:display_name)
37
+
38
+ @prev_day = @day > 1 ? @day - 1 : nil
39
+ @next_day = @day < Plan::DAYS ? @day + 1 : nil
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ class ReadersController < ApplicationController
4
+ def index
5
+ @readers = Reader.all.to_a
6
+ counts = Checkoff.group(:reader_id, :day).count
7
+ @days_completed = Hash.new(0)
8
+ counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.required_track_count(day) }
9
+ @readers.sort_by! { |r| [-@days_completed[r.id], r.display_name.to_s] }
10
+ end
11
+
12
+ def show
13
+ @reader = Reader.find_by(id: params[:id])
14
+ redirect_to(community_path, alert: "Reader not found.") and return unless @reader
15
+
16
+ @days_completed = @reader.days_completed
17
+ @recent_comments = @reader.comments.recent.limit(20)
18
+ end
19
+
20
+ def update_start_date
21
+ return unless require_reader!
22
+
23
+ unless Bible270.config.allow_reader_start_date
24
+ redirect_to(root_path, alert: "The start date is set for the whole community.") and return
25
+ end
26
+
27
+ if current_reader.update_start_date!(params[:start_date])
28
+ redirect_to root_path,
29
+ notice: "Start date set to #{current_reader.started_on.strftime('%B %-d, %Y')}."
30
+ else
31
+ redirect_to root_path, alert: "That doesn't look like a valid date."
32
+ end
33
+ end
34
+
35
+ def clear_start_date
36
+ return unless require_reader!
37
+
38
+ if Bible270.config.allow_reader_start_date
39
+ current_reader.clear_start_date!
40
+ redirect_to root_path, notice: "Start date cleared — the plan is now undated for you."
41
+ else
42
+ redirect_to root_path, alert: "The start date is set for the whole community."
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ # Built-in OmniAuth sign-in.
4
+ #
5
+ # The OmniAuth middleware (registered by the host app) handles the request and
6
+ # callback phases; by the time #create runs, request.env["omniauth.auth"] is
7
+ # populated and we just need to turn it into a Reader and remember them.
8
+ #
9
+ # NOTE: OmniAuth 2.0+ only permits POST to its request-phase routes, so all
10
+ # sign-in controls in these views are forms/buttons rather than links.
11
+ class SessionsController < ApplicationController
12
+ def new
13
+ redirect_to(after_sign_in_path, notice: "You're already signed in.") and return if signed_in?
14
+
15
+ @providers = Bible270.config.omniauth_providers
16
+ @origin = safe_origin(params[:origin])
17
+ end
18
+
19
+ def create
20
+ auth = request.env["omniauth.auth"]
21
+ unless auth
22
+ redirect_to(sign_in_path, alert: "Sign in didn't complete. Please try again.") and return
23
+ end
24
+
25
+ reader = Reader.from_omniauth(auth)
26
+ unless reader&.persisted?
27
+ redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return
28
+ end
29
+
30
+ destination = safe_origin(request.env["omniauth.origin"]) || after_sign_in_path
31
+
32
+ # Rotate the session id to avoid session fixation, then sign in.
33
+ reset_session
34
+ session[:bible270_reader_id] = reader.id
35
+ @current_reader = reader
36
+
37
+ redirect_to destination, notice: "Welcome, #{reader.display_name}."
38
+ end
39
+
40
+ def destroy
41
+ reset_session
42
+ @current_reader = nil
43
+ redirect_to(after_sign_out_path, notice: "Signed out.")
44
+ end
45
+
46
+ def failure
47
+ message = params[:message].presence || "unknown error"
48
+ redirect_to sign_in_path, alert: "Sign in failed (#{message.to_s.tr('_', ' ')})."
49
+ end
50
+
51
+ # ---- email (magic link) sign-in ---------------------------------------
52
+
53
+ # POST: accept an address and send a one-time link.
54
+ def email_link
55
+ unless Bible270.config.email_sign_in?
56
+ redirect_to(sign_in_path, alert: "Email sign-in isn't enabled here.") and return
57
+ end
58
+
59
+ origin = safe_origin(params[:origin])
60
+ address = EmailSignIn.normalize_email(params[:email])
61
+ if address.nil?
62
+ redirect_to(sign_in_path(origin: origin),
63
+ alert: "That doesn't look like an email address — please check and try again.") and return
64
+ end
65
+
66
+ name = Bible270.config.email_sign_in_ask_name ? params[:display_name].to_s.strip : nil
67
+ _record, raw = SignInToken.issue!(address, display_name: name)
68
+
69
+ if raw
70
+ url = email_sign_in_url(token: raw, origin: origin)
71
+ minutes = (Bible270.config.email_sign_in_ttl / 60.0).round
72
+ mail = SignInMailer.magic_link(email: address, url: url, expires_in_minutes: minutes)
73
+ Bible270.config.email_sign_in_deliver_later ? mail.deliver_later : mail.deliver_now
74
+ end
75
+
76
+ # Deliberately identical whether or not a link was actually sent, so this
77
+ # reveals neither who has an account nor that a limit was hit.
78
+ redirect_to sign_in_path,
79
+ notice: "Check your inbox — if that address is valid we've sent a sign-in link to #{address}."
80
+ end
81
+
82
+ # GET: the reader clicked the link.
83
+ def email_callback
84
+ token = SignInToken.claim!(params[:token])
85
+ if token.nil?
86
+ redirect_to(sign_in_path,
87
+ alert: "That link has expired or was already used. Please request a new one.") and return
88
+ end
89
+
90
+ reader = Reader.from_email(token.email, display_name: token.display_name)
91
+ unless reader&.persisted?
92
+ redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return
93
+ end
94
+
95
+ destination = safe_origin(params[:origin]) || after_sign_in_path
96
+
97
+ reset_session
98
+ session[:bible270_reader_id] = reader.id
99
+ @current_reader = reader
100
+
101
+ redirect_to destination, notice: "Welcome, #{reader.display_name}."
102
+ end
103
+
104
+ private
105
+
106
+ # Only ever redirect within this app, and never back into an auth route.
107
+ def safe_origin(value)
108
+ value = value.to_s
109
+ return nil if value.empty?
110
+ return nil unless value.start_with?("/")
111
+ return nil if value.include?("//") # protocol-relative URL
112
+ return nil if value.include?("\\") # browsers may normalise \ to /
113
+ return nil if value.include?("/auth/")
114
+
115
+ value
116
+ end
117
+
118
+ def after_sign_in_path
119
+ Bible270.config.after_sign_in_path || root_path
120
+ end
121
+
122
+ def after_sign_out_path
123
+ Bible270.config.after_sign_out_path || root_path
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ module PlanHelper
4
+ def b270_track(track)
5
+ Plan::TRACKS.fetch(track.to_s)
6
+ end
7
+
8
+ # Path that begins the OmniAuth request phase. Must be POSTed to: OmniAuth
9
+ # 2.0+ refuses GET on its own routes (CVE-2015-9284), so every sign-in
10
+ # control in these views is a button_to, not a link_to.
11
+ def b270_omniauth_path(provider)
12
+ prefix = Bible270.config.omniauth_path_prefix || "#{request.script_name}/auth"
13
+ "#{prefix}/#{provider}"
14
+ end
15
+
16
+ def b270_provider_label(provider)
17
+ Bible270.config.label_for_provider(provider)
18
+ end
19
+
20
+ def b270_passage_url(reference)
21
+ return "#" if reference.blank?
22
+ Bible270.config.passage_url_builder.call(reference, Bible270.config.bible_version)
23
+ end
24
+
25
+ def b270_avatar(reader, size: 34)
26
+ if reader.avatar_url.present?
27
+ image_tag reader.avatar_url, class: "b270-avatar", width: size, height: size, alt: reader.display_name
28
+ else
29
+ content_tag :span, reader.initials, class: "b270-avatar b270-avatar-fallback",
30
+ style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
31
+ end
32
+ end
33
+
34
+ def b270_day_status_class(reader, day)
35
+ return "" unless reader
36
+ if reader.day_complete?(day)
37
+ "complete"
38
+ elsif reader.read_tracks_for(day).any?
39
+ "partial"
40
+ else
41
+ ""
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: -> { Bible270.config.mailer_from }
5
+ layout nil
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ class SignInMailer < ApplicationMailer
4
+ # The URL is built by the controller (which has the request context), so the
5
+ # mailer needs no default_url_options of its own.
6
+ def magic_link(email:, url:, expires_in_minutes:)
7
+ @url = url
8
+ @expires_in_minutes = expires_in_minutes
9
+ @app_name = Bible270.config.app_name
10
+
11
+ mail to: email, subject: "Your sign-in link for #{@app_name}"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ # A single reader marking one track (ot / nt / pp) read on one day.
4
+ class Checkoff < ApplicationRecord
5
+ self.table_name = "bible270_checkoffs"
6
+
7
+ belongs_to :reader, class_name: "Bible270::Reader"
8
+
9
+ validates :day, inclusion: { in: 1..Plan::DAYS }
10
+ validates :track, inclusion: { in: Plan::TRACKS.keys }
11
+ validates :track, uniqueness: { scope: %i[reader_id day] }
12
+ validate :track_present_on_day
13
+
14
+ private
15
+
16
+ def track_present_on_day
17
+ return if day.nil? || track.nil?
18
+ return if Plan.present_tracks(day).include?(track)
19
+
20
+ errors.add(:track, "is not part of day #{day}")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ # A publicly visible reflection attached to a plan day (optionally a track).
4
+ class Comment < ApplicationRecord
5
+ self.table_name = "bible270_comments"
6
+
7
+ belongs_to :reader, class_name: "Bible270::Reader"
8
+
9
+ validates :day, inclusion: { in: 1..Plan::DAYS }
10
+ validates :body, presence: true, length: { maximum: 4000 }
11
+ validates :track, inclusion: { in: Plan::TRACKS.keys }, allow_nil: true
12
+
13
+ scope :for_day, ->(d) { where(day: d).order(created_at: :asc) }
14
+ scope :recent, -> { order(created_at: :desc) }
15
+ end
16
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ # A reader identity. Either self-contained (created via OmniAuth) or bridged
4
+ # to one of the host application's users through the polymorphic :owner.
5
+ class Reader < ApplicationRecord
6
+ self.table_name = "bible270_readers"
7
+
8
+ has_many :checkoffs, class_name: "Bible270::Checkoff", dependent: :destroy
9
+ has_many :comments, class_name: "Bible270::Comment", dependent: :destroy
10
+ belongs_to :owner, polymorphic: true, optional: true
11
+
12
+ validates :display_name, presence: true
13
+ validates :uid, uniqueness: { scope: :provider }, allow_nil: true
14
+
15
+ # Build/refresh a reader from an OmniAuth auth hash. Tolerant of the various
16
+ # shapes strategies return (OmniAuth::AuthHash, plain Hash, missing info).
17
+ def self.from_omniauth(auth)
18
+ provider = dig_auth(auth, :provider).to_s
19
+ uid = dig_auth(auth, :uid).to_s
20
+ return nil if provider.empty? || uid.empty?
21
+
22
+ info = dig_auth(auth, :info) || {}
23
+ name = first_present(dig_auth(info, :name), dig_auth(info, :nickname),
24
+ dig_auth(info, :first_name), dig_auth(info, :email))
25
+
26
+ reader = find_or_initialize_by(provider: provider, uid: uid)
27
+ reader.display_name = first_present(name, reader.display_name, "Reader")
28
+ email = dig_auth(info, :email)
29
+ image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
30
+ reader.email = email if email.present?
31
+ reader.avatar_url = image if image.present?
32
+ reader.save
33
+ reader
34
+ end
35
+
36
+ # Find or create the reader behind a verified email address. Uses the same
37
+ # provider/uid identity columns as OmniAuth, with provider "email", so an
38
+ # email reader is indistinguishable from any other downstream.
39
+ def self.from_email(email, display_name: nil)
40
+ address = EmailSignIn.normalize_email(email)
41
+ return nil if address.nil?
42
+
43
+ reader = find_or_initialize_by(provider: "email", uid: address)
44
+ chosen = first_present(display_name, reader.display_name,
45
+ EmailSignIn.display_name_from(address), "Reader")
46
+ reader.display_name = chosen
47
+ reader.email = address
48
+ reader.save
49
+ reader
50
+ end
51
+
52
+ def self.dig_auth(obj, key)
53
+ return nil if obj.nil?
54
+ if obj.respond_to?(:[])
55
+ obj[key] || (obj[key.to_s] if key.is_a?(Symbol))
56
+ elsif obj.respond_to?(key)
57
+ obj.public_send(key)
58
+ end
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ private_class_method :dig_auth
63
+
64
+ def self.first_present(*values)
65
+ values.compact.find { |v| v.to_s.strip != "" }
66
+ end
67
+ private_class_method :first_present
68
+
69
+ # Find or create a reader bridged to a host user (or any model).
70
+ def self.for_owner(owner, display_name:, email: nil, avatar_url: nil)
71
+ reader = find_or_initialize_by(owner: owner)
72
+ reader.display_name = display_name.presence || reader.display_name || "Reader"
73
+ reader.email ||= email
74
+ reader.avatar_url ||= avatar_url
75
+ reader.save!
76
+ reader
77
+ end
78
+
79
+ def initials
80
+ display_name.to_s.split(/\s+/).first(2).map { |w| w[0] }.join.upcase.presence || "?"
81
+ end
82
+
83
+ # {day => number of tracks checked off}
84
+ def checked_counts
85
+ @checked_counts ||= checkoffs.group(:day).count
86
+ end
87
+
88
+ def read_tracks_for(day)
89
+ checkoffs.where(day: day).pluck(:track)
90
+ end
91
+
92
+ def read?(day, track)
93
+ read_tracks_for(day).include?(track.to_s)
94
+ end
95
+
96
+ def day_complete?(day)
97
+ n = checked_counts[day].to_i
98
+ n.positive? && n >= Plan.required_track_count(day)
99
+ end
100
+
101
+ def days_completed
102
+ checked_counts.count { |day, n| n >= Plan.required_track_count(day) }
103
+ end
104
+
105
+ def days_read_in(track)
106
+ checkoffs.where(track: track.to_s).count
107
+ end
108
+
109
+ def completion_percent
110
+ (days_completed.to_f / Plan::DAYS * 100).round
111
+ end
112
+
113
+ # First day not yet fully complete (where the reader "is").
114
+ def current_day
115
+ (1..Plan::DAYS).find { |d| !day_complete?(d) } || Plan::DAYS
116
+ end
117
+
118
+ # Day number implied by the calendar, if a start date is set.
119
+ # ---- start date / calendar ------------------------------------------
120
+
121
+ # The start date that actually governs this reader. A reader's own
122
+ # started_on wins when per-reader dates are allowed; otherwise (or if they
123
+ # haven't got one) the community-wide config.start_date applies. Nil means
124
+ # the plan is undated for this reader and no calendar mapping exists.
125
+ def effective_start_date
126
+ config = Bible270.config
127
+ if config.allow_reader_start_date && started_on
128
+ started_on
129
+ else
130
+ config.start_date
131
+ end
132
+ end
133
+
134
+ def dated?
135
+ effective_start_date.present?
136
+ end
137
+
138
+ # Whether this reader is following a personal date or the shared cohort one.
139
+ def own_start_date?
140
+ Bible270.config.allow_reader_start_date && started_on.present?
141
+ end
142
+
143
+ # The plan day that today corresponds to (clamped into range), or nil when undated.
144
+ def calendar_day
145
+ Plan.day_for(Date.current, effective_start_date)
146
+ end
147
+
148
+ # Raw, unclamped — lets callers distinguish "not started yet" / "finished".
149
+ def raw_calendar_day
150
+ Plan.day_for(Date.current, effective_start_date, clamp: false)
151
+ end
152
+
153
+ def not_started_yet?
154
+ Plan.before_start?(Date.current, effective_start_date)
155
+ end
156
+
157
+ def past_end_date?
158
+ Plan.after_end?(Date.current, effective_start_date)
159
+ end
160
+
161
+ def plan_end_date
162
+ Plan.end_date_for(effective_start_date)
163
+ end
164
+
165
+ def date_for_day(day)
166
+ Plan.date_for(day, effective_start_date)
167
+ end
168
+
169
+ # How many days behind (positive) or ahead (negative) of the calendar the
170
+ # reader's actual progress is. Nil when undated.
171
+ def days_off_pace
172
+ today = calendar_day
173
+ return nil unless today
174
+
175
+ today - days_completed
176
+ end
177
+
178
+ # Set or change this reader's own start date. Accepts a Date or a string.
179
+ def start_date=(value)
180
+ self.started_on = Plan.to_date(value)
181
+ end
182
+
183
+ def start_date
184
+ started_on
185
+ end
186
+
187
+ def update_start_date!(value)
188
+ return false unless Bible270.config.allow_reader_start_date
189
+
190
+ date = Plan.to_date(value)
191
+ return false if date.nil?
192
+
193
+ update!(started_on: date)
194
+ end
195
+
196
+ def clear_start_date!
197
+ update!(started_on: nil)
198
+ end
199
+
200
+ # Called when a reader first participates. Only stamps a personal start date
201
+ # when per-reader dates are enabled and a shared date isn't already in force.
202
+ def ensure_started!
203
+ config = Bible270.config
204
+ return unless config.allow_reader_start_date
205
+ return if started_on.present?
206
+ return if config.start_date.present?
207
+
208
+ update!(started_on: Date.current)
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ # A single-use, short-lived magic-link token for email sign-in.
4
+ #
5
+ # Only the SHA-256 digest of the token is stored, so the table is useless to
6
+ # an attacker who reads it. Tokens are consumed on first successful use.
7
+ class SignInToken < ApplicationRecord
8
+ self.table_name = "bible270_sign_in_tokens"
9
+
10
+ validates :email, presence: true
11
+ validates :token_digest, presence: true, uniqueness: true
12
+ validates :expires_at, presence: true
13
+
14
+ scope :unconsumed, -> { where(consumed_at: nil) }
15
+ scope :live, -> { unconsumed.where(expires_at: Time.current..) }
16
+
17
+ # Issue a token for an email. Returns [token_record, raw_token], or
18
+ # [nil, nil] when the address is being hammered (see rate_limited?).
19
+ def self.issue!(email, display_name: nil)
20
+ address = EmailSignIn.normalize_email(email)
21
+ return [nil, nil] if address.nil?
22
+ return [nil, nil] if rate_limited?(address)
23
+
24
+ raw = EmailSignIn.generate_token
25
+ record = create!(
26
+ email: address,
27
+ display_name: display_name.presence,
28
+ token_digest: EmailSignIn.digest_token(raw),
29
+ expires_at: Time.current + Bible270.config.email_sign_in_ttl
30
+ )
31
+ [record, raw]
32
+ end
33
+
34
+ # Look up a raw token and consume it. Returns the token record on success
35
+ # (carrying email and any requested display name), nil for anything
36
+ # invalid, expired, or already used.
37
+ def self.claim!(raw_token)
38
+ digest = EmailSignIn.digest_token(raw_token)
39
+ return nil if digest.nil?
40
+
41
+ record = live.find_by(token_digest: digest)
42
+ return nil if record.nil?
43
+
44
+ # Consume it: only the update that actually flips the row wins, so a
45
+ # double-clicked link can't sign in twice.
46
+ claimed = unconsumed.where(id: record.id).update_all(consumed_at: Time.current)
47
+ return nil if claimed.zero?
48
+
49
+ record
50
+ end
51
+
52
+ # Too many outstanding requests for one address in the window.
53
+ def self.rate_limited?(address)
54
+ window = Bible270.config.email_sign_in_window
55
+ max = Bible270.config.email_sign_in_max_per_window
56
+ where(email: address).where(created_at: (Time.current - window)..).count >= max
57
+ end
58
+
59
+ # Housekeeping: drop spent and stale rows. Safe to run from a cron/rake task.
60
+ def self.sweep!(older_than: 7 * 24 * 60 * 60)
61
+ where(expires_at: ...Time.current).or(where.not(consumed_at: nil))
62
+ .where(created_at: ...(Time.current - older_than))
63
+ .delete_all
64
+ end
65
+
66
+ def expired?
67
+ expires_at.nil? || expires_at < Time.current
68
+ end
69
+
70
+ def consumed?
71
+ consumed_at.present?
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,4 @@
1
+ <%= turbo_stream.replace "reading_#{@day}_#{@track}" do %>
2
+ <%= render partial: "bible270/days/reading", locals: {
3
+ track: @track, day: @day, readings: @readings, reader_tracks: @reader_tracks } %>
4
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <div class="b270-comment" id="comment-<%= comment.id %>">
2
+ <%= b270_avatar(comment.reader) %>
3
+ <div style="flex:1">
4
+ <div class="b270-cmeta">
5
+ <strong><%= link_to comment.reader.display_name, reader_path(comment.reader), style: "text-decoration:none" %></strong>
6
+ · <%= comment.created_at.strftime("%b %-d, %Y") %>
7
+ <% if comment.track.present? %>· <%= b270_track(comment.track)[:label] %><% end %>
8
+ <% if signed_in? && comment.reader_id == current_reader.id %>
9
+ <%= button_to "delete", comment_path(comment), method: :delete, class: "b270-cdel",
10
+ form: { style: "display:inline", data: { turbo_stream: true } } %>
11
+ <% end %>
12
+ </div>
13
+ <p class="b270-cbody"><%= comment.body %></p>
14
+ </div>
15
+ </div>