Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
024f596
WIP: initial rake task for rich text link QA
rsmithlal Nov 24, 2024
d5ecc75
WIP: content link data type and rich text link metric
rsmithlal Nov 25, 2024
9038c08
Merge branch 'main' into feature/metrics/rich-text-links
rsmithlal Mar 7, 2025
ee7821b
Merge branch 'main' into feature/metrics/rich-text-links
rsmithlal Aug 7, 2025
4993dfd
Merge branch 'main' into feature/metrics/rich-text-links
rsmithlal Aug 29, 2025
3d1076f
Merge branch 'main' into feature/metrics/rich-text-links
rsmithlal Sep 2, 2025
e01bed8
Rubocop fixes
rsmithlal Sep 2, 2025
ce919ec
Rubocop fixes
rsmithlal Sep 2, 2025
067c5bf
Rubocop fixes
rsmithlal Sep 2, 2025
9f436a5
Rubocop fixes
rsmithlal Sep 2, 2025
44c6d45
Add RichText link checker functionality and related specs
rsmithlal Sep 2, 2025
81e586c
Add migration for better_together_metrics_rich_text_links and update …
rsmithlal Sep 2, 2025
be96825
Rubocop fixes
rsmithlal Sep 2, 2025
cec750e
Refactor RichText link handling and improve service documentation
rsmithlal Sep 2, 2025
b61f8d8
Refactor link processing logic in RichTextLinkIdentifier for clarity …
rsmithlal Sep 2, 2025
8156153
Rubocop fixes
rsmithlal Sep 2, 2025
fe419af
Add Link Checker report functionality with associated views, mailer, …
rsmithlal Sep 2, 2025
f04af71
Add scheduling and testing for daily link checker report functionality
rsmithlal Sep 2, 2025
583e4f7
Add Sidekiq scheduling for link checker and event reminder jobs, alon…
rsmithlal Sep 2, 2025
8e5911e
Implement Link Checker Reports functionality with CRUD operations, vi…
rsmithlal Sep 2, 2025
635d2c0
Rubocop fixes
rsmithlal Sep 2, 2025
a449930
Refactor tab navigation for metrics reports to improve accessibility …
rsmithlal Sep 2, 2025
8ec3b13
Refactor RSpec tests for ReportPORO and LinkCheckerReportsController …
rsmithlal Sep 2, 2025
ef124d9
Rubocop fixes
rsmithlal Sep 2, 2025
53f873a
Add migrations for rich text link associations and metrics link check…
rsmithlal Sep 2, 2025
19f4164
Refactor RichTextLinkCheckerQueueJob to use BetterTogether::Content::…
rsmithlal Sep 2, 2025
d48eddc
Refactor RichTextLink and related migrations to improve link handling…
rsmithlal Sep 3, 2025
148885f
Add link checker report translations to English, Spanish, and French …
rsmithlal Sep 3, 2025
1e20be2
Improve error handling assertions in HttpLinkChecker spec
rsmithlal Sep 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ group :test do
# Capybara for integration testing
gem 'capybara', '>= 2.15'
gem 'capybara-screenshot'
# WebMock for stubbing external HTTP requests in specs
gem 'webmock'
# Coveralls for test coverage reporting
gem 'coveralls_reborn', require: false
# Database cleaner for test database cleaning
Expand Down
21 changes: 21 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ PATH
reform-rails (>= 0.2, < 0.4)
rswag (>= 2.3.1, < 2.17.0)
ruby-openai
sidekiq-scheduler
simple_calendar
sprockets-rails
stackprof
Expand Down Expand Up @@ -229,6 +230,9 @@ GEM
term-ansicolor (~> 1.7)
thor (~> 1.2)
tins (~> 1.32)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.1)
addressable
Expand Down Expand Up @@ -297,6 +301,8 @@ GEM
multi_json
erb (5.0.2)
erubi (1.13.1)
et-orbi (1.3.0)
tzinfo
event_stream_parser (1.0.0)
excon (1.2.8)
logger
Expand Down Expand Up @@ -343,6 +349,9 @@ GEM
friendly_id-mobility (1.0.4)
friendly_id (>= 5.0.0, < 5.5)
mobility (>= 1.0.1, < 2.0)
fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
fuubar (2.5.1)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
Expand All @@ -362,6 +371,7 @@ GEM
rake (>= 13)
groupdate (6.7.0)
activesupport (>= 7.1)
hashdiff (1.2.0)
hashie (5.0.0)
highline (3.1.2)
reline
Expand Down Expand Up @@ -521,6 +531,7 @@ GEM
nio4r (~> 2.0)
pundit (2.5.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.16)
rack-attack (6.7.0)
Expand Down Expand Up @@ -699,6 +710,8 @@ GEM
ffi (~> 1.12)
logger
rubyzip (3.0.1)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
sass-embedded (1.86.3-aarch64-linux-gnu)
google-protobuf (~> 4.30)
sass-embedded (1.86.3-arm64-darwin)
Expand Down Expand Up @@ -732,6 +745,9 @@ GEM
logger (>= 1.6.2)
rack (>= 3.1.0)
redis-client (>= 0.23.2)
sidekiq-scheduler (6.0.1)
rufus-scheduler (~> 3.2)
sidekiq (>= 7.3, < 9)
simple_calendar (3.1.0)
rails (>= 6.1)
simplecov (0.22.0)
Expand Down Expand Up @@ -801,6 +817,10 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.25.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.8.0)
base64
Expand Down Expand Up @@ -873,6 +893,7 @@ DEPENDENCIES
storext!
uglifier (>= 1.3.0)
web-console (>= 3.3.0)
webmock

