bible270 0.6.3 → 0.10.0

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -164
  3. data/README.md +287 -272
  4. data/Rakefile +5 -4
  5. data/app/controllers/bible270/admin_controller.rb +119 -0
  6. data/app/controllers/bible270/application_controller.rb +19 -2
  7. data/app/controllers/bible270/checkoffs_controller.rb +2 -3
  8. data/app/controllers/bible270/comments_controller.rb +6 -2
  9. data/app/controllers/bible270/days_controller.rb +5 -6
  10. data/app/controllers/bible270/readers_controller.rb +5 -4
  11. data/app/controllers/bible270/sessions_controller.rb +60 -26
  12. data/app/helpers/bible270/plan_helper.rb +11 -8
  13. data/app/mailers/bible270/application_mailer.rb +4 -1
  14. data/app/mailers/bible270/sign_in_mailer.rb +1 -0
  15. data/app/models/bible270/application_record.rb +1 -0
  16. data/app/models/bible270/checkoff.rb +4 -3
  17. data/app/models/bible270/comment.rb +26 -5
  18. data/app/models/bible270/reader.rb +78 -14
  19. data/app/models/bible270/sign_in_token.rb +5 -2
  20. data/app/views/bible270/admin/comments.html.erb +44 -0
  21. data/app/views/bible270/admin/index.html.erb +30 -0
  22. data/app/views/bible270/admin/show.html.erb +89 -0
  23. data/app/views/bible270/days/index.html.erb +10 -13
  24. data/app/views/bible270/readers/index.html.erb +2 -2
  25. data/app/views/bible270/readers/show.html.erb +5 -5
  26. data/app/views/bible270/sessions/new.html.erb +9 -3
  27. data/app/views/bible270/shared/_header.html.erb +1 -0
  28. data/app/views/bible270/shared/_styles.html.erb +10 -7
  29. data/app/views/layouts/bible270/application.html.erb +4 -3
  30. data/config/routes.rb +32 -18
  31. data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
  32. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
  33. data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
  34. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
  35. data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
  36. data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
  37. data/lib/bible270/configuration.rb +91 -19
  38. data/lib/bible270/email_sign_in.rb +7 -7
  39. data/lib/bible270/engine.rb +1 -0
  40. data/lib/bible270/plan.rb +449 -150
  41. data/lib/bible270/versification.rb +84 -67
  42. data/lib/bible270/version.rb +2 -1
  43. data/lib/bible270.rb +6 -5
  44. data/lib/generators/bible270/install/install_generator.rb +520 -39
  45. data/lib/generators/bible270/install/templates/bible270.rb.tt +8 -2
  46. data/lib/generators/bible270/install/templates/omniauth.rb.tt +6 -4
  47. metadata +17 -29
data/Rakefile CHANGED
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
- require "bundler/gem_tasks" # provides build / install / release
3
- require "rake/testtask"
2
+
3
+ require 'bundler/gem_tasks' # provides build / install / release
4
+ require 'rake/testtask'
4
5
 
5
6
  Rake::TestTask.new(:test) do |t|
6
- t.libs << "test" << "lib"
7
- t.pattern = "test/**/*_test.rb"
7
+ t.libs << 'test' << 'lib'
8
+ t.pattern = 'test/**/*_test.rb'
8
9
  t.warning = false
9
10
  end
10
11
 
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # Small administrative panel: remove readers, adjust their completions, and
5
+ # move them to a given day of the plan.
6
+ #
7
+ # Access is controlled by Bible270.config.admin_emails / admin_resolver. With
8
+ # neither set the panel is unreachable, and every action 404s rather than 403s
9
+ # so its existence isn't advertised.
10
+ class AdminController < ApplicationController
11
+ before_action :require_admin!
12
+ before_action :load_reader, only: %i[show destroy update_start complete_through toggle_day]
13
+ before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
14
+
15
+ def index
16
+ @readers = Reader.all.to_a.sort_by(&:sort_name)
17
+ counts = Checkoff.group(:reader_id, :day).count
18
+ @days_completed = Hash.new(0)
19
+ counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.required_track_count(day) }
20
+ end
21
+
22
+ def show
23
+ @days_completed = @reader.days_completed
24
+ @comments = @reader.comments.order(created_at: :desc).limit(50)
25
+ end
26
+
27
+ # ---- reflections ------------------------------------------------------
28
+
29
+ # Everything is visible by default; this lists it all so anything unsuitable
30
+ # can be taken down.
31
+ def comments
32
+ scope = Comment.includes(:reader).order(created_at: :desc)
33
+ @filter = params[:filter].to_s
34
+ scope = scope.hidden if @filter == 'hidden'
35
+ scope = scope.approved if @filter == 'visible'
36
+ @comments = scope.limit(200)
37
+ @hidden_count = Comment.hidden.count
38
+ end
39
+
40
+ def hide_comment
41
+ @comment.hide!
42
+ redirect_back_to_comments "Hidden #{@comment.reader.display_name}'s reflection on day #{@comment.day}."
43
+ end
44
+
45
+ def unhide_comment
46
+ @comment.unhide!
47
+ redirect_back_to_comments "Restored #{@comment.reader.display_name}'s reflection on day #{@comment.day}."
48
+ end
49
+
50
+ def destroy_comment
51
+ day = @comment.day
52
+ @comment.destroy
53
+ redirect_back_to_comments "Deleted the reflection on day #{day}."
54
+ end
55
+
56
+ def destroy
57
+ name = @reader.display_name
58
+ @reader.destroy
59
+ redirect_to admin_path, notice: "Removed #{name} and all their check-offs and reflections."
60
+ end
61
+
62
+ # Put the reader on a given day as of today, or set an explicit start date.
63
+ def update_start
64
+ if params[:start_date].present?
65
+ if @reader.update_start_date!(params[:start_date])
66
+ redirect_to admin_reader_path(@reader), notice: 'Start date updated.'
67
+ else
68
+ redirect_to admin_reader_path(@reader), alert: "That doesn't look like a date."
69
+ end
70
+ elsif params[:day].present? && @reader.restart_on!(day: params[:day])
71
+ redirect_to admin_reader_path(@reader),
72
+ notice: "#{@reader.display_name} is now on day #{params[:day]}."
73
+ else
74
+ redirect_to admin_reader_path(@reader), alert: 'Give a start date or a day between 1 and 270.'
75
+ end
76
+ end
77
+
78
+ def complete_through
79
+ day = params[:day].to_i
80
+ if @reader.mark_through!(day)
81
+ redirect_to admin_reader_path(@reader),
82
+ notice: day.zero? ? 'All check-offs cleared.' : "Marked days 1-#{day} complete."
83
+ else
84
+ redirect_to admin_reader_path(@reader), alert: 'Give a day between 0 and 270.'
85
+ end
86
+ end
87
+
88
+ def toggle_day
89
+ day = params[:day].to_i
90
+ if @reader.toggle_day!(day)
91
+ redirect_to admin_reader_path(@reader, anchor: "day-#{day}")
92
+ else
93
+ redirect_to admin_reader_path(@reader), alert: 'That day is outside the plan.'
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def require_admin!
100
+ return if Bible270.config.admin_configured? && Bible270.config.admin?(current_reader)
101
+
102
+ raise ActionController::RoutingError, 'Not Found'
103
+ end
104
+
105
+ def load_comment
106
+ @comment = Comment.find_by(id: params[:id])
107
+ redirect_to(admin_comments_path, alert: 'Reflection not found.') if @comment.nil?
108
+ end
109
+
110
+ def redirect_back_to_comments(notice)
111
+ redirect_to(params[:return_to].presence || admin_comments_path, notice: notice)
112
+ end
113
+
114
+ def load_reader
115
+ @reader = Reader.find_by(id: params[:id])
116
+ redirect_to(admin_path, alert: 'Reader not found.') if @reader.nil?
117
+ end
118
+ end
119
+ end
@@ -1,11 +1,28 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class ApplicationController < Bible270.config.parent_controller.constantize
5
+ # Rails only auto-includes an isolated engine's helpers when the controller's
6
+ # superclass is *exactly* ActionController::Base:
7
+ #
8
+ # # ActionController::Railties::Helpers#inherited
9
+ # klass.helpers_path = namespace.railtie_helpers_paths
10
+ # klass.helper :all if klass.superclass == ActionController::Base && ...
11
+ #
12
+ # config.parent_controller normally points at the host's ApplicationController,
13
+ # so that check fails and the b270_* helpers are never mixed in — every view
14
+ # calling one then raises NoMethodError. helpers_path is still set to this
15
+ # engine's app/helpers, so asking for them explicitly is all that's needed.
16
+ helper :all
17
+ # ...and name it outright, in case helpers_path resolves to the host app's
18
+ # helpers rather than this engine's.
19
+ helper Bible270::PlanHelper
20
+
4
21
  layout :bible270_layout