RUBY VERSION
ruby 3.4.4p34
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

module BetterTogether
module Metrics
# Controller for creating and downloading Link Checker reports
class LinkCheckerReportsController < ApplicationController
before_action :set_report, only: %i[download]

def index
@link_checker_reports = BetterTogether::Metrics::LinkCheckerReport.order(created_at: :desc)

if request.headers['Turbo-Frame'].present?
render partial: 'better_together/metrics/link_checker_reports/index',
locals: { reports: @link_checker_reports },
layout: false
else
render :index
end
end

def new
@link_checker_report = BetterTogether::Metrics::LinkCheckerReport.new
end

# rubocop:todo Metrics/AbcSize
# rubocop:todo Metrics/MethodLength
# rubocop:todo Metrics/BlockLength
def create
opts = {
from_date: params.dig(:metrics_link_checker_report, :filters, :from_date),
to_date: params.dig(:metrics_link_checker_report, :filters, :to_date),
file_format: params.dig(:metrics_link_checker_report, :file_format) || 'csv'
}

@link_checker_report = BetterTogether::Metrics::LinkCheckerReport.create_and_generate!(**opts)

respond_to do |format| # rubocop:todo Metrics/BlockLength
if @link_checker_report.persisted?
flash[:notice] = t('flash.generic.created', resource: t('resources.report'))
format.html { redirect_to metrics_link_checker_reports_path, notice: flash[:notice] }
format.turbo_stream do
render turbo_stream: [
turbo_stream.prepend(
'link_checker_reports_table_body',
partial: 'better_together/metrics/link_checker_reports/link_checker_report',
locals: { link_checker_report: @link_checker_report }
),
turbo_stream.replace(
'flash_messages',
partial: 'layouts/better_together/flash_messages',
locals: { flash: flash }
),
turbo_stream.replace('new_link_checker_report',
'<turbo-frame id="new_link_checker_report"></turbo-frame>')
]
end
else
flash.now[:alert] = t('flash.generic.error_create', resource: t('resources.report'))
format.html { render :new, status: :unprocessable_content }
format.turbo_stream do
render turbo_stream: [
turbo_stream.update('form_errors', partial: 'layouts/errors', locals: { object: @link_checker_report }),
turbo_stream.replace(
'flash_messages',
partial: 'layouts/better_together/flash_messages',
locals: { flash: flash }
)
]
end
end
end
end
# rubocop:enable Metrics/BlockLength
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/AbcSize

# rubocop:todo Metrics/AbcSize
# rubocop:todo Metrics/MethodLength
def download
if @link_checker_report.report_file.attached?
BetterTogether::Metrics::TrackDownloadJob.perform_later(
@link_checker_report,
@link_checker_report.report_file.filename.to_s,
@link_checker_report.report_file.content_type,
@link_checker_report.report_file.byte_size,
I18n.locale.to_s
)

send_data @link_checker_report.report_file.download,
filename: @link_checker_report.report_file.filename.to_s,
type: @link_checker_report.report_file.content_type,
disposition: 'attachment'

return
end

redirect_to metrics_link_checker_reports_path, alert: t('resources.download_failed')
end
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/AbcSize

private

def set_report
@link_checker_report = BetterTogether::Metrics::LinkCheckerReport.find(params[:id])
end
end
end
end
6 changes: 6 additions & 0 deletions app/controllers/better_together/metrics/reports_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ def index # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
}
end
}

# Link Checker charts: aggregate data from stored links
links_scope = BetterTogether::Content::Link.all
@links_by_host = links_scope.group(:host).count
@invalid_by_host = links_scope.where(valid_link: false).group(:host).count
@failures_daily = links_scope.where(valid_link: false).group_by_day(:last_checked_at).count
end

# A helper method to generate a random color for each platform (this can be customized).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const platformBorderColors = {
};