5
22
 
6
23
  helper_method :current_reader, :signed_in?, :b270_config
7
24
 
8
- private
25
+ private
9
26
 
10
27
  def bible270_layout
11
28
  Bible270.config.layout
@@ -39,7 +56,7 @@ module Bible270
39
56
  format.turbo_stream { head :unauthorized }
40
57
  format.html do
41
58
  redirect_to sign_in_path(origin: request.fullpath),
42
- alert: "Please sign in to take part."
59
+ alert: 'Please sign in to take part.'
43
60
  end
44
61
  end
45
62
  false
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class CheckoffsController < ApplicationController
4
5
  def toggle
@@ -7,9 +8,7 @@ module Bible270
7
8
  @day = params[:day].to_i
8
9
  @track = params[:track].to_s
9
10
 
10
- unless Plan.valid_day?(@day) && Plan.present_tracks(@day).include?(@track)
11
- head :bad_request and return
12
- end
11
+ head :bad_request and return unless Plan.valid_day?(@day) && Plan.present_tracks(@day).include?(@track)
13
12
 
14
13
  existing = current_reader.checkoffs.find_by(day: @day, track: @track)
15
14
  if existing
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class CommentsController < ApplicationController
4
5
  def create
@@ -17,7 +18,10 @@ module Bible270
17
18
  end
18
19
  else
19
20
  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.turbo_stream do
22
+ render turbo_stream: turbo_stream.replace('new_comment_form', partial: 'bible270/comments/form',
23
+ locals: { day: @day, comment: @comment })
24
+ end
21
25
  format.html { redirect_to day_path(@day), alert: @comment.errors.full_messages.to_sentence }
22
26
  end
23
27
  end
@@ -37,7 +41,7 @@ module Bible270
37
41
  end
38
42
  end
39
43
 
40
- private
44
+ private
41
45
 
42
46
  def comment_params
43
47
  params.require(:comment).permit(:body, :track)
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class DaysController < ApplicationController
4
5
  def index
@@ -19,9 +20,7 @@ module Bible270
19
20
 
20
21
  def show
21
22
  @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
23
+ redirect_to(root_path, alert: 'That day is outside the plan.') and return unless Plan.valid_day?(@day)
25
24
 
26
25
  @readings = Plan.readings_for(@day)
27
26
  @comments = Comment.for_day(@day).includes(:reader)
@@ -30,9 +29,9 @@ module Bible270
30
29
 
31
30
  req = Plan.required_track_count(@day)
32
31
  completer_ids = Checkoff.where(day: @day)
33
- .group(:reader_id)
34
- .having("COUNT(*) >= ?", req)
35
- .pluck(:reader_id)
32
+ .group(:reader_id)
33
+ .having('COUNT(*) >= ?', req)
34
+ .pluck(:reader_id)
36
35
  @completers = Reader.where(id: completer_ids).order(:display_name)
37
36
 
38
37
  @prev_day = @day > 1 ? @day - 1 : nil
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class ReadersController < ApplicationController
4
5
  def index
@@ -11,7 +12,7 @@ module Bible270
11
12
 
12
13
  def show
13
14
  @reader = Reader.find_by(id: params[:id])
14
- redirect_to(community_path, alert: "Reader not found.") and return unless @reader
15
+ redirect_to(community_path, alert: 'Reader not found.') and return unless @reader
15
16
 
16
17
  @days_completed = @reader.days_completed
17
18
  @recent_comments = @reader.comments.recent.limit(20)
@@ -21,7 +22,7 @@ module Bible270
21
22
  return unless require_reader!
22
23
 
23
24
  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
+ redirect_to(root_path, alert: 'The start date is set for the whole community.') and return
25
26
  end
26
27
 
27
28
  if current_reader.update_start_date!(params[:start_date])
@@ -37,9 +38,9 @@ module Bible270
37
38
 
38
39
  if Bible270.config.allow_reader_start_date
39
40
  current_reader.clear_start_date!
40
- redirect_to root_path, notice: "Start date cleared — the plan is now undated for you."
41
+ redirect_to root_path, notice: 'Start date cleared — the plan is now undated for you.'
41
42
  else
42
- redirect_to root_path, alert: "The start date is set for the whole community."
43
+ redirect_to root_path, alert: 'The start date is set for the whole community.'
43
44
  end
44
45
  end
45
46
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # Built-in OmniAuth sign-in.
4
5
  #
@@ -17,17 +18,13 @@ module Bible270
17
18
  end
18
19
 
19
20
  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
21
+ auth = request.env['omniauth.auth']
22
+ redirect_to(sign_in_path, alert: "Sign in didn't complete. Please try again.") and return unless auth
24
23
 
25
24
  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
25
+ redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return unless reader&.persisted?
29
26
 
30
- destination = safe_origin(request.env["omniauth.origin"]) || after_sign_in_path
27
+ destination = safe_origin(request.env['omniauth.origin']) || after_sign_in_path
31
28
 
32
29
  # Rotate the session id to avoid session fixation, then sign in.
33
30
  reset_session
@@ -40,11 +37,11 @@ module Bible270
40
37
  def destroy
41
38
  reset_session
42
39
  @current_reader = nil
43
- redirect_to(after_sign_out_path, notice: "Signed out.")
40
+ redirect_to(after_sign_out_path, notice: 'Signed out.')
44
41
  end
45
42
 
46
43
  def failure
47
- message = params[:message].presence || "unknown error"
44
+ message = params[:message].presence || 'unknown error'
48
45
  redirect_to sign_in_path, alert: "Sign in failed (#{message.to_s.tr('_', ' ')})."
49
46
  end
50
47
 
@@ -63,14 +60,22 @@ module Bible270
63
60
  alert: "That doesn't look like an email address — please check and try again.") and return
64
61
  end
65
62
 
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)
63
+ first_name = params[:first_name].to_s.strip
64
+ last_name = params[:last_name].to_s.strip
65
+
66
+ if Bible270.config.email_sign_in_require_name && (first_name.empty? || last_name.empty?)
67
+ redirect_to(sign_in_path(origin: origin, email: address),
68
+ alert: 'Please give both your first and last name.') and return
69
+ end
70
+
71
+ _record, raw = SignInToken.issue!(address, first_name: first_name, last_name: last_name)
68
72
 
69
73
  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