export default class extends Controller {
static targets = ["pageViewsChart", "dailyPageViewsChart", "linkClicksChart", "dailyLinkClicksChart", "downloadsChart", "sharesChart", "sharesPerUrlPerPlatformChart"]
static targets = ["pageViewsChart", "dailyPageViewsChart", "linkClicksChart", "dailyLinkClicksChart", "downloadsChart", "sharesChart", "sharesPerUrlPerPlatformChart", "linksByHostChart", "invalidByHostChart", "failuresDailyChart"]

connect() {
this.renderPageViewsChart()
Expand All @@ -68,6 +68,9 @@ export default class extends Controller {
this.renderDownloadsChart()
this.renderSharesChart()
this.renderSharesPerUrlPerPlatformChart()
this.renderLinksByHostChart()
this.renderInvalidByHostChart()
this.renderFailuresDailyChart()
}

renderPageViewsChart() {
Expand Down Expand Up @@ -203,4 +206,58 @@ export default class extends Controller {
})
})
}

renderLinksByHostChart() {
const data = JSON.parse(this.linksByHostChartTarget.dataset.chartData)
new Chart(this.linksByHostChartTarget, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Links by Host',
data: data.values,
backgroundColor: 'rgba(99, 132, 255, 0.2)',
borderColor: 'rgba(99, 132, 255, 1)',
borderWidth: 1
}]
},
options: Object.assign({}, sharedChartOptions)
})
}

renderInvalidByHostChart() {
const data = JSON.parse(this.invalidByHostChartTarget.dataset.chartData)
new Chart(this.invalidByHostChartTarget, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Invalid Links by Host',
data: data.values,
backgroundColor: 'rgba(255, 159, 64, 0.2)',
borderColor: 'rgba(255, 159, 64, 1)',
borderWidth: 1
}]
},
options: Object.assign({}, sharedChartOptions)
})
}

renderFailuresDailyChart() {
const data = JSON.parse(this.failuresDailyChartTarget.dataset.chartData)
new Chart(this.failuresDailyChartTarget, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Invalid Links Over Time',
data: data.values,
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1
}]
},
options: Object.assign({}, sharedChartOptions)
})
}
}
24 changes: 24 additions & 0 deletions app/jobs/better_together/event_reminder_scan_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module BetterTogether
# Scans upcoming events and schedules per-event reminders via
# BetterTogether::EventReminderSchedulerJob. This job is intended to be run
# periodically by the scheduler (sidekiq-scheduler / sidekiq-cron).
class EventReminderScanJob < ApplicationJob
queue_as :notifications

# Keep lightweight: find events starting in the near future and enqueue the
# existing per-event scheduler job which handles cancellation/rescheduling.
# default: next 7 days
def perform(window_hours: 168)
cutoff = Time.current + window_hours.hours

BetterTogether::Event.where('starts_at <= ? AND starts_at >= ?', cutoff, Time.current).find_each do |event|
# Use the id to avoid serializing AR objects into the job payload
BetterTogether::EventReminderSchedulerJob.perform_later(event.id)
rescue StandardError => e
Rails.logger.error "Failed to enqueue reminder scheduler for event #{event&.id}: #{e.message}"
end
end
end
end
36 changes: 36 additions & 0 deletions app/jobs/better_together/metrics/external_link_checker_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

require 'net/http'
require 'uri'

module BetterTogether
module Metrics
# Background job that checks an external link's HTTP status and updates
# the corresponding BetterTogether::Content::Link record with the
# latest check timestamp, status code and validity flag.
class ExternalLinkCheckerJob < ApplicationJob
queue_as :default

def perform(link_id)
link = BetterTogether::Content::Link.find(link_id)
checker = BetterTogether::Metrics::HttpLinkChecker.new(link.url)
result = checker.call

update_link_from_result(link, result)
end

private

def update_link_from_result(link, result)
attrs = {
last_checked_at: Time.current,
latest_status_code: result.status_code,
valid_link: result.success
}

attrs[:error_message] = result.error&.message unless result.success
link.update!(attrs)
end
end
end
end
36 changes: 36 additions & 0 deletions app/jobs/better_together/metrics/internal_link_checker_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

require 'net/http'
require 'uri'

module BetterTogether
module Metrics
# Background job that checks an internal link's HTTP status and updates
# the corresponding BetterTogether::Content::Link record with the
# latest check timestamp, status code and validity flag.
class InternalLinkCheckerJob < ApplicationJob
queue_as :default

def perform(link_id)
link = BetterTogether::Content::Link.find(link_id)
checker = BetterTogether::Metrics::HttpLinkChecker.new(link.url)
result = checker.call

update_link_from_result(link, result)
end

private

def update_link_from_result(link, result)
attrs = {
last_checked_at: Time.current,
latest_status_code: result.status_code,
valid_link: result.success
}

attrs[:error_message] = result.error&.message unless result.success
link.update!(attrs)
end
end
end
end
Loading
Loading