+ deliver_sign_in_link(address, email_sign_in_url(token: raw, origin: origin))
75
+ else
76
+ # Silent to the reader by design; say so in the log or this is undebuggable.
77
+ Rails.logger.warn("[bible270] no sign-in link issued for #{address} " \
78
+ '(rate limited — see config.email_sign_in_max_per_window)')
74
79
  end
75
80
 
76
81
  # Deliberately identical whether or not a link was actually sent, so this
@@ -84,13 +89,12 @@ module Bible270
84
89
  token = SignInToken.claim!(params[:token])
85
90
  if token.nil?
86
91
  redirect_to(sign_in_path,
87
- alert: "That link has expired or was already used. Please request a new one.") and return
92
+ alert: 'That link has expired or was already used. Please request a new one.') and return
88
93
  end
89
94
 
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
95
+ reader = Reader.from_email(token.email, first_name: token.first_name,
96
+ last_name: token.last_name, display_name: token.display_name)
97
+ redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return unless reader&.persisted?
94
98
 
95
99
  destination = safe_origin(params[:origin]) || after_sign_in_path
96
100
 
@@ -101,16 +105,46 @@ module Bible270
101
105
  redirect_to destination, notice: "Welcome, #{reader.display_name}."
102
106
  end
103
107
 
104
- private
108
+ private
109
+
110
+ # Delivery problems must not leak through the UI (the response is deliberately
111
+ # identical either way), so they are logged rather than raised. Without this a
112
+ # misconfigured mailer either 500s the request or fails silently, depending on
113
+ # config.action_mailer.raise_delivery_errors.
114
+ def deliver_sign_in_link(address, url)
115
+ minutes = (Bible270.config.email_sign_in_ttl / 60.0).round
116
+ mail = SignInMailer.magic_link(email: address, url: url, expires_in_minutes: minutes)
117
+ Bible270.config.email_sign_in_deliver_later ? mail.deliver_later : mail.deliver_now
118
+ Rails.logger.info("[bible270] sign-in email handed to Action Mailer for #{address}")
119
+ rescue StandardError => e
120
+ Rails.logger.error("[bible270] could not deliver the sign-in email to #{address}: #{e.class}: #{e.message}")
121
+ ensure
122
+ log_sign_in_link(address, url)
123
+ end
124
+
125
+ # In development and test, put the link in the log so you can sign in without
126
+ # working mail delivery. Never in production — the link is a bearer token.
127
+ def log_sign_in_link(address, url)
128
+ return unless log_sign_in_link?
129
+
130
+ Rails.logger.info("[bible270] sign-in link for #{address}: #{url}")
131
+ end
132
+
133
+ def log_sign_in_link?
134
+ flag = Bible270.config.email_sign_in_log_link
135
+ return flag unless flag.nil?
136
+
137
+ Rails.env.development? || Rails.env.test?
138
+ end
105
139
 
106
140
  # Only ever redirect within this app, and never back into an auth route.
107
141
  def safe_origin(value)
108
142
  value = value.to_s
109
143
  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/")
144
+ return nil unless value.start_with?('/')
145
+ return nil if value.include?('//') # protocol-relative URL
146
+ return nil if value.include?('\\') # browsers may normalise \ to /
147
+ return nil if value.include?('/auth/')
114
148
 
115
149
  value
116
150
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  module PlanHelper
4
5
  def b270_track(track)
@@ -18,27 +19,29 @@ module Bible270
18
19
  end
19
20
 
20
21
  def b270_passage_url(reference)
21
- return "#" if reference.blank?
22
+ return '#' if reference.blank?
23
+
22
24
  Bible270.config.passage_url_builder.call(reference, Bible270.config.bible_version)
23
25
  end
24
26
 
25
27
  def b270_avatar(reader, size: 34)
26
28
  if reader.avatar_url.present?
27
- image_tag reader.avatar_url, class: "b270-avatar", width: size, height: size, alt: reader.display_name
29
+ image_tag reader.avatar_url, class: 'b270-avatar', width: size, height: size, alt: reader.display_name
28
30
  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
+ content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
32
+ style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
31
33
  end
32
34
  end
33
35
 
34
36
  def b270_day_status_class(reader, day)
35
- return "" unless reader
37
+ return '' unless reader
38
+
36
39
  if reader.day_complete?(day)
37
- "complete"
40
+ 'complete'
38
41
  elsif reader.read_tracks_for(day).any?
39
- "partial"
42
+ 'partial'
40
43
  else
41
- ""
44
+ ''
42
45
  end
43
46
  end
44
47
  end
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class ApplicationMailer < ActionMailer::Base
4
5
  default from: -> { Bible270.config.mailer_from }
5
- layout nil
6
+ # `layout nil` means "normal lookup", which finds the host app's mailer layout and
7
+ # nests our complete HTML document inside theirs. Only `false` disables it.
8
+ layout false
6
9
  end
7
10
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class SignInMailer < ApplicationMailer
4
5
  # The URL is built by the controller (which has the request context), so the
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class ApplicationRecord < ActiveRecord::Base
4
5
  self.abstract_class = true
@@ -1,17 +1,18 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # A single reader marking one track (ot / nt / pp) read on one day.
4
5
  class Checkoff < ApplicationRecord
5
- self.table_name = "bible270_checkoffs"
6
+ self.table_name = 'bible270_checkoffs'
6
7
 
7
- belongs_to :reader, class_name: "Bible270::Reader"
8
+ belongs_to :reader, class_name: 'Bible270::Reader'
8
9
 
9
10
  validates :day, inclusion: { in: 1..Plan::DAYS }
10
11
  validates :track, inclusion: { in: Plan::TRACKS.keys }
11
12
  validates :track, uniqueness: { scope: %i[reader_id day] }
12
13
  validate :track_present_on_day
13
14
 
14
- private
15
+ private
15
16
 
16
17
  def track_present_on_day
17
18
  return if day.nil? || track.nil?
@@ -1,16 +1,37 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # A publicly visible reflection attached to a plan day (optionally a track).
4
5
  class Comment < ApplicationRecord
5
- self.table_name = "bible270_comments"
6
+ self.table_name = 'bible270_comments'
6
7
 
7
- belongs_to :reader, class_name: "Bible270::Reader"
8
+ belongs_to :reader, class_name: 'Bible270::Reader'
8
9
 
9
10
  validates :day, inclusion: { in: 1..Plan::DAYS }
10
11
  validates :body, presence: true, length: { maximum: 4000 }
11
- validates :track, inclusion: { in: Plan::TRACKS.keys }, allow_nil: true
12
+ # A reflection can be about one track or about the whole day. The form's
13
+ # "The whole day" option submits an empty string, so normalise it to NULL
14
+ # before validating — allow_nil alone rejects "" with
15
+ # "Track is not included in the list".
16
+ before_validation :normalize_track
17
+
18
+ validates :track, inclusion: { in: Plan::TRACKS.keys }, allow_blank: true
19
+
20
+ scope :approved, -> { where(approved: true) }
21
+ scope :hidden, -> { where(approved: false) }
22
+ scope :for_day, ->(d) { approved.where(day: d).order(created_at: :asc) }
23
+ scope :recent, -> { approved.order(created_at: :desc) }
24
+
25
+ # Every reflection is visible when written; these are the moderation actions.
26
+ def hide! = update!(approved: false, moderated_at: Time.current)
27
+ def unhide! = update!(approved: true, moderated_at: Time.current)
28
+ def hidden? = !approved
29
+ def moderated? = moderated_at.present?
30
+
31
+ private
12
32
 
13
- scope :for_day, ->(d) { where(day: d).order(created_at: :asc) }
14
- scope :recent, -> { order(created_at: :desc) }
33
+ def normalize_track
34
+ self.track = Plan.normalize_track(track)
35
+ end
15
36
  end
16
37
  end