From b559e3a84162b2f5092a2c1b1be3823496d6c463 Mon Sep 17 00:00:00 2001 From: Alex Koval Date: Sun, 22 Feb 2026 12:11:05 +0100 Subject: [PATCH 1/6] feat: add RubyGems API integration with admin UI and polymorphic newsletter items Sync new/updated gems from RubyGems.org API (just_updated + latest endpoints) via scheduled job every 4h. Add full admin CRUD for gems with search, filtering, and sidebar navigation. Refactor NewsletterItem to use polymorphic `linkable` association so items can reference either an Article or a RubyGem, with virtual article_id/ruby_gem_id accessors for form compatibility. Add gem search combobox to newsletter item fields with autofill support. --- .../admin/gem_searches_controller.rb | 17 ++ .../admin/newsletter_issues_controller.rb | 2 +- app/controllers/admin/ruby_gems_controller.rb | 67 ++++++ .../controllers/gem_autofill_controller.js | 50 ++++ app/javascript/controllers/index.js | 3 + app/jobs/sync_ruby_gems_job.rb | 8 + app/mailers/newsletter_mailer.rb | 22 +- app/models/article.rb | 2 +- app/models/newsletter_item.rb | 45 +++- app/models/ruby_gem.rb | 115 +++++++++ .../newsletter_issues/_item_fields.html.erb | 50 ++-- app/views/admin/ruby_gems/_form.html.erb | 31 +++ app/views/admin/ruby_gems/edit.html.erb | 7 + app/views/admin/ruby_gems/index.html.erb | 82 +++++++ app/views/admin/ruby_gems/new.html.erb | 7 + app/views/admin/ruby_gems/show.html.erb | 73 ++++++ app/views/layouts/admin.html.erb | 1 + config/routes.rb | 2 + config/scheduler/development.yml | 4 + config/scheduler/production.yml | 4 + db/migrate/20260222105326_create_ruby_gems.rb | 30 +++ ...222110355_make_newsletter_item_linkable.rb | 37 +++ db/schema.rb | 33 ++- .../newsletter_issues_controller_test.rb | 23 ++ test/fixtures/newsletter_items.yml | 18 +- test/fixtures/ruby_gems.yml | 83 +++++++ test/jobs/sync_ruby_gems_job_test.rb | 8 + test/models/newsletter_issue_test.rb | 2 +- test/models/newsletter_item_test.rb | 73 +++++- test/models/ruby_gem_test.rb | 220 ++++++++++++++++++ 30 files changed, 1062 insertions(+), 57 deletions(-) create mode 100644 app/controllers/admin/gem_searches_controller.rb create mode 100644 app/controllers/admin/ruby_gems_controller.rb create mode 100644 app/javascript/controllers/gem_autofill_controller.js create mode 100644 app/jobs/sync_ruby_gems_job.rb create mode 100644 app/models/ruby_gem.rb create mode 100644 app/views/admin/ruby_gems/_form.html.erb create mode 100644 app/views/admin/ruby_gems/edit.html.erb create mode 100644 app/views/admin/ruby_gems/index.html.erb create mode 100644 app/views/admin/ruby_gems/new.html.erb create mode 100644 app/views/admin/ruby_gems/show.html.erb create mode 100644 db/migrate/20260222105326_create_ruby_gems.rb create mode 100644 db/migrate/20260222110355_make_newsletter_item_linkable.rb create mode 100644 test/fixtures/ruby_gems.yml create mode 100644 test/jobs/sync_ruby_gems_job_test.rb create mode 100644 test/models/ruby_gem_test.rb diff --git a/app/controllers/admin/gem_searches_controller.rb b/app/controllers/admin/gem_searches_controller.rb new file mode 100644 index 0000000..577b159 --- /dev/null +++ b/app/controllers/admin/gem_searches_controller.rb @@ -0,0 +1,17 @@ +module Admin + class GemSearchesController < BaseController + def index + if params[:id].present? + gem = RubyGem.find(params[:id]) + render json: {id: gem.id, name: gem.name, project_url: gem.project_url, info: gem.info} + elsif params[:q].present? + gems = RubyGem.search_by_name(params[:q]).recent(20) + render json: gems.map { |g| + {value: g.id, label: "#{g.name} (#{g.version})"} + } + else + render json: [] + end + end + end +end diff --git a/app/controllers/admin/newsletter_issues_controller.rb b/app/controllers/admin/newsletter_issues_controller.rb index 9c9864e..d0ddb80 100644 --- a/app/controllers/admin/newsletter_issues_controller.rb +++ b/app/controllers/admin/newsletter_issues_controller.rb @@ -85,7 +85,7 @@ def newsletter_issue_params :issue_number, :subject, :sent_at, :subscriber_count, :total_clicks, :total_unique_clicks, newsletter_sections_attributes: [ :id, :title, :position, :_destroy, - newsletter_items_attributes: [:id, :title, :description, :url, :position, :article_id, :_destroy] + newsletter_items_attributes: [:id, :title, :description, :url, :position, :article_id, :ruby_gem_id, :linkable_type, :linkable_id, :_destroy] ] ) end diff --git a/app/controllers/admin/ruby_gems_controller.rb b/app/controllers/admin/ruby_gems_controller.rb new file mode 100644 index 0000000..c0589e5 --- /dev/null +++ b/app/controllers/admin/ruby_gems_controller.rb @@ -0,0 +1,67 @@ +module Admin + class RubyGemsController < BaseController + before_action :set_ruby_gem, only: [:show, :edit, :update, :destroy] + + PERIOD_FILTERS = { + "last_week" => 1.week, + "last_2_weeks" => 2.weeks, + "last_month" => 1.month + }.freeze + + def index + scope = RubyGem.all + scope = scope.where(activity_type: params[:activity_type]) if params[:activity_type].present? + + @period = params[:period] + scope = scope.where("version_created_at >= ?", PERIOD_FILTERS[@period].ago) if PERIOD_FILTERS.key?(@period) + + @search = params[:search] + scope = scope.search_by_name(@search) if @search.present? + + @pagy, @ruby_gems = pagy(scope) + end + + def show + end + + def new + @ruby_gem = RubyGem.new + end + + def create + @ruby_gem = RubyGem.new(ruby_gem_params) + + if @ruby_gem.save + redirect_to admin_ruby_gem_path(@ruby_gem), notice: "Gem created." + else + render :new, status: :unprocessable_content + end + end + + def edit + end + + def update + if @ruby_gem.update(ruby_gem_params) + redirect_to admin_ruby_gem_path(@ruby_gem), notice: "Gem updated." + else + render :edit, status: :unprocessable_content + end + end + + def destroy + @ruby_gem.destroy + redirect_to admin_ruby_gems_path, notice: "Gem deleted." + end + + private + + def set_ruby_gem + @ruby_gem = RubyGem.find(params[:id]) + end + + def ruby_gem_params + params.require(:ruby_gem).permit(:name, :version, :authors, :info, :downloads, :project_url, :homepage_url, :source_code_url, :version_created_at, :activity_type, :processed, :featured_in_issue) + end + end +end diff --git a/app/javascript/controllers/gem_autofill_controller.js b/app/javascript/controllers/gem_autofill_controller.js new file mode 100644 index 0000000..8f966fd --- /dev/null +++ b/app/javascript/controllers/gem_autofill_controller.js @@ -0,0 +1,50 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { url: String } + + connect() { + const hiddenField = this.element.querySelector('[data-lui-combobox-target="hiddenField"]') + if (hiddenField) this.interceptHiddenField(hiddenField) + } + + interceptHiddenField(field) { + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value") + const controller = this + + Object.defineProperty(field, "value", { + get() { + return descriptor.get.call(this) + }, + set(val) { + const oldVal = descriptor.get.call(this) + descriptor.set.call(this, val) + if (val && val !== oldVal) { + controller.gemSelected(val) + } + } + }) + } + + async gemSelected(gemId) { + const url = `${this.urlValue}?id=${encodeURIComponent(gemId)}` + const response = await fetch(url, { + headers: { "Accept": "application/json" } + }) + + if (!response.ok) return + + const gem = await response.json() + + const wrapper = this.element.closest("[data-nested-form-wrapper]") + if (!wrapper) return + + const titleInput = wrapper.querySelector('input[name$="[title]"]') + const urlInput = wrapper.querySelector('input[name$="[url]"]') + const descInput = wrapper.querySelector('textarea[name$="[description]"]') + + if (titleInput) titleInput.value = gem.name + if (urlInput) urlInput.value = gem.project_url + if (descInput) descInput.value = gem.info || "" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 5b3c039..be7e4f0 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -35,3 +35,6 @@ application.register("article-autofill", ArticleAutofillController) import SearchFormController from "./search_form_controller" application.register("search-form", SearchFormController) + +import GemAutofillController from "./gem_autofill_controller" +application.register("gem-autofill", GemAutofillController) diff --git a/app/jobs/sync_ruby_gems_job.rb b/app/jobs/sync_ruby_gems_job.rb new file mode 100644 index 0000000..3ef9a87 --- /dev/null +++ b/app/jobs/sync_ruby_gems_job.rb @@ -0,0 +1,8 @@ +class SyncRubyGemsJob < ApplicationJob + queue_as :default + unique :until_executed, on_conflict: :log + + def perform + RubyGem.sync_from_api! + end +end diff --git a/app/mailers/newsletter_mailer.rb b/app/mailers/newsletter_mailer.rb index d9cb9db..dca75e1 100644 --- a/app/mailers/newsletter_mailer.rb +++ b/app/mailers/newsletter_mailer.rb @@ -2,7 +2,7 @@ class NewsletterMailer < ApplicationMailer def issue(newsletter_issue:, subscriber:) @newsletter_issue = newsletter_issue @subscriber = subscriber - @sections = newsletter_issue.newsletter_sections.includes(newsletter_items: [:tracked_link, {article: :blog}]) + @sections = newsletter_issue.newsletter_sections.includes(newsletter_items: [:tracked_link, :linkable]) @first_flight_blog_ids = first_flight_blog_ids(newsletter_issue) attachments.inline["rubycrow.png"] = { @@ -19,20 +19,24 @@ def issue(newsletter_issue:, subscriber:) private def first_flight_blog_ids(newsletter_issue) - current_blog_ids = newsletter_issue.newsletter_items - .joins(:article) - .pluck("articles.blog_id") - .uniq + article_ids = newsletter_issue.newsletter_items + .where(linkable_type: "Article") + .pluck(:linkable_id) + + current_blog_ids = Article.where(id: article_ids).pluck(:blog_id).uniq return Set.new if current_blog_ids.empty? - previously_seen_blog_ids = NewsletterItem + previously_seen_article_ids = NewsletterItem .joins(newsletter_section: :newsletter_issue) - .joins(:article) + .where(linkable_type: "Article") .where(newsletter_issues: {sent_at: ...newsletter_issue.created_at}) .where.not(newsletter_sections: {newsletter_issue_id: newsletter_issue.id}) - .where(articles: {blog_id: current_blog_ids}) - .pluck("articles.blog_id") + .pluck(:linkable_id) + + previously_seen_blog_ids = Article + .where(id: previously_seen_article_ids, blog_id: current_blog_ids) + .pluck(:blog_id) .to_set (current_blog_ids.to_set - previously_seen_blog_ids) diff --git a/app/models/article.rb b/app/models/article.rb index 796bac1..75c760d 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -31,7 +31,7 @@ class Article < ApplicationRecord self.ignored_columns += ["content_snippet"] belongs_to :blog - has_many :newsletter_items, dependent: :nullify + has_many :newsletter_items, as: :linkable, dependent: :nullify default_scope { order(Arel.sql("published_at DESC NULLS LAST")) } diff --git a/app/models/newsletter_item.rb b/app/models/newsletter_item.rb index 99e0154..e53ad61 100644 --- a/app/models/newsletter_item.rb +++ b/app/models/newsletter_item.rb @@ -4,17 +4,18 @@ # # id :bigint not null, primary key # description :text +# linkable_type :string # position :integer default(0), not null # title :string not null # url :string not null # created_at :datetime not null # updated_at :datetime not null -# article_id :bigint +# linkable_id :bigint # newsletter_section_id :bigint not null # # Indexes # -# index_newsletter_items_on_article_id (article_id) +# index_newsletter_items_on_linkable_type_and_linkable_id (linkable_type,linkable_id) # index_newsletter_items_on_newsletter_section_id (newsletter_section_id) # index_newsletter_items_on_newsletter_section_id_and_position (newsletter_section_id,position) # @@ -24,7 +25,7 @@ # class NewsletterItem < ApplicationRecord belongs_to :newsletter_section - belongs_to :article, optional: true + belongs_to :linkable, polymorphic: true, optional: true has_one :tracked_link, as: :trackable, dependent: :destroy validates :title, presence: true @@ -32,6 +33,40 @@ class NewsletterItem < ApplicationRecord default_scope { order(:position) } + def article + linkable if linkable_type == "Article" + end + + def article_id + linkable_id if linkable_type == "Article" + end + + def article_id=(id) + if id.present? + self.linkable_type = "Article" + self.linkable_id = id + elsif linkable_type == "Article" + self.linkable = nil + end + end + + def ruby_gem + linkable if linkable_type == "RubyGem" + end + + def ruby_gem_id + linkable_id if linkable_type == "RubyGem" + end + + def ruby_gem_id=(id) + if id.present? + self.linkable_type = "RubyGem" + self.linkable_id = id + elsif linkable_type == "RubyGem" + self.linkable = nil + end + end + def first_flight? return false unless article&.blog_id @@ -40,10 +75,10 @@ def first_flight? !NewsletterItem .joins(newsletter_section: :newsletter_issue) - .joins(:article) - .where(articles: {blog_id: blog_id}) + .where(linkable_type: "Article") .where(newsletter_issues: {sent_at: ...current_issue.created_at}) .where.not(newsletter_sections: {newsletter_issue_id: current_issue.id}) + .where(linkable_id: Article.where(blog_id: blog_id).select(:id)) .exists? end end diff --git a/app/models/ruby_gem.rb b/app/models/ruby_gem.rb new file mode 100644 index 0000000..48f848c --- /dev/null +++ b/app/models/ruby_gem.rb @@ -0,0 +1,115 @@ +# == Schema Information +# +# Table name: ruby_gems +# +# id :bigint not null, primary key +# activity_type :string not null +# authors :string +# downloads :integer default(0) +# featured_in_issue :integer +# first_seen_at :datetime +# homepage_url :string +# info :text +# last_synced_at :datetime +# licenses :text default([]), is an Array +# name :string not null +# processed :boolean default(FALSE) +# project_url :string not null +# source_code_url :string +# version :string not null +# version_created_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_ruby_gems_on_activity_type (activity_type) +# index_ruby_gems_on_downloads (downloads) +# index_ruby_gems_on_featured_in_issue (featured_in_issue) +# index_ruby_gems_on_name (name) UNIQUE +# index_ruby_gems_on_processed (processed) +# index_ruby_gems_on_version_created_at (version_created_at) +# +class RubyGem < ApplicationRecord + API_BASE = "https://rubygems.org/api/v1/activity" + API_TIMEOUT = 15 + ACTIVITY_TYPES = %w[new updated].freeze + + has_many :newsletter_items, as: :linkable, dependent: :nullify + + validates :name, presence: true, uniqueness: true + validates :version, presence: true + validates :project_url, presence: true + validates :activity_type, presence: true, inclusion: {in: ACTIVITY_TYPES} + + default_scope { order(version_created_at: :desc) } + + scope :recent, ->(limit = 15) { limit(limit) } + scope :unprocessed, -> { where(processed: false) } + scope :newly_created, -> { where(activity_type: "new") } + scope :recently_updated, -> { where(activity_type: "updated") } + scope :featured, -> { where.not(featured_in_issue: nil) } + scope :search_by_name, ->(query) { where("name ILIKE ?", "%#{sanitize_sql_like(query)}%") } + scope :popular, -> { unscoped.order(downloads: :desc) } + + def self.sync_from_api! + updated_gems = fetch_gems("just_updated.json", "updated") + new_gems = fetch_gems("latest.json", "new") + + records = updated_gems.merge(new_gems) + return [] if records.empty? + + now = Time.current + rows = records.values.map { |r| r.merge(first_seen_at: now, last_synced_at: now) } + + upsert_all( + rows, + unique_by: :index_ruby_gems_on_name, + update_only: %i[version authors info licenses downloads project_url homepage_url source_code_url version_created_at activity_type last_synced_at] + ) + rescue Faraday::Error, JSON::ParserError => e + Rails.logger.error("RubyGem sync failed: #{e.message}") + [] + end + + def self.fetch_gems(endpoint, activity_type) + response = http_client.get("#{API_BASE}/#{endpoint}") do |req| + req.options.timeout = API_TIMEOUT + req.options.open_timeout = API_TIMEOUT + end + + gems = JSON.parse(response.body) + + gems.each_with_object({}) do |gem_data, hash| + name = gem_data["name"] + next if name.blank? + + hash[name] = { + name: name, + version: gem_data["version"], + authors: gem_data["authors"], + info: gem_data["info"]&.squish, + licenses: gem_data["licenses"] || [], + downloads: gem_data["downloads"].to_i, + project_url: gem_data["project_uri"] || "https://rubygems.org/gems/#{name}", + homepage_url: gem_data["homepage_uri"], + source_code_url: gem_data["source_code_uri"], + version_created_at: gem_data["version_created_at"], + activity_type: activity_type + } + end + rescue Faraday::Error, JSON::ParserError => e + Rails.logger.error("Failed to fetch #{endpoint}: #{e.message}") + {} + end + + def self.http_client + @http_client ||= Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| + f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" + f.response :follow_redirects + f.adapter Faraday.default_adapter + end + end + + private_class_method :fetch_gems, :http_client +end diff --git a/app/views/admin/newsletter_issues/_item_fields.html.erb b/app/views/admin/newsletter_issues/_item_fields.html.erb index 1bf6c5c..9d71e7a 100644 --- a/app/views/admin/newsletter_issues/_item_fields.html.erb +++ b/app/views/admin/newsletter_issues/_item_fields.html.erb @@ -1,38 +1,50 @@ <%# locals: (item_form:) -%>
- +
- <%= lui.combobox( - name: :article_id, - form: item_form, - label: "Article", - placeholder: "Search articles…", - url: admin_article_searches_path, - min_chars: 2, - debounce: 250 - ) %> +
+
+ <%= lui.combobox( + name: :article_id, + form: item_form, + label: "Article", + placeholder: "Search articles…", + url: admin_article_searches_path, + min_chars: 2, + debounce: 250 + ) %> -
<%= lui.input(name: :title, form: item_form, label: "Title") %>
+ + + +
-
- <%= lui.input(name: :url, form: item_form, type: :url, label: "URL") %> +
+ <%= lui.combobox( + name: :ruby_gem_id, + form: item_form, + label: "Gem", + placeholder: "Search gems…", + url: admin_gem_searches_path, + min_chars: 2, + debounce: 250 + ) %> +
-
- <%= lui.textarea(name: :description, form: item_form, label: "Description", rows: 2) %> -
+ <%= lui.input(name: :title, form: item_form, label: "Title") %> + <%= lui.input(name: :url, form: item_form, type: :url, label: "URL") %> + <%= lui.textarea(name: :description, form: item_form, label: "Description", rows: 2) %>
<%= item_form.hidden_field :id %> <%= item_form.hidden_field :position %> <%= item_form.hidden_field :_destroy, value: "0" %> - +
diff --git a/app/views/admin/ruby_gems/_form.html.erb b/app/views/admin/ruby_gems/_form.html.erb new file mode 100644 index 0000000..74669ba --- /dev/null +++ b/app/views/admin/ruby_gems/_form.html.erb @@ -0,0 +1,31 @@ +<%# locals: (ruby_gem:) -%> +<% if ruby_gem.errors.any? %> +
+ <%= lui.alert(type: :error) do %> + <%= pluralize(ruby_gem.errors.count, "error") %> + prevented this gem from being saved: +
    + <% ruby_gem.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+ <% end %> +
+<% end %> + +<%= form_with model: [:admin, ruby_gem], class: "space-y-4 max-w-2xl" do |form| %> + <%= lui.input(name: :name, form: form, label: "Name") %> + <%= lui.input(name: :version, form: form, label: "Version") %> + <%= lui.input(name: :authors, form: form, label: "Authors") %> + <%= lui.textarea(name: :info, form: form, label: "Info", rows: 3) %> + <%= lui.select(name: :activity_type, form: form, label: "Activity Type", options_for_select: [["New", "new"], ["Updated", "updated"]], include_blank: false) %> + <%= lui.input(name: :downloads, form: form, type: :number, label: "Downloads") %> + <%= lui.input(name: :project_url, form: form, type: :url, label: "RubyGems URL") %> + <%= lui.input(name: :homepage_url, form: form, type: :url, label: "Homepage URL") %> + <%= lui.input(name: :source_code_url, form: form, type: :url, label: "Source Code URL") %> + <%= lui.input(name: :version_created_at, form: form, type: :datetime_local, label: "Version Published At") %> + <%= lui.switch(name: :processed, form: form, label: "Processed", enabled: ruby_gem.processed?) %> + <%= lui.input(name: :featured_in_issue, form: form, type: :number, label: "Featured in Issue") %> + +
<%= lui.button(type: :submit) { ruby_gem.persisted? ? "Update Gem" : "Create Gem" } %>
+<% end %> diff --git a/app/views/admin/ruby_gems/edit.html.erb b/app/views/admin/ruby_gems/edit.html.erb new file mode 100644 index 0000000..a4fac2e --- /dev/null +++ b/app/views/admin/ruby_gems/edit.html.erb @@ -0,0 +1,7 @@ +
+ <%= link_to "← #{@ruby_gem.name}", admin_ruby_gem_path(@ruby_gem), class: "text-sm admin-link-back" %> + +

Edit Gem

+
+ +<%= render "form", ruby_gem: @ruby_gem %> diff --git a/app/views/admin/ruby_gems/index.html.erb b/app/views/admin/ruby_gems/index.html.erb new file mode 100644 index 0000000..a703e61 --- /dev/null +++ b/app/views/admin/ruby_gems/index.html.erb @@ -0,0 +1,82 @@ +
+
+

Gems

+

<%= @pagy.count %> gems total

+
+ +
+
+ <%= lui.input( + name: :search, + label: false, + placeholder: "Search by name...", + value: @search, + input_data: { + search_form_target: "input", + action: "input->search-form#search" + } + ) %> +
+ +
+ <%= lui.select( + name: :activity_type, + label: false, + options_for_select: options_for_select( + [["All types", ""], ["New", "new"], ["Updated", "updated"]], + params[:activity_type] + ), + select_data: { action: "change->auto-submit#submit" } + ) %> +
+ +
+ <%= lui.select( + name: :period, + label: false, + options_for_select: options_for_select( + [["All time", ""], ["Last week", "last_week"], ["Last 2 weeks", "last_2_weeks"], ["Last month", "last_month"]], + @period + ), + select_data: { action: "change->auto-submit#submit" } + ) %> +
+ + <%= lui.button(url: new_admin_ruby_gem_path, icon: "plus") { "New Gem" } %> +
+
+ +<% if @ruby_gems.any? %> + <%= lui.table(data: @ruby_gems) do |table| %> + <% table.with_column("Name") { |gem| link_to gem.name, admin_ruby_gem_path(gem) } %> + <% table.with_column("Version") { |gem| gem.version } %> + + <% table.with_column("Type") do |gem| %> + <% if gem.activity_type == "new" %> + <%= lui.badge(status: :info) { "New" } %> + <% else %> + <%= lui.badge { "Updated" } %> + <% end %> + <% end %> + + <% table.with_column("Downloads") { |gem| number_with_delimiter(gem.downloads) } %> + <% table.with_column("Published") { |gem| gem.version_created_at&.strftime("%b %d, %Y") || "—" } %> + + <% table.with_column("Processed") do |gem| %> + <% if gem.processed? %> + <%= lui.badge(status: :success) { "Yes" } %> + <% else %> + <%= lui.badge(status: :warning) { "No" } %> + <% end %> + <% end %> + + <% table.with_action { |gem| link_to "Edit", edit_admin_ruby_gem_path(gem) } %> + <% table.with_action { |gem| link_to "Delete", admin_ruby_gem_path(gem), data: { turbo_method: :delete, turbo_confirm: "Delete this gem?" } } %> + <% end %> + <%= render "admin/shared/pagination", pagy: @pagy %> +<% else %> +
+

No gems yet

+ <%= lui.button(url: new_admin_ruby_gem_path, icon: "plus") { "Add your first gem" } %> +
+<% end %> diff --git a/app/views/admin/ruby_gems/new.html.erb b/app/views/admin/ruby_gems/new.html.erb new file mode 100644 index 0000000..6807a3e --- /dev/null +++ b/app/views/admin/ruby_gems/new.html.erb @@ -0,0 +1,7 @@ +
+ <%= link_to "← Gems", admin_ruby_gems_path, class: "text-sm admin-link-back" %> + +

New Gem

+
+ +<%= render "form", ruby_gem: @ruby_gem %> diff --git a/app/views/admin/ruby_gems/show.html.erb b/app/views/admin/ruby_gems/show.html.erb new file mode 100644 index 0000000..dabcb1f --- /dev/null +++ b/app/views/admin/ruby_gems/show.html.erb @@ -0,0 +1,73 @@ +
<%= link_to "← Gems", admin_ruby_gems_path, class: "text-sm admin-link-back" %>
+ +
+

<%= @ruby_gem.name %>

+ +
+ <%= lui.button(url: edit_admin_ruby_gem_path(@ruby_gem), style: :outline) { "Edit" } %> + <%= link_to "Delete", admin_ruby_gem_path(@ruby_gem), data: { turbo_method: :delete, turbo_confirm: "Delete this gem?" }, class: "inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium admin-delete-btn" %> +
+
+ +<%= lui.description_list do |list| %> + <% list.with_item(label: "Name", value: @ruby_gem.name) %> + <% list.with_item(label: "Version", value: @ruby_gem.version) %> + <% list.with_item(label: "Authors", value: @ruby_gem.authors || "—") %> + <% list.with_item(label: "Info", value: @ruby_gem.info || "—") %> + + <% list.with_item(label: "Activity Type") do %> + <% if @ruby_gem.activity_type == "new" %> + <%= lui.badge(status: :info) { "New" } %> + <% else %> + <%= lui.badge { "Updated" } %> + <% end %> + <% end %> + + <% list.with_item(label: "Downloads", value: number_with_delimiter(@ruby_gem.downloads)) %> + + <% list.with_item(label: "RubyGems URL") do %> + <%= link_to @ruby_gem.project_url, safe_external_url(@ruby_gem.project_url), target: "_blank", rel: "noopener noreferrer" %> + <% end %> + + <% list.with_item(label: "Homepage") do %> + <% if @ruby_gem.homepage_url.present? %> + <%= link_to @ruby_gem.homepage_url, safe_external_url(@ruby_gem.homepage_url), target: "_blank", rel: "noopener noreferrer" %> + <% else %> + — + <% end %> + <% end %> + + <% list.with_item(label: "Source Code") do %> + <% if @ruby_gem.source_code_url.present? %> + <%= link_to @ruby_gem.source_code_url, safe_external_url(@ruby_gem.source_code_url), target: "_blank", rel: "noopener noreferrer" %> + <% else %> + — + <% end %> + <% end %> + + <% list.with_item(label: "Licenses") do %> + <% if @ruby_gem.licenses.any? %> +
+ <% @ruby_gem.licenses.each do |license| %> + <%= lui.badge { license } %> + <% end %> +
+ <% else %> + — + <% end %> + <% end %> + + <% list.with_item(label: "Version Published", value: @ruby_gem.version_created_at&.strftime("%b %d, %Y %H:%M") || "—") %> + + <% list.with_item(label: "Processed") do %> + <% if @ruby_gem.processed? %> + <%= lui.badge(status: :success) { "Yes" } %> + <% else %> + <%= lui.badge(status: :warning) { "No" } %> + <% end %> + <% end %> + + <% list.with_item(label: "Featured in Issue", value: @ruby_gem.featured_in_issue || "—") %> + <% list.with_item(label: "First Seen", value: @ruby_gem.first_seen_at&.strftime("%b %d, %Y %H:%M") || "—") %> + <% list.with_item(label: "Last Synced", value: @ruby_gem.last_synced_at&.strftime("%b %d, %Y %H:%M") || "—") %> +<% end %> diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index fbe7fc4..1f2ab34 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -29,6 +29,7 @@ <% section.with_link(title: "Dashboard", url: admin_root_path, icon: "home", current: current_page?(admin_root_path)) %> <% section.with_link(title: "Blogs", url: admin_blogs_path, icon: "rss", current: request.path.start_with?("/admin/blogs")) %> <% section.with_link(title: "Articles", url: admin_articles_path, icon: "document-text", current: request.path.start_with?("/admin/articles")) %> + <% section.with_link(title: "Gems", url: admin_ruby_gems_path, icon: "cube", current: request.path.start_with?("/admin/ruby_gems")) %> <% section.with_link(title: "Newsletter Issues", url: admin_newsletter_issues_path, icon: "envelope", current: request.path.start_with?("/admin/newsletter_issues")) %> <% section.with_link(title: "Subscribers", url: admin_subscribers_path, icon: "users", current: request.path.start_with?("/admin/subscribers")) %> <% section.with_link(title: "Tracked Links", url: admin_tracked_links_path, icon: "link", current: request.path.start_with?("/admin/tracked_links")) %> diff --git a/config/routes.rb b/config/routes.rb index f35df20..fe154df 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,7 @@ resources :blogs resources :articles + resources :ruby_gems resources :newsletter_issues do member do get :preview @@ -25,6 +26,7 @@ end end resources :article_searches, only: [:index] + resources :gem_searches, only: [:index] resources :subscribers resources :tracked_links resources :clicks, only: [:index, :show, :destroy] diff --git a/config/scheduler/development.yml b/config/scheduler/development.yml index 8718f3e..e15b829 100644 --- a/config/scheduler/development.yml +++ b/config/scheduler/development.yml @@ -2,3 +2,7 @@ sync_blog_registry: every: "6h" class: SyncBlogRegistryJob queue: default +sync_ruby_gems: + every: "4h" + class: SyncRubyGemsJob + queue: default diff --git a/config/scheduler/production.yml b/config/scheduler/production.yml index 3682723..bd37078 100644 --- a/config/scheduler/production.yml +++ b/config/scheduler/production.yml @@ -6,3 +6,7 @@ parse_rss_feeds: every: "2h" class: ParseRssFeedsJob queue: default +sync_ruby_gems: + every: "4h" + class: SyncRubyGemsJob + queue: default diff --git a/db/migrate/20260222105326_create_ruby_gems.rb b/db/migrate/20260222105326_create_ruby_gems.rb new file mode 100644 index 0000000..493f808 --- /dev/null +++ b/db/migrate/20260222105326_create_ruby_gems.rb @@ -0,0 +1,30 @@ +class CreateRubyGems < ActiveRecord::Migration[8.1] + def change + create_table :ruby_gems do |t| + t.string :name, null: false + t.string :version, null: false + t.string :authors + t.text :info + t.text :licenses, array: true, default: [] + t.integer :downloads, default: 0 + t.string :project_url, null: false + t.string :homepage_url + t.string :source_code_url + t.datetime :version_created_at + t.string :activity_type, null: false + t.boolean :processed, default: false + t.integer :featured_in_issue + t.datetime :first_seen_at + t.datetime :last_synced_at + + t.timestamps + end + + add_index :ruby_gems, :name, unique: true + add_index :ruby_gems, :activity_type + add_index :ruby_gems, :processed + add_index :ruby_gems, :featured_in_issue + add_index :ruby_gems, :version_created_at + add_index :ruby_gems, :downloads + end +end diff --git a/db/migrate/20260222110355_make_newsletter_item_linkable.rb b/db/migrate/20260222110355_make_newsletter_item_linkable.rb new file mode 100644 index 0000000..c460c4c --- /dev/null +++ b/db/migrate/20260222110355_make_newsletter_item_linkable.rb @@ -0,0 +1,37 @@ +class MakeNewsletterItemLinkable < ActiveRecord::Migration[8.1] + def up + safety_assured do + add_column :newsletter_items, :linkable_type, :string + add_column :newsletter_items, :linkable_id, :bigint + + execute <<~SQL + UPDATE newsletter_items + SET linkable_type = 'Article', linkable_id = article_id + WHERE article_id IS NOT NULL + SQL + + add_index :newsletter_items, [:linkable_type, :linkable_id] + + remove_index :newsletter_items, :article_id + remove_column :newsletter_items, :article_id + end + end + + def down + safety_assured do + add_column :newsletter_items, :article_id, :bigint + + execute <<~SQL + UPDATE newsletter_items + SET article_id = linkable_id + WHERE linkable_type = 'Article' + SQL + + add_index :newsletter_items, :article_id + + remove_index :newsletter_items, [:linkable_type, :linkable_id] + remove_column :newsletter_items, :linkable_type + remove_column :newsletter_items, :linkable_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d916f06..1274f61 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_21_184321) do +ActiveRecord::Schema[8.1].define(version: 2026_02_22_110355) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -81,15 +81,16 @@ end create_table "newsletter_items", force: :cascade do |t| - t.bigint "article_id" t.datetime "created_at", null: false t.text "description" + t.bigint "linkable_id" + t.string "linkable_type" t.bigint "newsletter_section_id", null: false t.integer "position", default: 0, null: false t.string "title", null: false t.datetime "updated_at", null: false t.string "url", null: false - t.index ["article_id"], name: "index_newsletter_items_on_article_id" + t.index ["linkable_type", "linkable_id"], name: "index_newsletter_items_on_linkable_type_and_linkable_id" t.index ["newsletter_section_id", "position"], name: "index_newsletter_items_on_newsletter_section_id_and_position" t.index ["newsletter_section_id"], name: "index_newsletter_items_on_newsletter_section_id" end @@ -104,6 +105,32 @@ t.index ["newsletter_issue_id"], name: "index_newsletter_sections_on_newsletter_issue_id" end + create_table "ruby_gems", force: :cascade do |t| + t.string "activity_type", null: false + t.string "authors" + t.datetime "created_at", null: false + t.integer "downloads", default: 0 + t.integer "featured_in_issue" + t.datetime "first_seen_at" + t.string "homepage_url" + t.text "info" + t.datetime "last_synced_at" + t.text "licenses", default: [], array: true + t.string "name", null: false + t.boolean "processed", default: false + t.string "project_url", null: false + t.string "source_code_url" + t.datetime "updated_at", null: false + t.string "version", null: false + t.datetime "version_created_at" + t.index ["activity_type"], name: "index_ruby_gems_on_activity_type" + t.index ["downloads"], name: "index_ruby_gems_on_downloads" + t.index ["featured_in_issue"], name: "index_ruby_gems_on_featured_in_issue" + t.index ["name"], name: "index_ruby_gems_on_name", unique: true + t.index ["processed"], name: "index_ruby_gems_on_processed" + t.index ["version_created_at"], name: "index_ruby_gems_on_version_created_at" + end + create_table "subscribers", force: :cascade do |t| t.boolean "confirmed", default: false t.datetime "created_at", null: false diff --git a/test/controllers/admin/newsletter_issues_controller_test.rb b/test/controllers/admin/newsletter_issues_controller_test.rb index ea7824f..69a171d 100644 --- a/test/controllers/admin/newsletter_issues_controller_test.rb +++ b/test/controllers/admin/newsletter_issues_controller_test.rb @@ -96,6 +96,29 @@ class Admin::NewsletterIssuesControllerTest < ActionDispatch::IntegrationTest end item = NewsletterIssue.last.newsletter_sections.first.newsletter_items.first assert_equal article.id, item.article_id + assert_equal "Article", item.linkable_type + end + + test "create with nested item including ruby_gem_id" do + gem = ruby_gems(:rack_updated) + assert_difference ["NewsletterIssue.count", "NewsletterItem.count"] do + post admin_newsletter_issues_path, params: {newsletter_issue: { + issue_number: 102, + subject: "With Gem", + newsletter_sections_attributes: { + "0" => { + title: "Test Section", + position: 0, + newsletter_items_attributes: { + "0" => {title: gem.name, url: gem.project_url, position: 0, ruby_gem_id: gem.id} + } + } + } + }} + end + item = NewsletterIssue.last.newsletter_sections.first.newsletter_items.first + assert_equal gem.id, item.ruby_gem_id + assert_equal "RubyGem", item.linkable_type end test "create with nested items auto-generates tracked links" do diff --git a/test/fixtures/newsletter_items.yml b/test/fixtures/newsletter_items.yml index c8d7494..16a0937 100644 --- a/test/fixtures/newsletter_items.yml +++ b/test/fixtures/newsletter_items.yml @@ -4,17 +4,18 @@ # # id :bigint not null, primary key # description :text +# linkable_type :string # position :integer default(0), not null # title :string not null # url :string not null # created_at :datetime not null # updated_at :datetime not null -# article_id :bigint +# linkable_id :bigint # newsletter_section_id :bigint not null # # Indexes # -# index_newsletter_items_on_article_id (article_id) +# index_newsletter_items_on_linkable_type_and_linkable_id (linkable_type,linkable_id) # index_newsletter_items_on_newsletter_section_id (newsletter_section_id) # index_newsletter_items_on_newsletter_section_id_and_position (newsletter_section_id,position) # @@ -24,7 +25,7 @@ # rails_update: newsletter_section: crows_pick - article: rails_performance + linkable: rails_performance (Article) title: "Rails 8.1 Released" description: "Major release with async queries" url: "https://example.com/rails-8-1" @@ -32,14 +33,15 @@ rails_update: ruby_gem: newsletter_section: shiny_objects - title: "New Ruby Gem" - description: "A useful new gem" - url: "https://example.com/new-gem" + linkable: rack_updated (RubyGem) + title: "Rack 3.1.0" + description: "Rack provides a minimal interface between web servers and Ruby frameworks" + url: "https://rubygems.org/gems/rack" position: 0 issue_two_speedshop: newsletter_section: issue_two_picks - article: older_article + linkable: older_article (Article) title: "Ruby Memory Management" description: "Understanding Ruby memory" url: "https://example.com/memory" @@ -47,7 +49,7 @@ issue_two_speedshop: issue_two_martians: newsletter_section: issue_two_picks - article: martians_article + linkable: martians_article (Article) title: "ViewComponent Best Practices" description: "How to use ViewComponent effectively" url: "https://example.com/viewcomponent" diff --git a/test/fixtures/ruby_gems.yml b/test/fixtures/ruby_gems.yml new file mode 100644 index 0000000..073f985 --- /dev/null +++ b/test/fixtures/ruby_gems.yml @@ -0,0 +1,83 @@ +# == Schema Information +# +# Table name: ruby_gems +# +# id :bigint not null, primary key +# activity_type :string not null +# authors :string +# downloads :integer default(0) +# featured_in_issue :integer +# first_seen_at :datetime +# homepage_url :string +# info :text +# last_synced_at :datetime +# licenses :text default([]), is an Array +# name :string not null +# processed :boolean default(FALSE) +# project_url :string not null +# source_code_url :string +# version :string not null +# version_created_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_ruby_gems_on_activity_type (activity_type) +# index_ruby_gems_on_downloads (downloads) +# index_ruby_gems_on_featured_in_issue (featured_in_issue) +# index_ruby_gems_on_name (name) UNIQUE +# index_ruby_gems_on_processed (processed) +# index_ruby_gems_on_version_created_at (version_created_at) +# +rack_updated: + name: "rack" + version: "3.1.0" + authors: "Leah Neukirchen, Aaron Patterson" + info: "Rack provides a minimal interface between web servers and Ruby frameworks" + licenses: + - MIT + downloads: 500000000 + project_url: "https://rubygems.org/gems/rack" + homepage_url: "https://github.com/rack/rack" + source_code_url: "https://github.com/rack/rack" + version_created_at: <%= 1.day.ago.to_fs(:db) %> + activity_type: "updated" + processed: false + first_seen_at: <%= 1.day.ago.to_fs(:db) %> + last_synced_at: <%= 1.hour.ago.to_fs(:db) %> + +new_gem: + name: "cool_new_gem" + version: "0.1.0" + authors: "Jane Developer" + info: "A cool new gem for Ruby developers" + licenses: + - MIT + downloads: 42 + project_url: "https://rubygems.org/gems/cool_new_gem" + homepage_url: "https://github.com/jane/cool_new_gem" + source_code_url: "https://github.com/jane/cool_new_gem" + version_created_at: <%= 2.hours.ago.to_fs(:db) %> + activity_type: "new" + processed: false + first_seen_at: <%= 2.hours.ago.to_fs(:db) %> + last_synced_at: <%= 1.hour.ago.to_fs(:db) %> + +featured_gem: + name: "solid_queue" + version: "1.0.0" + authors: "Rosa Gutierrez" + info: "Database-backed Active Job backend" + licenses: + - MIT + downloads: 1000000 + project_url: "https://rubygems.org/gems/solid_queue" + homepage_url: "https://github.com/rails/solid_queue" + source_code_url: "https://github.com/rails/solid_queue" + version_created_at: <%= 3.days.ago.to_fs(:db) %> + activity_type: "new" + processed: true + featured_in_issue: 1 + first_seen_at: <%= 3.days.ago.to_fs(:db) %> + last_synced_at: <%= 1.day.ago.to_fs(:db) %> diff --git a/test/jobs/sync_ruby_gems_job_test.rb b/test/jobs/sync_ruby_gems_job_test.rb new file mode 100644 index 0000000..e0421a2 --- /dev/null +++ b/test/jobs/sync_ruby_gems_job_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class SyncRubyGemsJobTest < ActiveSupport::TestCase + test "calls RubyGem.sync_from_api!" do + RubyGem.expects(:sync_from_api!).once + SyncRubyGemsJob.perform_now + end +end diff --git a/test/models/newsletter_issue_test.rb b/test/models/newsletter_issue_test.rb index 5a55e38..c761fe7 100644 --- a/test/models/newsletter_issue_test.rb +++ b/test/models/newsletter_issue_test.rb @@ -66,7 +66,7 @@ class NewsletterIssueTest < ActiveSupport::TestCase article = articles(:rails_performance) issue = NewsletterIssue.create!(issue_number: 101, subject: "Test") section = issue.newsletter_sections.create!(title: "Crows Pick", position: 0) - section.newsletter_items.create!(title: article.title, url: article.url, position: 0, article: article) + section.newsletter_items.create!(title: article.title, url: article.url, position: 0, linkable: article) issue.create_tracked_links! diff --git a/test/models/newsletter_item_test.rb b/test/models/newsletter_item_test.rb index 211b87c..d269e5f 100644 --- a/test/models/newsletter_item_test.rb +++ b/test/models/newsletter_item_test.rb @@ -35,28 +35,81 @@ class NewsletterItemTest < ActiveSupport::TestCase assert item.valid? end - test "article_id is optional" do + test "linkable is optional" do item = NewsletterItem.new( newsletter_section: newsletter_sections(:crows_pick), - title: "No Article", + title: "No Linkable", url: "https://example.com/test", position: 0 ) assert item.valid? + assert_nil item.linkable + end + + test "linkable can be an article" do + article = articles(:rails_performance) + item = newsletter_items(:rails_update) + assert_equal "Article", item.linkable_type + assert_equal article, item.linkable + assert_equal article, item.article + end + + test "linkable can be a ruby gem" do + gem = ruby_gems(:rack_updated) + item = newsletter_items(:ruby_gem) + assert_equal "RubyGem", item.linkable_type + assert_equal gem, item.linkable + assert_equal gem, item.ruby_gem + end + + test "article returns nil when linkable is a ruby gem" do + item = newsletter_items(:ruby_gem) + assert_nil item.article assert_nil item.article_id end - test "belongs_to article when set" do + test "ruby_gem returns nil when linkable is an article" do + item = newsletter_items(:rails_update) + assert_nil item.ruby_gem + assert_nil item.ruby_gem_id + end + + test "article_id= sets linkable to article" do article = articles(:rails_performance) item = NewsletterItem.new( newsletter_section: newsletter_sections(:crows_pick), - title: article.title, - url: article.url, - position: 0, - article: article + title: "Test", + url: "https://example.com" ) - assert item.valid? - assert_equal article, item.article + item.article_id = article.id + assert_equal "Article", item.linkable_type + assert_equal article.id, item.linkable_id + end + + test "ruby_gem_id= sets linkable to ruby gem" do + gem = ruby_gems(:rack_updated) + item = NewsletterItem.new( + newsletter_section: newsletter_sections(:crows_pick), + title: "Test", + url: "https://example.com" + ) + item.ruby_gem_id = gem.id + assert_equal "RubyGem", item.linkable_type + assert_equal gem.id, item.linkable_id + end + + test "article_id= with blank clears article linkable" do + item = newsletter_items(:rails_update) + item.article_id = "" + assert_nil item.linkable_type + assert_nil item.linkable_id + end + + test "ruby_gem_id= with blank clears gem linkable" do + item = newsletter_items(:ruby_gem) + item.ruby_gem_id = "" + assert_nil item.linkable_type + assert_nil item.linkable_id end test "first_flight? returns false when item has no article" do @@ -79,7 +132,7 @@ class NewsletterItemTest < ActiveSupport::TestCase article = articles(:martians_article) another_item = NewsletterItem.create!( newsletter_section: section, - article: article, + linkable: article, title: "Another Martians Post", url: "https://example.com/another-martians", position: 2 diff --git a/test/models/ruby_gem_test.rb b/test/models/ruby_gem_test.rb new file mode 100644 index 0000000..f111daa --- /dev/null +++ b/test/models/ruby_gem_test.rb @@ -0,0 +1,220 @@ +require "test_helper" + +class RubyGemTest < ActiveSupport::TestCase + test "valid ruby gem" do + gem = RubyGem.new(name: "test_gem", version: "1.0.0", project_url: "https://rubygems.org/gems/test_gem", activity_type: "new") + assert gem.valid? + end + + test "requires name" do + gem = RubyGem.new(version: "1.0.0", project_url: "https://rubygems.org/gems/test", activity_type: "new") + assert_not gem.valid? + assert_includes gem.errors[:name], "can't be blank" + end + + test "name must be unique" do + gem = RubyGem.new(name: ruby_gems(:rack_updated).name, version: "1.0.0", project_url: "https://rubygems.org/gems/rack2", activity_type: "new") + assert_not gem.valid? + assert_includes gem.errors[:name], "has already been taken" + end + + test "requires version" do + gem = RubyGem.new(name: "test_gem", project_url: "https://rubygems.org/gems/test", activity_type: "new") + assert_not gem.valid? + assert_includes gem.errors[:version], "can't be blank" + end + + test "requires project_url" do + gem = RubyGem.new(name: "test_gem", version: "1.0.0", activity_type: "new") + assert_not gem.valid? + assert_includes gem.errors[:project_url], "can't be blank" + end + + test "requires valid activity_type" do + gem = RubyGem.new(name: "test_gem", version: "1.0.0", project_url: "https://rubygems.org/gems/test", activity_type: "invalid") + assert_not gem.valid? + assert_includes gem.errors[:activity_type], "is not included in the list" + end + + test "activity_type accepts new" do + gem = RubyGem.new(name: "test_gem", version: "1.0.0", project_url: "https://rubygems.org/gems/test", activity_type: "new") + assert gem.valid? + end + + test "activity_type accepts updated" do + gem = RubyGem.new(name: "test_gem", version: "1.0.0", project_url: "https://rubygems.org/gems/test", activity_type: "updated") + assert gem.valid? + end + + test "default scope orders by version_created_at desc" do + gems = RubyGem.all + dates = gems.map(&:version_created_at).compact + assert_equal dates, dates.sort.reverse + end + + test "recent scope limits results" do + assert RubyGem.recent(2).count <= 2 + end + + test "unprocessed scope returns unprocessed gems" do + RubyGem.unprocessed.each do |gem| + assert_not gem.processed? + end + end + + test "newly_created scope returns new gems" do + RubyGem.newly_created.each do |gem| + assert_equal "new", gem.activity_type + end + end + + test "recently_updated scope returns updated gems" do + RubyGem.recently_updated.each do |gem| + assert_equal "updated", gem.activity_type + end + end + + test "featured scope returns gems with featured_in_issue" do + featured = RubyGem.featured + assert_includes featured, ruby_gems(:featured_gem) + assert_not_includes featured, ruby_gems(:rack_updated) + end + + test "search_by_name finds matching gems" do + results = RubyGem.search_by_name("rack") + assert_includes results, ruby_gems(:rack_updated) + assert_not_includes results, ruby_gems(:new_gem) + end + + test "popular scope orders by downloads desc" do + popular = RubyGem.popular + downloads = popular.map(&:downloads) + assert_equal downloads, downloads.sort.reverse + end + + test "sync_from_api! upserts gems from both endpoints" do + updated_response = [ + { + "name" => "rails", + "version" => "8.0.0", + "authors" => "DHH", + "info" => "Full-stack web framework", + "licenses" => ["MIT"], + "downloads" => 400000000, + "project_uri" => "https://rubygems.org/gems/rails", + "homepage_uri" => "https://rubyonrails.org", + "source_code_uri" => "https://github.com/rails/rails", + "version_created_at" => "2025-01-01T00:00:00.000Z" + } + ].to_json + + latest_response = [ + { + "name" => "my_new_gem", + "version" => "0.1.0", + "authors" => "Author", + "info" => "A brand new gem", + "licenses" => ["MIT"], + "downloads" => 10, + "project_uri" => "https://rubygems.org/gems/my_new_gem", + "homepage_uri" => nil, + "source_code_uri" => nil, + "version_created_at" => "2025-06-01T00:00:00.000Z" + } + ].to_json + + stub_request(:get, "https://rubygems.org/api/v1/activity/just_updated.json") + .to_return(status: 200, body: updated_response) + stub_request(:get, "https://rubygems.org/api/v1/activity/latest.json") + .to_return(status: 200, body: latest_response) + + assert_difference "RubyGem.count", 2 do + RubyGem.sync_from_api! + end + + rails_gem = RubyGem.find_by(name: "rails") + assert_equal "8.0.0", rails_gem.version + assert_equal "updated", rails_gem.activity_type + assert_not_nil rails_gem.first_seen_at + + new_gem = RubyGem.find_by(name: "my_new_gem") + assert_equal "0.1.0", new_gem.version + assert_equal "new", new_gem.activity_type + end + + test "sync_from_api! new gems overwrite updated gems with same name" do + shared_gem = { + "name" => "shared_gem", + "version" => "1.0.0", + "authors" => "Author", + "info" => "Shared gem", + "licenses" => ["MIT"], + "downloads" => 100, + "project_uri" => "https://rubygems.org/gems/shared_gem", + "homepage_uri" => nil, + "source_code_uri" => nil, + "version_created_at" => "2025-06-01T00:00:00.000Z" + } + + stub_request(:get, "https://rubygems.org/api/v1/activity/just_updated.json") + .to_return(status: 200, body: [shared_gem].to_json) + stub_request(:get, "https://rubygems.org/api/v1/activity/latest.json") + .to_return(status: 200, body: [shared_gem].to_json) + + RubyGem.sync_from_api! + + gem = RubyGem.find_by(name: "shared_gem") + assert_equal "new", gem.activity_type + end + + test "sync_from_api! returns empty array on api error" do + stub_request(:get, "https://rubygems.org/api/v1/activity/just_updated.json") + .to_return(status: 500) + stub_request(:get, "https://rubygems.org/api/v1/activity/latest.json") + .to_return(status: 200, body: "[]") + + result = RubyGem.sync_from_api! + assert_equal [], result + end + + test "sync_from_api! handles json parse error" do + stub_request(:get, "https://rubygems.org/api/v1/activity/just_updated.json") + .to_return(status: 200, body: "invalid json") + stub_request(:get, "https://rubygems.org/api/v1/activity/latest.json") + .to_return(status: 200, body: "[]") + + result = RubyGem.sync_from_api! + assert_equal [], result + end + + test "sync_from_api! preserves first_seen_at on re-sync" do + stub_request(:get, "https://rubygems.org/api/v1/activity/just_updated.json") + .to_return(status: 200, body: "[]") + + gem_data = { + "name" => ruby_gems(:rack_updated).name, + "version" => "3.2.0", + "authors" => "New Author", + "info" => "Updated info", + "licenses" => ["MIT"], + "downloads" => 600000000, + "project_uri" => "https://rubygems.org/gems/rack", + "homepage_uri" => "https://github.com/rack/rack", + "source_code_uri" => "https://github.com/rack/rack", + "version_created_at" => "2025-06-01T00:00:00.000Z" + } + + stub_request(:get, "https://rubygems.org/api/v1/activity/latest.json") + .to_return(status: 200, body: [gem_data].to_json) + + original_first_seen = ruby_gems(:rack_updated).first_seen_at + + assert_no_difference "RubyGem.count" do + RubyGem.sync_from_api! + end + + ruby_gems(:rack_updated).reload + assert_equal original_first_seen, ruby_gems(:rack_updated).first_seen_at + assert_equal "3.2.0", ruby_gems(:rack_updated).version + end +end From 90708ad1e823a022e6c710e8b2d54055ceb4aba1 Mon Sep 17 00:00:00 2001 From: Alex Koval Date: Sun, 22 Feb 2026 16:40:12 +0100 Subject: [PATCH 2/6] feat: add GitHub trending repos and Reddit posts as newsletter sources - Add GithubRepo model with GitHub Search API sync (Faraday) - Add RedditPost model with RSS feed sync (Feedjira) for r/ruby and r/rails - Add sync jobs with sidekiq-scheduler (6h/4h intervals) - Add full admin CRUD with search, filters, and pagination for both - Add linkable selector UI with type dropdown and dynamic combobox - Consolidate 4 autofill controllers into generic linkable-autofill - Use linkable_type/linkable_id directly in newsletter item form - Normalize search controller responses to {id, title, url, description} - Add nav links, dashboard stats, and lui.button icons throughout --- .../admin/article_searches_controller.rb | 2 +- app/controllers/admin/dashboard_controller.rb | 4 + .../admin/gem_searches_controller.rb | 2 +- .../admin/github_repo_searches_controller.rb | 23 +++ .../admin/github_repos_controller.rb | 66 +++++++ .../admin/newsletter_issues_controller.rb | 2 +- .../admin/reddit_post_searches_controller.rb | 17 ++ .../admin/reddit_posts_controller.rb | 67 +++++++ .../article_autofill_controller.js | 48 ----- app/javascript/controllers/index.js | 10 +- ...ler.js => linkable_autofill_controller.js} | 14 +- .../linkable_selector_controller.js | 34 ++++ app/jobs/sync_github_repos_job.rb | 8 + app/jobs/sync_reddit_posts_job.rb | 8 + app/models/github_repo.rb | 89 ++++++++++ app/models/newsletter_item.rb | 47 +++++ app/models/reddit_post.rb | 97 ++++++++++ app/views/admin/github_repos/_form.html.erb | 32 ++++ app/views/admin/github_repos/edit.html.erb | 7 + app/views/admin/github_repos/index.html.erb | 62 +++++++ app/views/admin/github_repos/new.html.erb | 7 + app/views/admin/github_repos/show.html.erb | 51 ++++++ .../admin/newsletter_issues/_form.html.erb | 2 +- .../newsletter_issues/_item_fields.html.erb | 80 +++++++-- .../_section_fields.html.erb | 10 +- app/views/admin/reddit_posts/_form.html.erb | 30 ++++ app/views/admin/reddit_posts/edit.html.erb | 7 + app/views/admin/reddit_posts/index.html.erb | 77 ++++++++ app/views/admin/reddit_posts/new.html.erb | 7 + app/views/admin/reddit_posts/show.html.erb | 48 +++++ app/views/layouts/admin.html.erb | 18 +- config/routes.rb | 4 + config/scheduler/development.yml | 8 + config/scheduler/production.yml | 8 + .../20260222122911_create_github_repos.rb | 30 ++++ .../20260222122912_create_reddit_posts.rb | 28 +++ db/schema.rb | 52 +++++- .../admin/article_searches_controller_test.rb | 2 +- .../newsletter_issues_controller_test.rb | 12 +- test/fixtures/github_repos.yml | 58 ++++++ test/fixtures/reddit_posts.yml | 42 +++++ test/jobs/sync_github_repos_job_test.rb | 8 + test/jobs/sync_reddit_posts_job_test.rb | 8 + test/models/github_repo_test.rb | 153 ++++++++++++++++ test/models/reddit_post_test.rb | 168 ++++++++++++++++++ 45 files changed, 1456 insertions(+), 101 deletions(-) create mode 100644 app/controllers/admin/github_repo_searches_controller.rb create mode 100644 app/controllers/admin/github_repos_controller.rb create mode 100644 app/controllers/admin/reddit_post_searches_controller.rb create mode 100644 app/controllers/admin/reddit_posts_controller.rb delete mode 100644 app/javascript/controllers/article_autofill_controller.js rename app/javascript/controllers/{gem_autofill_controller.js => linkable_autofill_controller.js} (78%) create mode 100644 app/javascript/controllers/linkable_selector_controller.js create mode 100644 app/jobs/sync_github_repos_job.rb create mode 100644 app/jobs/sync_reddit_posts_job.rb create mode 100644 app/models/github_repo.rb create mode 100644 app/models/reddit_post.rb create mode 100644 app/views/admin/github_repos/_form.html.erb create mode 100644 app/views/admin/github_repos/edit.html.erb create mode 100644 app/views/admin/github_repos/index.html.erb create mode 100644 app/views/admin/github_repos/new.html.erb create mode 100644 app/views/admin/github_repos/show.html.erb create mode 100644 app/views/admin/reddit_posts/_form.html.erb create mode 100644 app/views/admin/reddit_posts/edit.html.erb create mode 100644 app/views/admin/reddit_posts/index.html.erb create mode 100644 app/views/admin/reddit_posts/new.html.erb create mode 100644 app/views/admin/reddit_posts/show.html.erb create mode 100644 db/migrate/20260222122911_create_github_repos.rb create mode 100644 db/migrate/20260222122912_create_reddit_posts.rb create mode 100644 test/fixtures/github_repos.yml create mode 100644 test/fixtures/reddit_posts.yml create mode 100644 test/jobs/sync_github_repos_job_test.rb create mode 100644 test/jobs/sync_reddit_posts_job_test.rb create mode 100644 test/models/github_repo_test.rb create mode 100644 test/models/reddit_post_test.rb diff --git a/app/controllers/admin/article_searches_controller.rb b/app/controllers/admin/article_searches_controller.rb index fa5479d..4b5a85a 100644 --- a/app/controllers/admin/article_searches_controller.rb +++ b/app/controllers/admin/article_searches_controller.rb @@ -3,7 +3,7 @@ class ArticleSearchesController < BaseController def index if params[:id].present? article = Article.includes(:blog).find(params[:id]) - render json: {id: article.id, title: article.title, url: article.url, summary: article.summary} + render json: {id: article.id, title: article.title, url: article.url, description: article.summary} elsif params[:q].present? articles = Article.includes(:blog).search_by_title(params[:q]).recent(20) render json: articles.map { |a| diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 27a9121..4f9e561 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -10,6 +10,10 @@ def index @total_issues = NewsletterIssue.count @sent_issues = NewsletterIssue.sent.count @total_clicks = Click.count + @total_github_repos = GithubRepo.count + @unprocessed_github_repos = GithubRepo.unprocessed.count + @total_reddit_posts = RedditPost.count + @unprocessed_reddit_posts = RedditPost.unprocessed.count @recent_issues = NewsletterIssue.order(created_at: :desc).limit(5) @recent_articles = Article.includes(:blog).order(published_at: :desc).limit(5) end diff --git a/app/controllers/admin/gem_searches_controller.rb b/app/controllers/admin/gem_searches_controller.rb index 577b159..d6cc51b 100644 --- a/app/controllers/admin/gem_searches_controller.rb +++ b/app/controllers/admin/gem_searches_controller.rb @@ -3,7 +3,7 @@ class GemSearchesController < BaseController def index if params[:id].present? gem = RubyGem.find(params[:id]) - render json: {id: gem.id, name: gem.name, project_url: gem.project_url, info: gem.info} + render json: {id: gem.id, title: gem.name, url: gem.project_url, description: gem.info} elsif params[:q].present? gems = RubyGem.search_by_name(params[:q]).recent(20) render json: gems.map { |g| diff --git a/app/controllers/admin/github_repo_searches_controller.rb b/app/controllers/admin/github_repo_searches_controller.rb new file mode 100644 index 0000000..6df985f --- /dev/null +++ b/app/controllers/admin/github_repo_searches_controller.rb @@ -0,0 +1,23 @@ +module Admin + class GithubRepoSearchesController < BaseController + def index + if params[:id].present? + repo = GithubRepo.find(params[:id]) + render json: {id: repo.id, title: repo.full_name, url: repo.url, description: repo.description} + elsif params[:q].present? + repos = GithubRepo.search_by_name(params[:q]).recent(20) + render json: repos.map { |r| + {value: r.id, label: "#{r.full_name} (#{number_with_delimiter(r.stars)} stars)"} + } + else + render json: [] + end + end + + private + + def number_with_delimiter(number) + ActiveSupport::NumberHelper.number_to_delimited(number) + end + end +end diff --git a/app/controllers/admin/github_repos_controller.rb b/app/controllers/admin/github_repos_controller.rb new file mode 100644 index 0000000..02bd6ae --- /dev/null +++ b/app/controllers/admin/github_repos_controller.rb @@ -0,0 +1,66 @@ +module Admin + class GithubReposController < BaseController + before_action :set_github_repo, only: [:show, :edit, :update, :destroy] + + PERIOD_FILTERS = { + "last_week" => 1.week, + "last_2_weeks" => 2.weeks, + "last_month" => 1.month + }.freeze + + def index + scope = GithubRepo.all + + @period = params[:period] + scope = scope.where("repo_pushed_at >= ?", PERIOD_FILTERS[@period].ago) if PERIOD_FILTERS.key?(@period) + + @search = params[:search] + scope = scope.search_by_name(@search) if @search.present? + + @pagy, @github_repos = pagy(scope) + end + + def show + end + + def new + @github_repo = GithubRepo.new + end + + def create + @github_repo = GithubRepo.new(github_repo_params) + + if @github_repo.save + redirect_to admin_github_repo_path(@github_repo), notice: "GitHub repo created." + else + render :new, status: :unprocessable_content + end + end + + def edit + end + + def update + if @github_repo.update(github_repo_params) + redirect_to admin_github_repo_path(@github_repo), notice: "GitHub repo updated." + else + render :edit, status: :unprocessable_content + end + end + + def destroy + @github_repo.destroy + redirect_to admin_github_repos_path, notice: "GitHub repo deleted." + end + + private + + def set_github_repo + @github_repo = GithubRepo.find(params[:id]) + end + + def github_repo_params + params.require(:github_repo).permit(:full_name, :name, :description, :url, :stars, :forks, :language, :owner_name, :owner_avatar_url, :repo_created_at, :repo_pushed_at, :processed, :featured_in_issue, topics: []) + end + end +end diff --git a/app/controllers/admin/newsletter_issues_controller.rb b/app/controllers/admin/newsletter_issues_controller.rb index d0ddb80..b938d4b 100644 --- a/app/controllers/admin/newsletter_issues_controller.rb +++ b/app/controllers/admin/newsletter_issues_controller.rb @@ -85,7 +85,7 @@ def newsletter_issue_params :issue_number, :subject, :sent_at, :subscriber_count, :total_clicks, :total_unique_clicks, newsletter_sections_attributes: [ :id, :title, :position, :_destroy, - newsletter_items_attributes: [:id, :title, :description, :url, :position, :article_id, :ruby_gem_id, :linkable_type, :linkable_id, :_destroy] + newsletter_items_attributes: [:id, :title, :description, :url, :position, :linkable_type, :linkable_id, :_destroy] ] ) end diff --git a/app/controllers/admin/reddit_post_searches_controller.rb b/app/controllers/admin/reddit_post_searches_controller.rb new file mode 100644 index 0000000..b385fc7 --- /dev/null +++ b/app/controllers/admin/reddit_post_searches_controller.rb @@ -0,0 +1,17 @@ +module Admin + class RedditPostSearchesController < BaseController + def index + if params[:id].present? + post = RedditPost.find(params[:id]) + render json: {id: post.id, title: post.title, url: post.url, description: "r/#{post.subreddit} by #{post.author}"} + elsif params[:q].present? + posts = RedditPost.search_by_title(params[:q]).recent(20) + render json: posts.map { |p| + {value: p.id, label: "#{p.title} (r/#{p.subreddit})"} + } + else + render json: [] + end + end + end +end diff --git a/app/controllers/admin/reddit_posts_controller.rb b/app/controllers/admin/reddit_posts_controller.rb new file mode 100644 index 0000000..501a55a --- /dev/null +++ b/app/controllers/admin/reddit_posts_controller.rb @@ -0,0 +1,67 @@ +module Admin + class RedditPostsController < BaseController + before_action :set_reddit_post, only: [:show, :edit, :update, :destroy] + + PERIOD_FILTERS = { + "last_week" => 1.week, + "last_2_weeks" => 2.weeks, + "last_month" => 1.month + }.freeze + + def index + scope = RedditPost.all + scope = scope.from_subreddit(params[:subreddit]) if params[:subreddit].present? + + @period = params[:period] + scope = scope.where("posted_at >= ?", PERIOD_FILTERS[@period].ago) if PERIOD_FILTERS.key?(@period) + + @search = params[:search] + scope = scope.search_by_title(@search) if @search.present? + + @pagy, @reddit_posts = pagy(scope) + end + + def show + end + + def new + @reddit_post = RedditPost.new + end + + def create + @reddit_post = RedditPost.new(reddit_post_params) + + if @reddit_post.save + redirect_to admin_reddit_post_path(@reddit_post), notice: "Reddit post created." + else + render :new, status: :unprocessable_content + end + end + + def edit + end + + def update + if @reddit_post.update(reddit_post_params) + redirect_to admin_reddit_post_path(@reddit_post), notice: "Reddit post updated." + else + render :edit, status: :unprocessable_content + end + end + + def destroy + @reddit_post.destroy + redirect_to admin_reddit_posts_path, notice: "Reddit post deleted." + end + + private + + def set_reddit_post + @reddit_post = RedditPost.find(params[:id]) + end + + def reddit_post_params + params.require(:reddit_post).permit(:reddit_id, :title, :url, :external_url, :score, :author, :subreddit, :num_comments, :posted_at, :processed, :featured_in_issue) + end + end +end diff --git a/app/javascript/controllers/article_autofill_controller.js b/app/javascript/controllers/article_autofill_controller.js deleted file mode 100644 index a4a9a99..0000000 --- a/app/javascript/controllers/article_autofill_controller.js +++ /dev/null @@ -1,48 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["titleField", "urlField", "descriptionField"] - static values = { url: String } - - connect() { - const hiddenField = this.element.querySelector('[data-lui-combobox-target="hiddenField"]') - if (hiddenField) this.interceptHiddenField(hiddenField) - } - - interceptHiddenField(field) { - const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value") - const controller = this - - Object.defineProperty(field, "value", { - get() { - return descriptor.get.call(this) - }, - set(val) { - const oldVal = descriptor.get.call(this) - descriptor.set.call(this, val) - if (val && val !== oldVal) { - controller.articleSelected(val) - } - } - }) - } - - async articleSelected(articleId) { - const url = `${this.urlValue}?id=${encodeURIComponent(articleId)}` - const response = await fetch(url, { - headers: { "Accept": "application/json" } - }) - - if (!response.ok) return - - const article = await response.json() - - const titleInput = this.titleFieldTarget.querySelector("input") - const urlInput = this.urlFieldTarget.querySelector("input") - const descInput = this.descriptionFieldTarget.querySelector("textarea") - - if (titleInput) titleInput.value = article.title - if (urlInput) urlInput.value = article.url - if (descInput) descInput.value = article.summary || "" - } -} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index be7e4f0..6f4053e 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -30,11 +30,11 @@ application.register("nested-form", NestedFormController) import SortableController from "./sortable_controller" application.register("sortable", SortableController) -import ArticleAutofillController from "./article_autofill_controller" -application.register("article-autofill", ArticleAutofillController) - import SearchFormController from "./search_form_controller" application.register("search-form", SearchFormController) -import GemAutofillController from "./gem_autofill_controller" -application.register("gem-autofill", GemAutofillController) +import LinkableSelectorController from "./linkable_selector_controller" +application.register("linkable-selector", LinkableSelectorController) + +import LinkableAutofillController from "./linkable_autofill_controller" +application.register("linkable-autofill", LinkableAutofillController) diff --git a/app/javascript/controllers/gem_autofill_controller.js b/app/javascript/controllers/linkable_autofill_controller.js similarity index 78% rename from app/javascript/controllers/gem_autofill_controller.js rename to app/javascript/controllers/linkable_autofill_controller.js index 8f966fd..3fa83c7 100644 --- a/app/javascript/controllers/gem_autofill_controller.js +++ b/app/javascript/controllers/linkable_autofill_controller.js @@ -20,21 +20,21 @@ export default class extends Controller { const oldVal = descriptor.get.call(this) descriptor.set.call(this, val) if (val && val !== oldVal) { - controller.gemSelected(val) + controller.itemSelected(val) } } }) } - async gemSelected(gemId) { - const url = `${this.urlValue}?id=${encodeURIComponent(gemId)}` + async itemSelected(itemId) { + const url = `${this.urlValue}?id=${encodeURIComponent(itemId)}` const response = await fetch(url, { headers: { "Accept": "application/json" } }) if (!response.ok) return - const gem = await response.json() + const data = await response.json() const wrapper = this.element.closest("[data-nested-form-wrapper]") if (!wrapper) return @@ -43,8 +43,8 @@ export default class extends Controller { const urlInput = wrapper.querySelector('input[name$="[url]"]') const descInput = wrapper.querySelector('textarea[name$="[description]"]') - if (titleInput) titleInput.value = gem.name - if (urlInput) urlInput.value = gem.project_url - if (descInput) descInput.value = gem.info || "" + if (titleInput) titleInput.value = data.title + if (urlInput) urlInput.value = data.url + if (descInput) descInput.value = data.description || "" } } diff --git a/app/javascript/controllers/linkable_selector_controller.js b/app/javascript/controllers/linkable_selector_controller.js new file mode 100644 index 0000000..1da5846 --- /dev/null +++ b/app/javascript/controllers/linkable_selector_controller.js @@ -0,0 +1,34 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["typeSelect", "panel"] + + connect() { + this.syncPanels() + } + + typeChanged() { + this.syncPanels() + } + + syncPanels() { + const selectedType = this.typeSelectTarget.value + + this.panelTargets.forEach(panel => { + const hiddenField = panel.querySelector('[data-lui-combobox-target="hiddenField"]') + const textInput = panel.querySelector('[data-lui-combobox-target="input"]') + + if (panel.dataset.linkableType === selectedType) { + panel.classList.remove("hidden") + if (hiddenField) hiddenField.disabled = false + } else { + panel.classList.add("hidden") + if (hiddenField) { + hiddenField.value = "" + hiddenField.disabled = true + } + if (textInput) textInput.value = "" + } + }) + } +} diff --git a/app/jobs/sync_github_repos_job.rb b/app/jobs/sync_github_repos_job.rb new file mode 100644 index 0000000..6f9ff08 --- /dev/null +++ b/app/jobs/sync_github_repos_job.rb @@ -0,0 +1,8 @@ +class SyncGithubReposJob < ApplicationJob + queue_as :default + unique :until_executed, on_conflict: :log + + def perform + GithubRepo.sync_from_api! + end +end diff --git a/app/jobs/sync_reddit_posts_job.rb b/app/jobs/sync_reddit_posts_job.rb new file mode 100644 index 0000000..242c925 --- /dev/null +++ b/app/jobs/sync_reddit_posts_job.rb @@ -0,0 +1,8 @@ +class SyncRedditPostsJob < ApplicationJob + queue_as :default + unique :until_executed, on_conflict: :log + + def perform + RedditPost.sync_from_api! + end +end diff --git a/app/models/github_repo.rb b/app/models/github_repo.rb new file mode 100644 index 0000000..1ff2c06 --- /dev/null +++ b/app/models/github_repo.rb @@ -0,0 +1,89 @@ +class GithubRepo < ApplicationRecord + API_BASE = "https://api.github.com/search/repositories" + API_TIMEOUT = 15 + + has_many :newsletter_items, as: :linkable, dependent: :nullify + + validates :full_name, presence: true, uniqueness: true + validates :name, presence: true + validates :url, presence: true + + default_scope { order(repo_pushed_at: :desc) } + + scope :recent, ->(limit = 15) { limit(limit) } + scope :unprocessed, -> { where(processed: false) } + scope :featured, -> { where.not(featured_in_issue: nil) } + scope :search_by_name, ->(query) { where("full_name ILIKE ?", "%#{sanitize_sql_like(query)}%") } + scope :popular, -> { unscoped.order(stars: :desc) } + + def self.sync_from_api! + daily_repos = fetch_repos(1.day.ago) + weekly_repos = fetch_repos(1.week.ago) + + records = daily_repos.merge(weekly_repos) + return [] if records.empty? + + now = Time.current + rows = records.values.map { |r| r.merge(first_seen_at: now, last_synced_at: now) } + + upsert_all( + rows, + unique_by: :index_github_repos_on_full_name, + update_only: %i[name description url stars forks language owner_name owner_avatar_url topics repo_pushed_at last_synced_at] + ) + rescue Faraday::Error, JSON::ParserError => e + Rails.logger.error("GithubRepo sync failed: #{e.message}") + [] + end + + def self.fetch_repos(pushed_after) + date = pushed_after.strftime("%Y-%m-%d") + response = http_client.get(API_BASE) do |req| + req.params["q"] = "language:ruby pushed:>#{date}" + req.params["sort"] = "stars" + req.params["order"] = "desc" + req.params["per_page"] = 50 + req.options.timeout = API_TIMEOUT + req.options.open_timeout = API_TIMEOUT + end + + data = JSON.parse(response.body) + items = data["items"] || [] + + items.each_with_object({}) do |repo, hash| + full_name = repo["full_name"] + next if full_name.blank? + + hash[full_name] = { + full_name: full_name, + name: repo["name"], + description: repo["description"]&.squish, + url: repo["html_url"], + stars: repo["stargazers_count"].to_i, + forks: repo["forks_count"].to_i, + language: repo["language"], + owner_name: repo.dig("owner", "login"), + owner_avatar_url: repo.dig("owner", "avatar_url"), + topics: repo["topics"] || [], + repo_created_at: repo["created_at"], + repo_pushed_at: repo["pushed_at"] + } + end + rescue Faraday::Error, JSON::ParserError => e + Rails.logger.error("Failed to fetch GitHub repos (pushed>#{date}): #{e.message}") + {} + end + + def self.http_client + @http_client ||= Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| + f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" + f.headers["Accept"] = "application/vnd.github+json" + token = Rails.application.credentials.github_token + f.headers["Authorization"] = "Bearer #{token}" if token.present? + f.response :follow_redirects + f.adapter Faraday.default_adapter + end + end + + private_class_method :fetch_repos, :http_client +end diff --git a/app/models/newsletter_item.rb b/app/models/newsletter_item.rb index e53ad61..5a4b39f 100644 --- a/app/models/newsletter_item.rb +++ b/app/models/newsletter_item.rb @@ -31,6 +31,8 @@ class NewsletterItem < ApplicationRecord validates :title, presence: true validates :url, presence: true + before_validation :clear_blank_linkable + default_scope { order(:position) } def article @@ -67,6 +69,51 @@ def ruby_gem_id=(id) end end + def github_repo + linkable if linkable_type == "GithubRepo" + end + + def github_repo_id + linkable_id if linkable_type == "GithubRepo" + end + + def github_repo_id=(id) + if id.present? + self.linkable_type = "GithubRepo" + self.linkable_id = id + elsif linkable_type == "GithubRepo" + self.linkable = nil + end + end + + def reddit_post + linkable if linkable_type == "RedditPost" + end + + def reddit_post_id + linkable_id if linkable_type == "RedditPost" + end + + def reddit_post_id=(id) + if id.present? + self.linkable_type = "RedditPost" + self.linkable_id = id + elsif linkable_type == "RedditPost" + self.linkable = nil + end + end + + private + + def clear_blank_linkable + if linkable_type.blank? || linkable_id.blank? + self.linkable_type = nil + self.linkable_id = nil + end + end + + public + def first_flight? return false unless article&.blog_id diff --git a/app/models/reddit_post.rb b/app/models/reddit_post.rb new file mode 100644 index 0000000..c2cf524 --- /dev/null +++ b/app/models/reddit_post.rb @@ -0,0 +1,97 @@ +class RedditPost < ApplicationRecord + SUBREDDITS = %w[ruby rails].freeze + FEED_TIMEOUT = 15 + + has_many :newsletter_items, as: :linkable, dependent: :nullify + + validates :reddit_id, presence: true, uniqueness: true + validates :title, presence: true + validates :url, presence: true + validates :subreddit, presence: true, inclusion: {in: SUBREDDITS} + + default_scope { order(posted_at: :desc) } + + scope :recent, ->(limit = 15) { limit(limit) } + scope :unprocessed, -> { where(processed: false) } + scope :from_subreddit, ->(sub) { where(subreddit: sub) } + scope :featured, -> { where.not(featured_in_issue: nil) } + scope :search_by_title, ->(query) { where("title ILIKE ?", "%#{sanitize_sql_like(query)}%") } + + def self.sync_from_api! + records = {} + + SUBREDDITS.each do |subreddit| + records.merge!(fetch_feed(subreddit)) + end + + return [] if records.empty? + + now = Time.current + rows = records.values.map { |r| r.merge(first_seen_at: now, last_synced_at: now) } + + upsert_all( + rows, + unique_by: :index_reddit_posts_on_reddit_id, + update_only: %i[title url score num_comments last_synced_at] + ) + rescue Faraday::Error, Feedjira::NoParserAvailable => e + Rails.logger.error("RedditPost sync failed: #{e.message}") + [] + end + + def self.fetch_feed(subreddit) + rss_url = "https://www.reddit.com/r/#{subreddit}/hot/.rss?limit=50" + response = http_client.get(rss_url) do |req| + req.options.timeout = FEED_TIMEOUT + req.options.open_timeout = FEED_TIMEOUT + end + + feed = Feedjira.parse(response.body) + + feed.entries.each_with_object({}) do |entry, hash| + next if entry.title.blank? + + permalink = entry.url.to_s.strip + rid = extract_reddit_id(permalink) + next if rid.blank? + + hash[rid] = { + reddit_id: rid, + title: entry.title.strip, + url: permalink, + external_url: extract_external_url(entry), + score: 0, + author: entry.try(:author)&.strip, + subreddit: subreddit, + num_comments: 0, + posted_at: entry.published + } + end + rescue Faraday::Error, Feedjira::NoParserAvailable => e + Rails.logger.error("Failed to fetch Reddit feed for r/#{subreddit}: #{e.message}") + {} + end + + def self.extract_reddit_id(permalink) + match = permalink.match(%r{/comments/([a-z0-9]+)}i) + match&.captures&.first + end + + def self.extract_external_url(entry) + content = entry.try(:content) || entry.try(:summary) || "" + match = content.match(%r{\[link\]}) + link = match&.captures&.first + return nil if link.blank? || link.include?("reddit.com") + link + end + + def self.http_client + @http_client ||= Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| + f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" + f.response :follow_redirects + f.adapter Faraday.default_adapter + end + end + + private_class_method :fetch_feed, :extract_reddit_id, :extract_external_url, :http_client +end diff --git a/app/views/admin/github_repos/_form.html.erb b/app/views/admin/github_repos/_form.html.erb new file mode 100644 index 0000000..1ff333b --- /dev/null +++ b/app/views/admin/github_repos/_form.html.erb @@ -0,0 +1,32 @@ +<%# locals: (github_repo:) -%> +<% if github_repo.errors.any? %> +
+ <%= lui.alert(type: :error) do %> + <%= pluralize(github_repo.errors.count, "error") %> + prevented this repo from being saved: +
    + <% github_repo.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+ <% end %> +
+<% end %> + +<%= form_with model: [:admin, github_repo], class: "space-y-4 max-w-2xl" do |form| %> + <%= lui.input(name: :full_name, form: form, label: "Full Name") %> + <%= lui.input(name: :name, form: form, label: "Name") %> + <%= lui.textarea(name: :description, form: form, label: "Description", rows: 3) %> + <%= lui.input(name: :url, form: form, type: :url, label: "URL") %> + <%= lui.input(name: :stars, form: form, type: :number, label: "Stars") %> + <%= lui.input(name: :forks, form: form, type: :number, label: "Forks") %> + <%= lui.input(name: :language, form: form, label: "Language") %> + <%= lui.input(name: :owner_name, form: form, label: "Owner Name") %> + <%= lui.input(name: :owner_avatar_url, form: form, type: :url, label: "Owner Avatar URL") %> + <%= lui.input(name: :repo_created_at, form: form, type: :datetime_local, label: "Repo Created At") %> + <%= lui.input(name: :repo_pushed_at, form: form, type: :datetime_local, label: "Repo Pushed At") %> + <%= lui.switch(name: :processed, form: form, label: "Processed", enabled: github_repo.processed?) %> + <%= lui.input(name: :featured_in_issue, form: form, type: :number, label: "Featured in Issue") %> + +
<%= lui.button(type: :submit) { github_repo.persisted? ? "Update Repo" : "Create Repo" } %>
+<% end %> diff --git a/app/views/admin/github_repos/edit.html.erb b/app/views/admin/github_repos/edit.html.erb new file mode 100644 index 0000000..8a42113 --- /dev/null +++ b/app/views/admin/github_repos/edit.html.erb @@ -0,0 +1,7 @@ +
+ <%= link_to "← #{@github_repo.full_name}", admin_github_repo_path(@github_repo), class: "text-sm admin-link-back" %> + +

Edit GitHub Repo

+
+ +<%= render "form", github_repo: @github_repo %> diff --git a/app/views/admin/github_repos/index.html.erb b/app/views/admin/github_repos/index.html.erb new file mode 100644 index 0000000..ded99bd --- /dev/null +++ b/app/views/admin/github_repos/index.html.erb @@ -0,0 +1,62 @@ +
+
+

GitHub Repos

+

<%= @pagy.count %> repos total

+
+ +
+
+ <%= lui.input( + name: :search, + label: false, + placeholder: "Search by name...", + value: @search, + input_data: { + search_form_target: "input", + action: "input->search-form#search" + } + ) %> +
+ +
+ <%= lui.select( + name: :period, + label: false, + options_for_select: options_for_select( + [["All time", ""], ["Last week", "last_week"], ["Last 2 weeks", "last_2_weeks"], ["Last month", "last_month"]], + @period + ), + select_data: { action: "change->auto-submit#submit" } + ) %> +
+ + <%= lui.button(url: new_admin_github_repo_path, icon: "plus") { "New Repo" } %> +
+
+ +<% if @github_repos.any? %> + <%= lui.table(data: @github_repos) do |table| %> + <% table.with_column("Name") { |repo| link_to repo.full_name, admin_github_repo_path(repo) } %> + <% table.with_column("Stars") { |repo| number_with_delimiter(repo.stars) } %> + <% table.with_column("Forks") { |repo| number_with_delimiter(repo.forks) } %> + <% table.with_column("Language") { |repo| repo.language || "—" } %> + <% table.with_column("Pushed") { |repo| repo.repo_pushed_at&.strftime("%b %d, %Y") || "—" } %> + + <% table.with_column("Processed") do |repo| %> + <% if repo.processed? %> + <%= lui.badge(status: :success) { "Yes" } %> + <% else %> + <%= lui.badge(status: :warning) { "No" } %> + <% end %> + <% end %> + + <% table.with_action { |repo| link_to "Edit", edit_admin_github_repo_path(repo) } %> + <% table.with_action { |repo| link_to "Delete", admin_github_repo_path(repo), data: { turbo_method: :delete, turbo_confirm: "Delete this repo?" } } %> + <% end %> + <%= render "admin/shared/pagination", pagy: @pagy %> +<% else %> +
+

No GitHub repos yet

+ <%= lui.button(url: new_admin_github_repo_path, icon: "plus") { "Add your first repo" } %> +
+<% end %> diff --git a/app/views/admin/github_repos/new.html.erb b/app/views/admin/github_repos/new.html.erb new file mode 100644 index 0000000..d04deab --- /dev/null +++ b/app/views/admin/github_repos/new.html.erb @@ -0,0 +1,7 @@ +
+ <%= link_to "← GitHub Repos", admin_github_repos_path, class: "text-sm admin-link-back" %> + +

New GitHub Repo

+
+ +<%= render "form", github_repo: @github_repo %> diff --git a/app/views/admin/github_repos/show.html.erb b/app/views/admin/github_repos/show.html.erb new file mode 100644 index 0000000..606f460 --- /dev/null +++ b/app/views/admin/github_repos/show.html.erb @@ -0,0 +1,51 @@ +
<%= link_to "← GitHub Repos", admin_github_repos_path, class: "text-sm admin-link-back" %>
+ +
+

<%= @github_repo.full_name %>

+ +
+ <%= lui.button(url: edit_admin_github_repo_path(@github_repo), style: :outline, icon: "pencil") { "Edit" } %> + <%= lui.button(url: admin_github_repo_path(@github_repo), style: :outline, icon: "trash", data: { turbo_method: :delete, turbo_confirm: "Delete this repo?" }) { "Delete" } %> +
+
+ +<%= lui.description_list do |list| %> + <% list.with_item(label: "Full Name", value: @github_repo.full_name) %> + <% list.with_item(label: "Name", value: @github_repo.name) %> + <% list.with_item(label: "Description", value: @github_repo.description || "—") %> + + <% list.with_item(label: "URL") do %> + <%= link_to @github_repo.url, safe_external_url(@github_repo.url), target: "_blank", rel: "noopener noreferrer" %> + <% end %> + + <% list.with_item(label: "Stars", value: number_with_delimiter(@github_repo.stars)) %> + <% list.with_item(label: "Forks", value: number_with_delimiter(@github_repo.forks)) %> + <% list.with_item(label: "Language", value: @github_repo.language || "—") %> + <% list.with_item(label: "Owner", value: @github_repo.owner_name || "—") %> + + <% list.with_item(label: "Topics") do %> + <% if @github_repo.topics.any? %> +
+ <% @github_repo.topics.each do |topic| %> + <%= lui.badge { topic } %> + <% end %> +
+ <% else %> + — + <% end %> + <% end %> + + <% list.with_item(label: "Processed") do %> + <% if @github_repo.processed? %> + <%= lui.badge(status: :success) { "Yes" } %> + <% else %> + <%= lui.badge(status: :warning) { "No" } %> + <% end %> + <% end %> + + <% list.with_item(label: "Featured in Issue", value: @github_repo.featured_in_issue || "—") %> + <% list.with_item(label: "Repo Created", value: @github_repo.repo_created_at&.strftime("%b %d, %Y %H:%M") || "—") %> + <% list.with_item(label: "Repo Pushed", value: @github_repo.repo_pushed_at&.strftime("%b %d, %Y %H:%M") || "—") %> + <% list.with_item(label: "First Seen", value: @github_repo.first_seen_at&.strftime("%b %d, %Y %H:%M") || "—") %> + <% list.with_item(label: "Last Synced", value: @github_repo.last_synced_at&.strftime("%b %d, %Y %H:%M") || "—") %> +<% end %> diff --git a/app/views/admin/newsletter_issues/_form.html.erb b/app/views/admin/newsletter_issues/_form.html.erb index 150077a..5bfdac3 100644 --- a/app/views/admin/newsletter_issues/_form.html.erb +++ b/app/views/admin/newsletter_issues/_form.html.erb @@ -22,7 +22,7 @@

Sections

- + <%= lui.button(type: :button, style: :outline, icon: "plus", data: { action: "nested-form#add" }) %>
diff --git a/app/views/admin/newsletter_issues/_item_fields.html.erb b/app/views/admin/newsletter_issues/_item_fields.html.erb index 9d71e7a..0f70619 100644 --- a/app/views/admin/newsletter_issues/_item_fields.html.erb +++ b/app/views/admin/newsletter_issues/_item_fields.html.erb @@ -1,16 +1,34 @@ <%# locals: (item_form:) -%>
- +
+ -
-
-
+
+
+ <%= lui.select( + name: :linkable_type, + form: item_form, + label: "Source", + options_for_select: options_for_select( + [["None", ""], ["Article", "Article"], ["Gem", "RubyGem"], ["GitHub Repo", "GithubRepo"], ["Reddit Post", "RedditPost"]], + item_form.object.linkable_type + ), + select_data: { + linkable_selector_target: "typeSelect", + action: "change->linkable-selector#typeChanged" + } + ) %> +
+ +
"> <%= lui.combobox( - name: :article_id, + name: :linkable_id, form: item_form, label: "Article", placeholder: "Search articles…", @@ -18,15 +36,13 @@ min_chars: 2, debounce: 250 ) %> - - - -
-
+
"> <%= lui.combobox( - name: :ruby_gem_id, + name: :linkable_id, form: item_form, label: "Gem", placeholder: "Search gems…", @@ -35,16 +51,46 @@ debounce: 250 ) %>
+ +
"> + <%= lui.combobox( + name: :linkable_id, + form: item_form, + label: "GitHub Repo", + placeholder: "Search repos…", + url: admin_github_repo_searches_path, + min_chars: 2, + debounce: 250 + ) %> +
+ +
"> + <%= lui.combobox( + name: :linkable_id, + form: item_form, + label: "Reddit Post", + placeholder: "Search posts…", + url: admin_reddit_post_searches_path, + min_chars: 2, + debounce: 250 + ) %> +
- <%= lui.input(name: :title, form: item_form, label: "Title") %> - <%= lui.input(name: :url, form: item_form, type: :url, label: "URL") %> - <%= lui.textarea(name: :description, form: item_form, label: "Description", rows: 2) %> +
+ <%= lui.button(type: :button, style: :outline, icon: "trash", data: { action: "nested-form#remove" }) %> +
+ <%= lui.input(name: :title, form: item_form, label: "Title") %> + <%= lui.input(name: :url, form: item_form, type: :url, label: "URL") %> + <%= lui.textarea(name: :description, form: item_form, label: "Description", rows: 2) %> + <%= item_form.hidden_field :id %> <%= item_form.hidden_field :position %> <%= item_form.hidden_field :_destroy, value: "0" %> - -
diff --git a/app/views/admin/newsletter_issues/_section_fields.html.erb b/app/views/admin/newsletter_issues/_section_fields.html.erb index 556f1f7..ed36b96 100644 --- a/app/views/admin/newsletter_issues/_section_fields.html.erb +++ b/app/views/admin/newsletter_issues/_section_fields.html.erb @@ -6,10 +6,12 @@ class="space-y-4 rounded-lg border p-4" style="border-color: var(--lui-theme-border); background: var(--lui-theme-surface-secondary);" > -
- +
+
<%= lui.input(name: :title, form: section_form, label: "Section Title") %>
- +
+ <%= lui.button(type: :button, style: :outline, icon: "trash", data: { action: "nested-form#remove" }) %> +
<%= section_form.hidden_field :id %> @@ -19,7 +21,7 @@

Items

- + <%= lui.button(type: :button, style: :outline, icon: "plus", data: { action: "nested-form#add" }) %>
diff --git a/app/views/admin/reddit_posts/_form.html.erb b/app/views/admin/reddit_posts/_form.html.erb new file mode 100644 index 0000000..43e4dae --- /dev/null +++ b/app/views/admin/reddit_posts/_form.html.erb @@ -0,0 +1,30 @@ +<%# locals: (reddit_post:) -%> +<% if reddit_post.errors.any? %> +
+ <%= lui.alert(type: :error) do %> + <%= pluralize(reddit_post.errors.count, "error") %> + prevented this post from being saved: +
    + <% reddit_post.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+ <% end %> +
+<% end %> + +<%= form_with model: [:admin, reddit_post], class: "space-y-4 max-w-2xl" do |form| %> + <%= lui.input(name: :reddit_id, form: form, label: "Reddit ID") %> + <%= lui.input(name: :title, form: form, label: "Title") %> + <%= lui.input(name: :url, form: form, type: :url, label: "URL") %> + <%= lui.input(name: :external_url, form: form, type: :url, label: "External URL") %> + <%= lui.select(name: :subreddit, form: form, label: "Subreddit", options_for_select: [["r/ruby", "ruby"], ["r/rails", "rails"]], include_blank: false) %> + <%= lui.input(name: :author, form: form, label: "Author") %> + <%= lui.input(name: :score, form: form, type: :number, label: "Score") %> + <%= lui.input(name: :num_comments, form: form, type: :number, label: "Comments") %> + <%= lui.input(name: :posted_at, form: form, type: :datetime_local, label: "Posted At") %> + <%= lui.switch(name: :processed, form: form, label: "Processed", enabled: reddit_post.processed?) %> + <%= lui.input(name: :featured_in_issue, form: form, type: :number, label: "Featured in Issue") %> + +
<%= lui.button(type: :submit) { reddit_post.persisted? ? "Update Post" : "Create Post" } %>
+<% end %> diff --git a/app/views/admin/reddit_posts/edit.html.erb b/app/views/admin/reddit_posts/edit.html.erb new file mode 100644 index 0000000..7907d6a --- /dev/null +++ b/app/views/admin/reddit_posts/edit.html.erb @@ -0,0 +1,7 @@ +
+ <%= link_to "← #{truncate(@reddit_post.title, length: 40)}", admin_reddit_post_path(@reddit_post), class: "text-sm admin-link-back" %> + +

Edit Reddit Post

+
+ +<%= render "form", reddit_post: @reddit_post %> diff --git a/app/views/admin/reddit_posts/index.html.erb b/app/views/admin/reddit_posts/index.html.erb new file mode 100644 index 0000000..26b786a --- /dev/null +++ b/app/views/admin/reddit_posts/index.html.erb @@ -0,0 +1,77 @@ +
+
+

Reddit Posts

+

<%= @pagy.count %> posts total

+
+ +
+
+ <%= lui.input( + name: :search, + label: false, + placeholder: "Search by title...", + value: @search, + input_data: { + search_form_target: "input", + action: "input->search-form#search" + } + ) %> +
+ +
+ <%= lui.select( + name: :subreddit, + label: false, + options_for_select: options_for_select( + [["All subreddits", ""], ["r/ruby", "ruby"], ["r/rails", "rails"]], + params[:subreddit] + ), + select_data: { action: "change->auto-submit#submit" } + ) %> +
+ +
+ <%= lui.select( + name: :period, + label: false, + options_for_select: options_for_select( + [["All time", ""], ["Last week", "last_week"], ["Last 2 weeks", "last_2_weeks"], ["Last month", "last_month"]], + @period + ), + select_data: { action: "change->auto-submit#submit" } + ) %> +
+ + <%= lui.button(url: new_admin_reddit_post_path, icon: "plus") { "New Post" } %> +
+
+ +<% if @reddit_posts.any? %> + <%= lui.table(data: @reddit_posts) do |table| %> + <% table.with_column("Title") { |post| link_to truncate(post.title, length: 60), admin_reddit_post_path(post) } %> + + <% table.with_column("Subreddit") do |post| %> + <%= lui.badge { "r/#{post.subreddit}" } %> + <% end %> + + <% table.with_column("Author") { |post| post.author || "—" } %> + <% table.with_column("Posted") { |post| post.posted_at&.strftime("%b %d, %Y") || "—" } %> + + <% table.with_column("Processed") do |post| %> + <% if post.processed? %> + <%= lui.badge(status: :success) { "Yes" } %> + <% else %> + <%= lui.badge(status: :warning) { "No" } %> + <% end %> + <% end %> + + <% table.with_action { |post| link_to "Edit", edit_admin_reddit_post_path(post) } %> + <% table.with_action { |post| link_to "Delete", admin_reddit_post_path(post), data: { turbo_method: :delete, turbo_confirm: "Delete this post?" } } %> + <% end %> + <%= render "admin/shared/pagination", pagy: @pagy %> +<% else %> +
+

No Reddit posts yet

+ <%= lui.button(url: new_admin_reddit_post_path, icon: "plus") { "Add your first post" } %> +
+<% end %> diff --git a/app/views/admin/reddit_posts/new.html.erb b/app/views/admin/reddit_posts/new.html.erb new file mode 100644 index 0000000..7bc54bc --- /dev/null +++ b/app/views/admin/reddit_posts/new.html.erb @@ -0,0 +1,7 @@ +
+ <%= link_to "← Reddit Posts", admin_reddit_posts_path, class: "text-sm admin-link-back" %> + +

New Reddit Post

+
+ +<%= render "form", reddit_post: @reddit_post %> diff --git a/app/views/admin/reddit_posts/show.html.erb b/app/views/admin/reddit_posts/show.html.erb new file mode 100644 index 0000000..84745ae --- /dev/null +++ b/app/views/admin/reddit_posts/show.html.erb @@ -0,0 +1,48 @@ +
<%= link_to "← Reddit Posts", admin_reddit_posts_path, class: "text-sm admin-link-back" %>
+ +
+

<%= truncate(@reddit_post.title, length: 80) %>

+ +
+ <%= lui.button(url: edit_admin_reddit_post_path(@reddit_post), style: :outline, icon: "pencil") { "Edit" } %> + <%= lui.button(url: admin_reddit_post_path(@reddit_post), style: :outline, icon: "trash", data: { turbo_method: :delete, turbo_confirm: "Delete this post?" }) { "Delete" } %> +
+
+ +<%= lui.description_list do |list| %> + <% list.with_item(label: "Title", value: @reddit_post.title) %> + <% list.with_item(label: "Reddit ID", value: @reddit_post.reddit_id) %> + + <% list.with_item(label: "Subreddit") do %> + <%= lui.badge { "r/#{@reddit_post.subreddit}" } %> + <% end %> + + <% list.with_item(label: "URL") do %> + <%= link_to @reddit_post.url, safe_external_url(@reddit_post.url), target: "_blank", rel: "noopener noreferrer" %> + <% end %> + + <% list.with_item(label: "External URL") do %> + <% if @reddit_post.external_url.present? %> + <%= link_to @reddit_post.external_url, safe_external_url(@reddit_post.external_url), target: "_blank", rel: "noopener noreferrer" %> + <% else %> + — + <% end %> + <% end %> + + <% list.with_item(label: "Author", value: @reddit_post.author || "—") %> + <% list.with_item(label: "Score", value: @reddit_post.score) %> + <% list.with_item(label: "Comments", value: @reddit_post.num_comments) %> + <% list.with_item(label: "Posted At", value: @reddit_post.posted_at&.strftime("%b %d, %Y %H:%M") || "—") %> + + <% list.with_item(label: "Processed") do %> + <% if @reddit_post.processed? %> + <%= lui.badge(status: :success) { "Yes" } %> + <% else %> + <%= lui.badge(status: :warning) { "No" } %> + <% end %> + <% end %> + + <% list.with_item(label: "Featured in Issue", value: @reddit_post.featured_in_issue || "—") %> + <% list.with_item(label: "First Seen", value: @reddit_post.first_seen_at&.strftime("%b %d, %Y %H:%M") || "—") %> + <% list.with_item(label: "Last Synced", value: @reddit_post.last_synced_at&.strftime("%b %d, %Y %H:%M") || "—") %> +<% end %> diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 1f2ab34..b0586ae 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -26,14 +26,16 @@ <% end %> <% layout.with_section(title: "Navigation") do |section| %> - <% section.with_link(title: "Dashboard", url: admin_root_path, icon: "home", current: current_page?(admin_root_path)) %> - <% section.with_link(title: "Blogs", url: admin_blogs_path, icon: "rss", current: request.path.start_with?("/admin/blogs")) %> - <% section.with_link(title: "Articles", url: admin_articles_path, icon: "document-text", current: request.path.start_with?("/admin/articles")) %> - <% section.with_link(title: "Gems", url: admin_ruby_gems_path, icon: "cube", current: request.path.start_with?("/admin/ruby_gems")) %> - <% section.with_link(title: "Newsletter Issues", url: admin_newsletter_issues_path, icon: "envelope", current: request.path.start_with?("/admin/newsletter_issues")) %> - <% section.with_link(title: "Subscribers", url: admin_subscribers_path, icon: "users", current: request.path.start_with?("/admin/subscribers")) %> - <% section.with_link(title: "Tracked Links", url: admin_tracked_links_path, icon: "link", current: request.path.start_with?("/admin/tracked_links")) %> - <% section.with_link(title: "Clicks", url: admin_clicks_path, icon: "cursor-arrow-rays", current: request.path.start_with?("/admin/clicks")) %> + <% section.with_link(title: "Dashboard", url: admin_root_path, icon: "home", current: current_page?(admin_root_path), animate_icon: true) %> + <% section.with_link(title: "Blogs", url: admin_blogs_path, icon: "rss", current: request.path.start_with?("/admin/blogs"), animate_icon: true) %> + <% section.with_link(title: "Articles", url: admin_articles_path, icon: "document-text", current: request.path.start_with?("/admin/articles"), animate_icon: true) %> + <% section.with_link(title: "Gems", url: admin_ruby_gems_path, icon: "cube", current: request.path.start_with?("/admin/ruby_gems"), animate_icon: true) %> + <% section.with_link(title: "GitHub Repos", url: admin_github_repos_path, icon: "code-bracket", current: request.path.start_with?("/admin/github_repos"), animate_icon: true) %> + <% section.with_link(title: "Reddit Posts", url: admin_reddit_posts_path, icon: "chat-bubble-left-right", current: request.path.start_with?("/admin/reddit_posts"), animate_icon: true) %> + <% section.with_link(title: "Newsletter Issues", url: admin_newsletter_issues_path, icon: "envelope", current: request.path.start_with?("/admin/newsletter_issues"), animate_icon: true) %> + <% section.with_link(title: "Subscribers", url: admin_subscribers_path, icon: "users", current: request.path.start_with?("/admin/subscribers"), animate_icon: true) %> + <% section.with_link(title: "Tracked Links", url: admin_tracked_links_path, icon: "link", current: request.path.start_with?("/admin/tracked_links"), animate_icon: true) %> + <% section.with_link(title: "Clicks", url: admin_clicks_path, icon: "cursor-arrow-rays", current: request.path.start_with?("/admin/clicks"), animate_icon: true) %> <% end %> <% layout.with_section(title: "Tools") do |section| %> diff --git a/config/routes.rb b/config/routes.rb index fe154df..aa85b4d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,8 @@ resources :blogs resources :articles resources :ruby_gems + resources :github_repos + resources :reddit_posts resources :newsletter_issues do member do get :preview @@ -27,6 +29,8 @@ end resources :article_searches, only: [:index] resources :gem_searches, only: [:index] + resources :github_repo_searches, only: [:index] + resources :reddit_post_searches, only: [:index] resources :subscribers resources :tracked_links resources :clicks, only: [:index, :show, :destroy] diff --git a/config/scheduler/development.yml b/config/scheduler/development.yml index e15b829..58c2006 100644 --- a/config/scheduler/development.yml +++ b/config/scheduler/development.yml @@ -6,3 +6,11 @@ sync_ruby_gems: every: "4h" class: SyncRubyGemsJob queue: default +sync_github_repos: + every: "6h" + class: SyncGithubReposJob + queue: default +sync_reddit_posts: + every: "4h" + class: SyncRedditPostsJob + queue: default diff --git a/config/scheduler/production.yml b/config/scheduler/production.yml index bd37078..85f379a 100644 --- a/config/scheduler/production.yml +++ b/config/scheduler/production.yml @@ -10,3 +10,11 @@ sync_ruby_gems: every: "4h" class: SyncRubyGemsJob queue: default +sync_github_repos: + every: "6h" + class: SyncGithubReposJob + queue: default +sync_reddit_posts: + every: "4h" + class: SyncRedditPostsJob + queue: default diff --git a/db/migrate/20260222122911_create_github_repos.rb b/db/migrate/20260222122911_create_github_repos.rb new file mode 100644 index 0000000..b13b5d1 --- /dev/null +++ b/db/migrate/20260222122911_create_github_repos.rb @@ -0,0 +1,30 @@ +class CreateGithubRepos < ActiveRecord::Migration[8.1] + def change + create_table :github_repos do |t| + t.string :full_name, null: false + t.string :name, null: false + t.text :description + t.string :url, null: false + t.integer :stars, default: 0 + t.integer :forks, default: 0 + t.string :language + t.string :owner_name + t.string :owner_avatar_url + t.text :topics, array: true, default: [] + t.boolean :processed, default: false + t.integer :featured_in_issue + t.datetime :first_seen_at + t.datetime :last_synced_at + t.datetime :repo_created_at + t.datetime :repo_pushed_at + + t.timestamps + end + + add_index :github_repos, :full_name, unique: true + add_index :github_repos, :stars + add_index :github_repos, :processed + add_index :github_repos, :featured_in_issue + add_index :github_repos, :repo_pushed_at + end +end diff --git a/db/migrate/20260222122912_create_reddit_posts.rb b/db/migrate/20260222122912_create_reddit_posts.rb new file mode 100644 index 0000000..6b76b21 --- /dev/null +++ b/db/migrate/20260222122912_create_reddit_posts.rb @@ -0,0 +1,28 @@ +class CreateRedditPosts < ActiveRecord::Migration[8.1] + def change + create_table :reddit_posts do |t| + t.string :reddit_id, null: false + t.string :title, null: false + t.string :url, null: false + t.string :external_url + t.integer :score, default: 0 + t.string :author + t.string :subreddit, null: false + t.integer :num_comments, default: 0 + t.boolean :processed, default: false + t.integer :featured_in_issue + t.datetime :first_seen_at + t.datetime :last_synced_at + t.datetime :posted_at + + t.timestamps + end + + add_index :reddit_posts, :reddit_id, unique: true + add_index :reddit_posts, :subreddit + add_index :reddit_posts, :score + add_index :reddit_posts, :processed + add_index :reddit_posts, :featured_in_issue + add_index :reddit_posts, :posted_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 1274f61..bf77d3b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_22_110355) do +ActiveRecord::Schema[8.1].define(version: 2026_02_22_122912) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -67,6 +67,32 @@ t.index ["tracked_link_id"], name: "index_clicks_on_tracked_link_id" end + create_table "github_repos", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.integer "featured_in_issue" + t.datetime "first_seen_at" + t.integer "forks", default: 0 + t.string "full_name", null: false + t.string "language" + t.datetime "last_synced_at" + t.string "name", null: false + t.string "owner_avatar_url" + t.string "owner_name" + t.boolean "processed", default: false + t.datetime "repo_created_at" + t.datetime "repo_pushed_at" + t.integer "stars", default: 0 + t.text "topics", default: [], array: true + t.datetime "updated_at", null: false + t.string "url", null: false + t.index ["featured_in_issue"], name: "index_github_repos_on_featured_in_issue" + t.index ["full_name"], name: "index_github_repos_on_full_name", unique: true + t.index ["processed"], name: "index_github_repos_on_processed" + t.index ["repo_pushed_at"], name: "index_github_repos_on_repo_pushed_at" + t.index ["stars"], name: "index_github_repos_on_stars" + end + create_table "newsletter_issues", force: :cascade do |t| t.datetime "created_at", null: false t.integer "issue_number", null: false @@ -105,6 +131,30 @@ t.index ["newsletter_issue_id"], name: "index_newsletter_sections_on_newsletter_issue_id" end + create_table "reddit_posts", force: :cascade do |t| + t.string "author" + t.datetime "created_at", null: false + t.string "external_url" + t.integer "featured_in_issue" + t.datetime "first_seen_at" + t.datetime "last_synced_at" + t.integer "num_comments", default: 0 + t.datetime "posted_at" + t.boolean "processed", default: false + t.string "reddit_id", null: false + t.integer "score", default: 0 + t.string "subreddit", null: false + t.string "title", null: false + t.datetime "updated_at", null: false + t.string "url", null: false + t.index ["featured_in_issue"], name: "index_reddit_posts_on_featured_in_issue" + t.index ["posted_at"], name: "index_reddit_posts_on_posted_at" + t.index ["processed"], name: "index_reddit_posts_on_processed" + t.index ["reddit_id"], name: "index_reddit_posts_on_reddit_id", unique: true + t.index ["score"], name: "index_reddit_posts_on_score" + t.index ["subreddit"], name: "index_reddit_posts_on_subreddit" + end + create_table "ruby_gems", force: :cascade do |t| t.string "activity_type", null: false t.string "authors" diff --git a/test/controllers/admin/article_searches_controller_test.rb b/test/controllers/admin/article_searches_controller_test.rb index 83f74d3..122d340 100644 --- a/test/controllers/admin/article_searches_controller_test.rb +++ b/test/controllers/admin/article_searches_controller_test.rb @@ -48,6 +48,6 @@ class Admin::ArticleSearchesControllerTest < ActionDispatch::IntegrationTest assert_equal article.id, result["id"] assert_equal article.title, result["title"] assert_equal article.url, result["url"] - assert_equal article.summary, result["summary"] + assert_equal article.summary, result["description"] end end diff --git a/test/controllers/admin/newsletter_issues_controller_test.rb b/test/controllers/admin/newsletter_issues_controller_test.rb index 69a171d..6c43d1c 100644 --- a/test/controllers/admin/newsletter_issues_controller_test.rb +++ b/test/controllers/admin/newsletter_issues_controller_test.rb @@ -77,7 +77,7 @@ class Admin::NewsletterIssuesControllerTest < ActionDispatch::IntegrationTest assert_equal 1, issue.newsletter_sections.first.newsletter_items.count end - test "create with nested item including article_id" do + test "create with nested item including article linkable" do article = articles(:rails_performance) assert_difference ["NewsletterIssue.count", "NewsletterItem.count"] do post admin_newsletter_issues_path, params: {newsletter_issue: { @@ -88,18 +88,18 @@ class Admin::NewsletterIssuesControllerTest < ActionDispatch::IntegrationTest title: "Test Section", position: 0, newsletter_items_attributes: { - "0" => {title: article.title, url: article.url, position: 0, article_id: article.id} + "0" => {title: article.title, url: article.url, position: 0, linkable_type: "Article", linkable_id: article.id} } } } }} end item = NewsletterIssue.last.newsletter_sections.first.newsletter_items.first - assert_equal article.id, item.article_id + assert_equal article.id, item.linkable_id assert_equal "Article", item.linkable_type end - test "create with nested item including ruby_gem_id" do + test "create with nested item including ruby_gem linkable" do gem = ruby_gems(:rack_updated) assert_difference ["NewsletterIssue.count", "NewsletterItem.count"] do post admin_newsletter_issues_path, params: {newsletter_issue: { @@ -110,14 +110,14 @@ class Admin::NewsletterIssuesControllerTest < ActionDispatch::IntegrationTest title: "Test Section", position: 0, newsletter_items_attributes: { - "0" => {title: gem.name, url: gem.project_url, position: 0, ruby_gem_id: gem.id} + "0" => {title: gem.name, url: gem.project_url, position: 0, linkable_type: "RubyGem", linkable_id: gem.id} } } } }} end item = NewsletterIssue.last.newsletter_sections.first.newsletter_items.first - assert_equal gem.id, item.ruby_gem_id + assert_equal gem.id, item.linkable_id assert_equal "RubyGem", item.linkable_type end diff --git a/test/fixtures/github_repos.yml b/test/fixtures/github_repos.yml new file mode 100644 index 0000000..f29df34 --- /dev/null +++ b/test/fixtures/github_repos.yml @@ -0,0 +1,58 @@ +rails_repo: + full_name: "rails/rails" + name: "rails" + description: "Ruby on Rails" + url: "https://github.com/rails/rails" + stars: 56000 + forks: 21000 + language: "Ruby" + owner_name: "rails" + owner_avatar_url: "https://avatars.githubusercontent.com/u/4223" + topics: + - ruby + - rails + - web + processed: false + first_seen_at: <%= 2.days.ago.to_fs(:db) %> + last_synced_at: <%= 1.hour.ago.to_fs(:db) %> + repo_created_at: <%= 10.years.ago.to_fs(:db) %> + repo_pushed_at: <%= 1.day.ago.to_fs(:db) %> + +sidekiq_repo: + full_name: "sidekiq/sidekiq" + name: "sidekiq" + description: "Simple, efficient background processing for Ruby" + url: "https://github.com/sidekiq/sidekiq" + stars: 13000 + forks: 2400 + language: "Ruby" + owner_name: "sidekiq" + owner_avatar_url: "https://avatars.githubusercontent.com/u/124505" + topics: + - ruby + - sidekiq + processed: false + first_seen_at: <%= 3.days.ago.to_fs(:db) %> + last_synced_at: <%= 2.hours.ago.to_fs(:db) %> + repo_created_at: <%= 8.years.ago.to_fs(:db) %> + repo_pushed_at: <%= 2.days.ago.to_fs(:db) %> + +featured_repo: + full_name: "ruby/ruby" + name: "ruby" + description: "The Ruby Programming Language" + url: "https://github.com/ruby/ruby" + stars: 22000 + forks: 5300 + language: "Ruby" + owner_name: "ruby" + owner_avatar_url: "https://avatars.githubusercontent.com/u/210414" + topics: + - ruby + - programming-language + processed: true + featured_in_issue: 1 + first_seen_at: <%= 5.days.ago.to_fs(:db) %> + last_synced_at: <%= 1.day.ago.to_fs(:db) %> + repo_created_at: <%= 15.years.ago.to_fs(:db) %> + repo_pushed_at: <%= 3.days.ago.to_fs(:db) %> diff --git a/test/fixtures/reddit_posts.yml b/test/fixtures/reddit_posts.yml new file mode 100644 index 0000000..27f75e9 --- /dev/null +++ b/test/fixtures/reddit_posts.yml @@ -0,0 +1,42 @@ +ruby_post: + reddit_id: "abc123" + title: "What's new in Ruby 4.0?" + url: "https://www.reddit.com/r/ruby/comments/abc123/whats_new_in_ruby_40/" + external_url: + score: 0 + author: "rubyist" + subreddit: "ruby" + num_comments: 0 + processed: false + first_seen_at: <%= 1.day.ago.to_fs(:db) %> + last_synced_at: <%= 1.hour.ago.to_fs(:db) %> + posted_at: <%= 1.day.ago.to_fs(:db) %> + +rails_post: + reddit_id: "def456" + title: "Rails 8.1 released with new features" + url: "https://www.reddit.com/r/rails/comments/def456/rails_81_released/" + external_url: "https://rubyonrails.org/2025/01/01/rails-8-1-released" + score: 0 + author: "railsdev" + subreddit: "rails" + num_comments: 0 + processed: false + first_seen_at: <%= 2.days.ago.to_fs(:db) %> + last_synced_at: <%= 2.hours.ago.to_fs(:db) %> + posted_at: <%= 2.days.ago.to_fs(:db) %> + +featured_post: + reddit_id: "ghi789" + title: "Best Ruby gems for 2025" + url: "https://www.reddit.com/r/ruby/comments/ghi789/best_ruby_gems/" + external_url: + score: 0 + author: "gemhunter" + subreddit: "ruby" + num_comments: 0 + processed: true + featured_in_issue: 1 + first_seen_at: <%= 5.days.ago.to_fs(:db) %> + last_synced_at: <%= 1.day.ago.to_fs(:db) %> + posted_at: <%= 5.days.ago.to_fs(:db) %> diff --git a/test/jobs/sync_github_repos_job_test.rb b/test/jobs/sync_github_repos_job_test.rb new file mode 100644 index 0000000..aa72ea7 --- /dev/null +++ b/test/jobs/sync_github_repos_job_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class SyncGithubReposJobTest < ActiveSupport::TestCase + test "calls GithubRepo.sync_from_api!" do + GithubRepo.expects(:sync_from_api!).once + SyncGithubReposJob.perform_now + end +end diff --git a/test/jobs/sync_reddit_posts_job_test.rb b/test/jobs/sync_reddit_posts_job_test.rb new file mode 100644 index 0000000..affad63 --- /dev/null +++ b/test/jobs/sync_reddit_posts_job_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class SyncRedditPostsJobTest < ActiveSupport::TestCase + test "calls RedditPost.sync_from_api!" do + RedditPost.expects(:sync_from_api!).once + SyncRedditPostsJob.perform_now + end +end diff --git a/test/models/github_repo_test.rb b/test/models/github_repo_test.rb new file mode 100644 index 0000000..ca772db --- /dev/null +++ b/test/models/github_repo_test.rb @@ -0,0 +1,153 @@ +require "test_helper" + +class GithubRepoTest < ActiveSupport::TestCase + test "valid github repo" do + repo = GithubRepo.new(full_name: "test/repo", name: "repo", url: "https://github.com/test/repo") + assert repo.valid? + end + + test "requires full_name" do + repo = GithubRepo.new(name: "repo", url: "https://github.com/test/repo") + assert_not repo.valid? + assert_includes repo.errors[:full_name], "can't be blank" + end + + test "full_name must be unique" do + repo = GithubRepo.new(full_name: github_repos(:rails_repo).full_name, name: "rails", url: "https://github.com/rails/rails2") + assert_not repo.valid? + assert_includes repo.errors[:full_name], "has already been taken" + end + + test "requires name" do + repo = GithubRepo.new(full_name: "test/repo", url: "https://github.com/test/repo") + assert_not repo.valid? + assert_includes repo.errors[:name], "can't be blank" + end + + test "requires url" do + repo = GithubRepo.new(full_name: "test/repo", name: "repo") + assert_not repo.valid? + assert_includes repo.errors[:url], "can't be blank" + end + + test "default scope orders by repo_pushed_at desc" do + repos = GithubRepo.all + dates = repos.map(&:repo_pushed_at).compact + assert_equal dates, dates.sort.reverse + end + + test "recent scope limits results" do + assert GithubRepo.recent(2).count <= 2 + end + + test "unprocessed scope returns unprocessed repos" do + GithubRepo.unprocessed.each do |repo| + assert_not repo.processed? + end + end + + test "featured scope returns repos with featured_in_issue" do + featured = GithubRepo.featured + assert_includes featured, github_repos(:featured_repo) + assert_not_includes featured, github_repos(:rails_repo) + end + + test "search_by_name finds matching repos" do + results = GithubRepo.search_by_name("rails") + assert_includes results, github_repos(:rails_repo) + assert_not_includes results, github_repos(:sidekiq_repo) + end + + test "popular scope orders by stars desc" do + popular = GithubRepo.popular + stars = popular.map(&:stars) + assert_equal stars, stars.sort.reverse + end + + test "sync_from_api! upserts repos from GitHub API" do + api_response = { + "total_count" => 1, + "items" => [ + { + "full_name" => "faker-ruby/faker", + "name" => "faker", + "description" => "A library for generating fake data", + "html_url" => "https://github.com/faker-ruby/faker", + "stargazers_count" => 11000, + "forks_count" => 3200, + "language" => "Ruby", + "owner" => {"login" => "faker-ruby", "avatar_url" => "https://avatars.githubusercontent.com/u/123"}, + "topics" => ["ruby", "faker"], + "created_at" => "2015-01-01T00:00:00Z", + "pushed_at" => "2025-06-01T00:00:00Z" + } + ] + }.to_json + + stub_request(:get, "https://api.github.com/search/repositories") + .with(query: hash_including("q" => /language:ruby/)) + .to_return(status: 200, body: api_response) + + assert_difference "GithubRepo.count", 1 do + GithubRepo.sync_from_api! + end + + repo = GithubRepo.find_by(full_name: "faker-ruby/faker") + assert_equal "faker", repo.name + assert_equal 11000, repo.stars + assert_not_nil repo.first_seen_at + end + + test "sync_from_api! returns empty array on api error" do + stub_request(:get, "https://api.github.com/search/repositories") + .with(query: hash_including("q" => /language:ruby/)) + .to_return(status: 500) + + result = GithubRepo.sync_from_api! + assert_equal [], result + end + + test "sync_from_api! handles json parse error" do + stub_request(:get, "https://api.github.com/search/repositories") + .with(query: hash_including("q" => /language:ruby/)) + .to_return(status: 200, body: "invalid json") + + result = GithubRepo.sync_from_api! + assert_equal [], result + end + + test "sync_from_api! preserves first_seen_at on re-sync" do + api_response = { + "total_count" => 1, + "items" => [ + { + "full_name" => github_repos(:rails_repo).full_name, + "name" => "rails", + "description" => "Updated description", + "html_url" => "https://github.com/rails/rails", + "stargazers_count" => 57000, + "forks_count" => 21500, + "language" => "Ruby", + "owner" => {"login" => "rails", "avatar_url" => "https://avatars.githubusercontent.com/u/4223"}, + "topics" => ["ruby", "rails"], + "created_at" => "2010-01-01T00:00:00Z", + "pushed_at" => "2025-06-01T00:00:00Z" + } + ] + }.to_json + + stub_request(:get, "https://api.github.com/search/repositories") + .with(query: hash_including("q" => /language:ruby/)) + .to_return(status: 200, body: api_response) + + original_first_seen = github_repos(:rails_repo).first_seen_at + + assert_no_difference "GithubRepo.count" do + GithubRepo.sync_from_api! + end + + github_repos(:rails_repo).reload + assert_equal original_first_seen, github_repos(:rails_repo).first_seen_at + assert_equal 57000, github_repos(:rails_repo).stars + end +end diff --git a/test/models/reddit_post_test.rb b/test/models/reddit_post_test.rb new file mode 100644 index 0000000..31a5ae5 --- /dev/null +++ b/test/models/reddit_post_test.rb @@ -0,0 +1,168 @@ +require "test_helper" + +class RedditPostTest < ActiveSupport::TestCase + test "valid reddit post" do + post = RedditPost.new(reddit_id: "xyz999", title: "Test post", url: "https://reddit.com/r/ruby/comments/xyz999/test/", subreddit: "ruby") + assert post.valid? + end + + test "requires reddit_id" do + post = RedditPost.new(title: "Test", url: "https://reddit.com/test", subreddit: "ruby") + assert_not post.valid? + assert_includes post.errors[:reddit_id], "can't be blank" + end + + test "reddit_id must be unique" do + post = RedditPost.new(reddit_id: reddit_posts(:ruby_post).reddit_id, title: "Dup", url: "https://reddit.com/dup", subreddit: "ruby") + assert_not post.valid? + assert_includes post.errors[:reddit_id], "has already been taken" + end + + test "requires title" do + post = RedditPost.new(reddit_id: "xyz999", url: "https://reddit.com/test", subreddit: "ruby") + assert_not post.valid? + assert_includes post.errors[:title], "can't be blank" + end + + test "requires url" do + post = RedditPost.new(reddit_id: "xyz999", title: "Test", subreddit: "ruby") + assert_not post.valid? + assert_includes post.errors[:url], "can't be blank" + end + + test "requires valid subreddit" do + post = RedditPost.new(reddit_id: "xyz999", title: "Test", url: "https://reddit.com/test", subreddit: "invalid") + assert_not post.valid? + assert_includes post.errors[:subreddit], "is not included in the list" + end + + test "subreddit accepts ruby" do + post = RedditPost.new(reddit_id: "xyz999", title: "Test", url: "https://reddit.com/test", subreddit: "ruby") + assert post.valid? + end + + test "subreddit accepts rails" do + post = RedditPost.new(reddit_id: "xyz999", title: "Test", url: "https://reddit.com/test", subreddit: "rails") + assert post.valid? + end + + test "default scope orders by posted_at desc" do + posts = RedditPost.all + dates = posts.map(&:posted_at).compact + assert_equal dates, dates.sort.reverse + end + + test "recent scope limits results" do + assert RedditPost.recent(2).count <= 2 + end + + test "unprocessed scope returns unprocessed posts" do + RedditPost.unprocessed.each do |post| + assert_not post.processed? + end + end + + test "from_subreddit scope filters by subreddit" do + ruby_posts = RedditPost.from_subreddit("ruby") + ruby_posts.each do |post| + assert_equal "ruby", post.subreddit + end + end + + test "featured scope returns posts with featured_in_issue" do + featured = RedditPost.featured + assert_includes featured, reddit_posts(:featured_post) + assert_not_includes featured, reddit_posts(:ruby_post) + end + + test "search_by_title finds matching posts" do + results = RedditPost.search_by_title("Ruby 4.0") + assert_includes results, reddit_posts(:ruby_post) + assert_not_includes results, reddit_posts(:rails_post) + end + + test "sync_from_api! upserts posts from RSS feeds" do + rss_body = <<~XML + + + r/ruby + + Amazing Ruby gem discovered + + testuser + 2025-06-01T12:00:00Z + <a href="https://example.com/gem">[link]</a> + + + XML + + empty_rss = <<~XML + + + r/rails + + XML + + stub_request(:get, "https://www.reddit.com/r/ruby/hot/.rss?limit=50") + .to_return(status: 200, body: rss_body, headers: {"Content-Type" => "application/xml"}) + stub_request(:get, "https://www.reddit.com/r/rails/hot/.rss?limit=50") + .to_return(status: 200, body: empty_rss, headers: {"Content-Type" => "application/xml"}) + + assert_difference "RedditPost.count", 1 do + RedditPost.sync_from_api! + end + + post = RedditPost.find_by(reddit_id: "zzz111") + assert_equal "Amazing Ruby gem discovered", post.title + assert_equal "ruby", post.subreddit + assert_equal "https://example.com/gem", post.external_url + assert_not_nil post.first_seen_at + end + + test "sync_from_api! returns empty array on api error" do + stub_request(:get, "https://www.reddit.com/r/ruby/hot/.rss?limit=50") + .to_return(status: 500) + stub_request(:get, "https://www.reddit.com/r/rails/hot/.rss?limit=50") + .to_return(status: 200, body: 'r/rails', headers: {"Content-Type" => "application/xml"}) + + result = RedditPost.sync_from_api! + assert_equal [], result + end + + test "sync_from_api! preserves first_seen_at on re-sync" do + rss_body = <<~XML + + + r/ruby + + Updated: What's new in Ruby 4.0? + + rubyist + 2025-06-01T12:00:00Z + + + XML + + empty_rss = <<~XML + + + r/rails + + XML + + stub_request(:get, "https://www.reddit.com/r/ruby/hot/.rss?limit=50") + .to_return(status: 200, body: rss_body, headers: {"Content-Type" => "application/xml"}) + stub_request(:get, "https://www.reddit.com/r/rails/hot/.rss?limit=50") + .to_return(status: 200, body: empty_rss, headers: {"Content-Type" => "application/xml"}) + + original_first_seen = reddit_posts(:ruby_post).first_seen_at + + assert_no_difference "RedditPost.count" do + RedditPost.sync_from_api! + end + + reddit_posts(:ruby_post).reload + assert_equal original_first_seen, reddit_posts(:ruby_post).first_seen_at + assert_equal "Updated: What's new in Ruby 4.0?", reddit_posts(:ruby_post).title + end +end From fc3b8eef4f39046b165223d4a6fd48713cb49bb7 Mon Sep 17 00:00:00 2001 From: Alex Koval Date: Mon, 23 Feb 2026 12:00:30 +0100 Subject: [PATCH 3/6] chore: bump lui kit version --- Gemfile | 3 ++- Gemfile.lock | 24 +++++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/Gemfile b/Gemfile index 4489d77..dcab236 100644 --- a/Gemfile +++ b/Gemfile @@ -26,7 +26,8 @@ gem "faraday" gem "faraday-follow_redirects" gem "resend" -gem "lightning_ui_kit" +gem "lightning_ui_kit", github: "k0va1/lightning_ui_kit", branch: "master" +# gem "lightning_ui_kit", path: "../lightning_ui_kit" group :development, :test do gem "brakeman" diff --git a/Gemfile.lock b/Gemfile.lock index 0fb0631..75b88b8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,14 @@ +GIT + remote: https://github.com/k0va1/lightning_ui_kit.git + revision: 07fd9ae39c2aba63f198466ff10d471447be0781 + branch: master + specs: + lightning_ui_kit (0.3.4) + heroicons + rails (>= 8.0.0) + tailwind_merge + view_component + GIT remote: https://github.com/kodehealth/activejob-uniqueness.git revision: 548d8a49e72335af066810f9d7e1f51eebac8558 @@ -172,11 +183,6 @@ GEM railties (>= 6.0.0) json (2.18.1) language_server-protocol (3.17.0.5) - lightning_ui_kit (0.3.3) - heroicons - rails (>= 8.0.0) - tailwind_merge - view_component lint_roller (1.1.0) logger (1.7.0) loofah (2.25.0) @@ -390,7 +396,7 @@ GEM stringio (3.2.0) strong_migrations (2.5.2) activerecord (>= 7.1) - tailwind_merge (1.3.3) + tailwind_merge (1.4.0) sin_lru_redux (~> 2.5) thor (1.5.0) timeout (0.6.0) @@ -456,7 +462,7 @@ DEPENDENCIES faraday-follow_redirects feedjira jsbundling-rails - lightning_ui_kit + lightning_ui_kit! minitest-mock (~> 5.27) mocha (~> 3.0) pagy @@ -537,7 +543,7 @@ CHECKSUMS jsbundling-rails (1.3.1) sha256=0fa03f6d051c694cbf55a022d8be53399879f2c4cf38b2968f86379c62b1c2ca json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986 language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc - lightning_ui_kit (0.3.3) sha256=c52bdad3219c05f3f40719591cd01da85ddd5518a0150a908df337ca0008e9a8 + lightning_ui_kit (0.3.4) lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 @@ -627,7 +633,7 @@ CHECKSUMS stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 strong_migrations (2.5.2) sha256=06faff4782b24c8a0a4b1a5a1613d809339ff26c5ac135fde75a932a5c9b454e - tailwind_merge (1.3.3) sha256=9073fa69add5e9266609dee759bee333cdfa7bc0704fc577812e50c70b62291b + tailwind_merge (1.4.0) sha256=58009e3f4410dcb7ea6156e15b875e405f0e428f961cda5ee19359fe037933da thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f From f6b89a3d850e6b92afad0a7dda9d95926aa94f4b Mon Sep 17 00:00:00 2001 From: Alex Koval Date: Mon, 23 Feb 2026 12:20:07 +0100 Subject: [PATCH 4/6] fix: address code review issues for other sources feature - Add linkable_type inclusion validation on NewsletterItem (security) - Fix GitHub sync merge order so daily data takes precedence over weekly - Fix RubyGem sync merge order so "updated" takes precedence over "new" - Remove score/num_comments from Reddit sync update_only to preserve admin values - Fix form selects not preserving value on re-render (reddit_posts, ruby_gems) - Remove memoized @http_client class variable for thread safety - Add RubyGem stats to admin dashboard - Add disconnect() cleanup to linkable_autofill_controller - Fix Ruby Gems show page buttons to match GitHub/Reddit pattern - Fix private/public ordering in NewsletterItem - Add controller tests for all 6 new admin controllers (62 tests) - Add NewsletterItem tests for GithubRepo/RedditPost accessors and validations --- app/controllers/admin/dashboard_controller.rb | 2 + .../linkable_autofill_controller.js | 11 +- app/models/github_repo.rb | 4 +- app/models/newsletter_item.rb | 23 ++-- app/models/reddit_post.rb | 4 +- app/models/ruby_gem.rb | 4 +- app/views/admin/reddit_posts/_form.html.erb | 2 +- app/views/admin/ruby_gems/_form.html.erb | 2 +- app/views/admin/ruby_gems/show.html.erb | 4 +- .../admin/gem_searches_controller_test.rb | 46 ++++++++ .../github_repo_searches_controller_test.rb | 46 ++++++++ .../admin/github_repos_controller_test.rb | 78 ++++++++++++++ .../reddit_post_searches_controller_test.rb | 46 ++++++++ .../admin/reddit_posts_controller_test.rb | 85 +++++++++++++++ .../admin/ruby_gems_controller_test.rb | 84 +++++++++++++++ test/models/newsletter_item_test.rb | 100 ++++++++++++++++++ test/models/ruby_gem_test.rb | 4 +- 17 files changed, 520 insertions(+), 25 deletions(-) create mode 100644 test/controllers/admin/gem_searches_controller_test.rb create mode 100644 test/controllers/admin/github_repo_searches_controller_test.rb create mode 100644 test/controllers/admin/github_repos_controller_test.rb create mode 100644 test/controllers/admin/reddit_post_searches_controller_test.rb create mode 100644 test/controllers/admin/reddit_posts_controller_test.rb create mode 100644 test/controllers/admin/ruby_gems_controller_test.rb diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 4f9e561..1ecee6e 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -10,6 +10,8 @@ def index @total_issues = NewsletterIssue.count @sent_issues = NewsletterIssue.sent.count @total_clicks = Click.count + @total_ruby_gems = RubyGem.count + @unprocessed_ruby_gems = RubyGem.unprocessed.count @total_github_repos = GithubRepo.count @unprocessed_github_repos = GithubRepo.unprocessed.count @total_reddit_posts = RedditPost.count diff --git a/app/javascript/controllers/linkable_autofill_controller.js b/app/javascript/controllers/linkable_autofill_controller.js index 3fa83c7..10f18b0 100644 --- a/app/javascript/controllers/linkable_autofill_controller.js +++ b/app/javascript/controllers/linkable_autofill_controller.js @@ -4,8 +4,15 @@ export default class extends Controller { static values = { url: String } connect() { - const hiddenField = this.element.querySelector('[data-lui-combobox-target="hiddenField"]') - if (hiddenField) this.interceptHiddenField(hiddenField) + this.hiddenField = this.element.querySelector('[data-lui-combobox-target="hiddenField"]') + if (this.hiddenField) this.interceptHiddenField(this.hiddenField) + } + + disconnect() { + if (this.hiddenField) { + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value") + Object.defineProperty(this.hiddenField, "value", descriptor) + } } interceptHiddenField(field) { diff --git a/app/models/github_repo.rb b/app/models/github_repo.rb index 1ff2c06..0842a75 100644 --- a/app/models/github_repo.rb +++ b/app/models/github_repo.rb @@ -20,7 +20,7 @@ def self.sync_from_api! daily_repos = fetch_repos(1.day.ago) weekly_repos = fetch_repos(1.week.ago) - records = daily_repos.merge(weekly_repos) + records = weekly_repos.merge(daily_repos) return [] if records.empty? now = Time.current @@ -75,7 +75,7 @@ def self.fetch_repos(pushed_after) end def self.http_client - @http_client ||= Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| + Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" f.headers["Accept"] = "application/vnd.github+json" token = Rails.application.credentials.github_token diff --git a/app/models/newsletter_item.rb b/app/models/newsletter_item.rb index 5a4b39f..7ea1572 100644 --- a/app/models/newsletter_item.rb +++ b/app/models/newsletter_item.rb @@ -28,8 +28,11 @@ class NewsletterItem < ApplicationRecord belongs_to :linkable, polymorphic: true, optional: true has_one :tracked_link, as: :trackable, dependent: :destroy + LINKABLE_TYPES = %w[Article RubyGem GithubRepo RedditPost].freeze + validates :title, presence: true validates :url, presence: true + validates :linkable_type, inclusion: {in: LINKABLE_TYPES}, allow_nil: true before_validation :clear_blank_linkable @@ -103,17 +106,6 @@ def reddit_post_id=(id) end end - private - - def clear_blank_linkable - if linkable_type.blank? || linkable_id.blank? - self.linkable_type = nil - self.linkable_id = nil - end - end - - public - def first_flight? return false unless article&.blog_id @@ -128,4 +120,13 @@ def first_flight? .where(linkable_id: Article.where(blog_id: blog_id).select(:id)) .exists? end + + private + + def clear_blank_linkable + if linkable_type.blank? || linkable_id.blank? + self.linkable_type = nil + self.linkable_id = nil + end + end end diff --git a/app/models/reddit_post.rb b/app/models/reddit_post.rb index c2cf524..d5b7b2f 100644 --- a/app/models/reddit_post.rb +++ b/app/models/reddit_post.rb @@ -32,7 +32,7 @@ def self.sync_from_api! upsert_all( rows, unique_by: :index_reddit_posts_on_reddit_id, - update_only: %i[title url score num_comments last_synced_at] + update_only: %i[title url last_synced_at] ) rescue Faraday::Error, Feedjira::NoParserAvailable => e Rails.logger.error("RedditPost sync failed: #{e.message}") @@ -86,7 +86,7 @@ def self.extract_external_url(entry) end def self.http_client - @http_client ||= Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| + Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" f.response :follow_redirects f.adapter Faraday.default_adapter diff --git a/app/models/ruby_gem.rb b/app/models/ruby_gem.rb index 48f848c..8b86ba8 100644 --- a/app/models/ruby_gem.rb +++ b/app/models/ruby_gem.rb @@ -56,7 +56,7 @@ def self.sync_from_api! updated_gems = fetch_gems("just_updated.json", "updated") new_gems = fetch_gems("latest.json", "new") - records = updated_gems.merge(new_gems) + records = new_gems.merge(updated_gems) return [] if records.empty? now = Time.current @@ -104,7 +104,7 @@ def self.fetch_gems(endpoint, activity_type) end def self.http_client - @http_client ||= Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| + Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" f.response :follow_redirects f.adapter Faraday.default_adapter diff --git a/app/views/admin/reddit_posts/_form.html.erb b/app/views/admin/reddit_posts/_form.html.erb index 43e4dae..c99f7b2 100644 --- a/app/views/admin/reddit_posts/_form.html.erb +++ b/app/views/admin/reddit_posts/_form.html.erb @@ -18,7 +18,7 @@ <%= lui.input(name: :title, form: form, label: "Title") %> <%= lui.input(name: :url, form: form, type: :url, label: "URL") %> <%= lui.input(name: :external_url, form: form, type: :url, label: "External URL") %> - <%= lui.select(name: :subreddit, form: form, label: "Subreddit", options_for_select: [["r/ruby", "ruby"], ["r/rails", "rails"]], include_blank: false) %> + <%= lui.select(name: :subreddit, form: form, label: "Subreddit", options_for_select: options_for_select([["r/ruby", "ruby"], ["r/rails", "rails"]], reddit_post.subreddit), include_blank: false) %> <%= lui.input(name: :author, form: form, label: "Author") %> <%= lui.input(name: :score, form: form, type: :number, label: "Score") %> <%= lui.input(name: :num_comments, form: form, type: :number, label: "Comments") %> diff --git a/app/views/admin/ruby_gems/_form.html.erb b/app/views/admin/ruby_gems/_form.html.erb index 74669ba..e4f57bc 100644 --- a/app/views/admin/ruby_gems/_form.html.erb +++ b/app/views/admin/ruby_gems/_form.html.erb @@ -18,7 +18,7 @@ <%= lui.input(name: :version, form: form, label: "Version") %> <%= lui.input(name: :authors, form: form, label: "Authors") %> <%= lui.textarea(name: :info, form: form, label: "Info", rows: 3) %> - <%= lui.select(name: :activity_type, form: form, label: "Activity Type", options_for_select: [["New", "new"], ["Updated", "updated"]], include_blank: false) %> + <%= lui.select(name: :activity_type, form: form, label: "Activity Type", options_for_select: options_for_select([["New", "new"], ["Updated", "updated"]], ruby_gem.activity_type), include_blank: false) %> <%= lui.input(name: :downloads, form: form, type: :number, label: "Downloads") %> <%= lui.input(name: :project_url, form: form, type: :url, label: "RubyGems URL") %> <%= lui.input(name: :homepage_url, form: form, type: :url, label: "Homepage URL") %> diff --git a/app/views/admin/ruby_gems/show.html.erb b/app/views/admin/ruby_gems/show.html.erb index dabcb1f..446a36f 100644 --- a/app/views/admin/ruby_gems/show.html.erb +++ b/app/views/admin/ruby_gems/show.html.erb @@ -4,8 +4,8 @@

<%= @ruby_gem.name %>

- <%= lui.button(url: edit_admin_ruby_gem_path(@ruby_gem), style: :outline) { "Edit" } %> - <%= link_to "Delete", admin_ruby_gem_path(@ruby_gem), data: { turbo_method: :delete, turbo_confirm: "Delete this gem?" }, class: "inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium admin-delete-btn" %> + <%= lui.button(url: edit_admin_ruby_gem_path(@ruby_gem), style: :outline, icon: "pencil") { "Edit" } %> + <%= lui.button(url: admin_ruby_gem_path(@ruby_gem), style: :outline, icon: "trash", data: { turbo_method: :delete, turbo_confirm: "Delete this gem?" }) { "Delete" } %>
diff --git a/test/controllers/admin/gem_searches_controller_test.rb b/test/controllers/admin/gem_searches_controller_test.rb new file mode 100644 index 0000000..66ba696 --- /dev/null +++ b/test/controllers/admin/gem_searches_controller_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Admin::GemSearchesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_admin + end + + test "redirects when not authenticated" do + delete admin_session_path + get admin_gem_searches_path(q: "rack") + assert_redirected_to new_admin_session_path + end + + test "returns matching gems as JSON" do + get admin_gem_searches_path(q: "rack", format: :json) + assert_response :success + + results = response.parsed_body + assert results.any? { |g| g["label"].include?("rack") } + assert results.all? { |g| g.key?("value") && g.key?("label") } + end + + test "returns empty array for no matches" do + get admin_gem_searches_path(q: "zzzznonexistent", format: :json) + assert_response :success + assert_equal [], response.parsed_body + end + + test "returns empty array when no query" do + get admin_gem_searches_path(format: :json) + assert_response :success + assert_equal [], response.parsed_body + end + + test "returns single gem by id" do + gem = ruby_gems(:rack_updated) + get admin_gem_searches_path(id: gem.id, format: :json) + assert_response :success + + result = response.parsed_body + assert_equal gem.id, result["id"] + assert_equal gem.name, result["title"] + assert_equal gem.project_url, result["url"] + assert_equal gem.info, result["description"] + end +end diff --git a/test/controllers/admin/github_repo_searches_controller_test.rb b/test/controllers/admin/github_repo_searches_controller_test.rb new file mode 100644 index 0000000..7a84af9 --- /dev/null +++ b/test/controllers/admin/github_repo_searches_controller_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Admin::GithubRepoSearchesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_admin + end + + test "redirects when not authenticated" do + delete admin_session_path + get admin_github_repo_searches_path(q: "rails") + assert_redirected_to new_admin_session_path + end + + test "returns matching repos as JSON" do + get admin_github_repo_searches_path(q: "rails", format: :json) + assert_response :success + + results = response.parsed_body + assert results.any? { |r| r["label"].include?("rails") } + assert results.all? { |r| r.key?("value") && r.key?("label") } + end + + test "returns empty array for no matches" do + get admin_github_repo_searches_path(q: "zzzznonexistent", format: :json) + assert_response :success + assert_equal [], response.parsed_body + end + + test "returns empty array when no query" do + get admin_github_repo_searches_path(format: :json) + assert_response :success + assert_equal [], response.parsed_body + end + + test "returns single repo by id" do + repo = github_repos(:rails_repo) + get admin_github_repo_searches_path(id: repo.id, format: :json) + assert_response :success + + result = response.parsed_body + assert_equal repo.id, result["id"] + assert_equal repo.full_name, result["title"] + assert_equal repo.url, result["url"] + assert_equal repo.description, result["description"] + end +end diff --git a/test/controllers/admin/github_repos_controller_test.rb b/test/controllers/admin/github_repos_controller_test.rb new file mode 100644 index 0000000..10c2b0c --- /dev/null +++ b/test/controllers/admin/github_repos_controller_test.rb @@ -0,0 +1,78 @@ +require "test_helper" + +class Admin::GithubReposControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_admin + @github_repo = github_repos(:rails_repo) + end + + test "redirects when not authenticated" do + delete admin_session_path + get admin_github_repos_path + assert_redirected_to new_admin_session_path + end + + test "index" do + get admin_github_repos_path + assert_response :success + end + + test "index with search" do + get admin_github_repos_path(search: "rails") + assert_response :success + end + + test "index with period filter" do + get admin_github_repos_path(period: "last_week") + assert_response :success + end + + test "show" do + get admin_github_repo_path(@github_repo) + assert_response :success + end + + test "new" do + get new_admin_github_repo_path + assert_response :success + end + + test "create with valid params" do + assert_difference("GithubRepo.count") do + post admin_github_repos_path, params: {github_repo: { + full_name: "test/new-repo", + name: "new-repo", + url: "https://github.com/test/new-repo" + }} + end + assert_redirected_to admin_github_repo_path(GithubRepo.unscoped.last) + end + + test "create with invalid params" do + post admin_github_repos_path, params: {github_repo: {full_name: "", name: "", url: ""}} + assert_response :unprocessable_content + end + + test "edit" do + get edit_admin_github_repo_path(@github_repo) + assert_response :success + end + + test "update with valid params" do + patch admin_github_repo_path(@github_repo), params: {github_repo: {description: "Updated description"}} + assert_redirected_to admin_github_repo_path(@github_repo) + assert_equal "Updated description", @github_repo.reload.description + end + + test "update with invalid params" do + patch admin_github_repo_path(@github_repo), params: {github_repo: {full_name: ""}} + assert_response :unprocessable_content + end + + test "destroy" do + assert_difference("GithubRepo.count", -1) do + delete admin_github_repo_path(@github_repo) + end + assert_redirected_to admin_github_repos_path + end +end diff --git a/test/controllers/admin/reddit_post_searches_controller_test.rb b/test/controllers/admin/reddit_post_searches_controller_test.rb new file mode 100644 index 0000000..c0a3611 --- /dev/null +++ b/test/controllers/admin/reddit_post_searches_controller_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Admin::RedditPostSearchesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_admin + end + + test "redirects when not authenticated" do + delete admin_session_path + get admin_reddit_post_searches_path(q: "ruby") + assert_redirected_to new_admin_session_path + end + + test "returns matching posts as JSON" do + get admin_reddit_post_searches_path(q: "Ruby", format: :json) + assert_response :success + + results = response.parsed_body + assert results.any? { |p| p["label"].include?("Ruby") } + assert results.all? { |p| p.key?("value") && p.key?("label") } + end + + test "returns empty array for no matches" do + get admin_reddit_post_searches_path(q: "zzzznonexistent", format: :json) + assert_response :success + assert_equal [], response.parsed_body + end + + test "returns empty array when no query" do + get admin_reddit_post_searches_path(format: :json) + assert_response :success + assert_equal [], response.parsed_body + end + + test "returns single post by id" do + post_record = reddit_posts(:ruby_post) + get admin_reddit_post_searches_path(id: post_record.id, format: :json) + assert_response :success + + result = response.parsed_body + assert_equal post_record.id, result["id"] + assert_equal post_record.title, result["title"] + assert_equal post_record.url, result["url"] + assert_includes result["description"], "r/#{post_record.subreddit}" + end +end diff --git a/test/controllers/admin/reddit_posts_controller_test.rb b/test/controllers/admin/reddit_posts_controller_test.rb new file mode 100644 index 0000000..f3c4d50 --- /dev/null +++ b/test/controllers/admin/reddit_posts_controller_test.rb @@ -0,0 +1,85 @@ +require "test_helper" + +class Admin::RedditPostsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_admin + @reddit_post = reddit_posts(:ruby_post) + end + + test "redirects when not authenticated" do + delete admin_session_path + get admin_reddit_posts_path + assert_redirected_to new_admin_session_path + end + + test "index" do + get admin_reddit_posts_path + assert_response :success + end + + test "index with subreddit filter" do + get admin_reddit_posts_path(subreddit: "ruby") + assert_response :success + end + + test "index with search" do + get admin_reddit_posts_path(search: "Ruby") + assert_response :success + end + + test "index with period filter" do + get admin_reddit_posts_path(period: "last_week") + assert_response :success + end + + test "show" do + get admin_reddit_post_path(@reddit_post) + assert_response :success + end + + test "new" do + get new_admin_reddit_post_path + assert_response :success + end + + test "create with valid params" do + assert_difference("RedditPost.count") do + post admin_reddit_posts_path, params: {reddit_post: { + reddit_id: "xyz999", + title: "New post", + url: "https://www.reddit.com/r/ruby/comments/xyz999/new_post/", + subreddit: "ruby", + posted_at: 1.hour.ago + }} + end + assert_redirected_to admin_reddit_post_path(RedditPost.unscoped.last) + end + + test "create with invalid params" do + post admin_reddit_posts_path, params: {reddit_post: {reddit_id: "", title: "", url: ""}} + assert_response :unprocessable_content + end + + test "edit" do + get edit_admin_reddit_post_path(@reddit_post) + assert_response :success + end + + test "update with valid params" do + patch admin_reddit_post_path(@reddit_post), params: {reddit_post: {title: "Updated Title"}} + assert_redirected_to admin_reddit_post_path(@reddit_post) + assert_equal "Updated Title", @reddit_post.reload.title + end + + test "update with invalid params" do + patch admin_reddit_post_path(@reddit_post), params: {reddit_post: {title: ""}} + assert_response :unprocessable_content + end + + test "destroy" do + assert_difference("RedditPost.count", -1) do + delete admin_reddit_post_path(@reddit_post) + end + assert_redirected_to admin_reddit_posts_path + end +end diff --git a/test/controllers/admin/ruby_gems_controller_test.rb b/test/controllers/admin/ruby_gems_controller_test.rb new file mode 100644 index 0000000..0e3838a --- /dev/null +++ b/test/controllers/admin/ruby_gems_controller_test.rb @@ -0,0 +1,84 @@ +require "test_helper" + +class Admin::RubyGemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_admin + @ruby_gem = ruby_gems(:rack_updated) + end + + test "redirects when not authenticated" do + delete admin_session_path + get admin_ruby_gems_path + assert_redirected_to new_admin_session_path + end + + test "index" do + get admin_ruby_gems_path + assert_response :success + end + + test "index with activity_type filter" do + get admin_ruby_gems_path(activity_type: "new") + assert_response :success + end + + test "index with search" do + get admin_ruby_gems_path(search: "rack") + assert_response :success + end + + test "index with period filter" do + get admin_ruby_gems_path(period: "last_week") + assert_response :success + end + + test "show" do + get admin_ruby_gem_path(@ruby_gem) + assert_response :success + end + + test "new" do + get new_admin_ruby_gem_path + assert_response :success + end + + test "create with valid params" do + assert_difference("RubyGem.count") do + post admin_ruby_gems_path, params: {ruby_gem: { + name: "brand_new_gem", + version: "1.0.0", + project_url: "https://rubygems.org/gems/brand_new_gem", + activity_type: "new" + }} + end + assert_redirected_to admin_ruby_gem_path(RubyGem.unscoped.last) + end + + test "create with invalid params" do + post admin_ruby_gems_path, params: {ruby_gem: {name: "", version: "", project_url: ""}} + assert_response :unprocessable_content + end + + test "edit" do + get edit_admin_ruby_gem_path(@ruby_gem) + assert_response :success + end + + test "update with valid params" do + patch admin_ruby_gem_path(@ruby_gem), params: {ruby_gem: {version: "3.2.0"}} + assert_redirected_to admin_ruby_gem_path(@ruby_gem) + assert_equal "3.2.0", @ruby_gem.reload.version + end + + test "update with invalid params" do + patch admin_ruby_gem_path(@ruby_gem), params: {ruby_gem: {name: ""}} + assert_response :unprocessable_content + end + + test "destroy" do + assert_difference("RubyGem.count", -1) do + delete admin_ruby_gem_path(@ruby_gem) + end + assert_redirected_to admin_ruby_gems_path + end +end diff --git a/test/models/newsletter_item_test.rb b/test/models/newsletter_item_test.rb index d269e5f..42ef586 100644 --- a/test/models/newsletter_item_test.rb +++ b/test/models/newsletter_item_test.rb @@ -112,6 +112,106 @@ class NewsletterItemTest < ActiveSupport::TestCase assert_nil item.linkable_id end + test "github_repo_id= sets linkable to github repo" do + repo = github_repos(:rails_repo) + item = NewsletterItem.new( + newsletter_section: newsletter_sections(:crows_pick), + title: "Test", + url: "https://example.com" + ) + item.github_repo_id = repo.id + assert_equal "GithubRepo", item.linkable_type + assert_equal repo.id, item.linkable_id + end + + test "reddit_post_id= sets linkable to reddit post" do + post_record = reddit_posts(:ruby_post) + item = NewsletterItem.new( + newsletter_section: newsletter_sections(:crows_pick), + title: "Test", + url: "https://example.com" + ) + item.reddit_post_id = post_record.id + assert_equal "RedditPost", item.linkable_type + assert_equal post_record.id, item.linkable_id + end + + test "github_repo_id= with blank clears github repo linkable" do + item = NewsletterItem.new( + newsletter_section: newsletter_sections(:crows_pick), + title: "Test", + url: "https://example.com", + linkable_type: "GithubRepo", + linkable_id: github_repos(:rails_repo).id + ) + item.github_repo_id = "" + assert_nil item.linkable_type + assert_nil item.linkable_id + end + + test "reddit_post_id= with blank clears reddit post linkable" do + item = NewsletterItem.new( + newsletter_section: newsletter_sections(:crows_pick), + title: "Test", + url: "https://example.com", + linkable_type: "RedditPost", + linkable_id: reddit_posts(:ruby_post).id + ) + item.reddit_post_id = "" + assert_nil item.linkable_type + assert_nil item.linkable_id + end + + test "github_repo returns nil when linkable is an article" do + item = newsletter_items(:rails_update) + assert_nil item.github_repo + assert_nil item.github_repo_id + end + + test "reddit_post returns nil when linkable is an article" do + item = newsletter_items(:rails_update) + assert_nil item.reddit_post + assert_nil item.reddit_post_id + end + + test "clear_blank_linkable nils both fields when linkable_type is blank" do + item = NewsletterItem.new( + newsletter_section: newsletter_sections(:crows_pick), + title: "Test", + url: "https://example.com", + linkable_type: "", + linkable_id: 1 + ) + item.valid? + assert_nil item.linkable_type + assert_nil item.linkable_id + end + + test "clear_blank_linkable nils both fields when linkable_id is blank" do + item = NewsletterItem.new( + newsletter_section: newsletter_sections(:crows_pick), + title: "Test", + url: "https://example.com", + linkable_type: "Article", + linkable_id: nil + ) + item.valid? + assert_nil item.linkable_type + assert_nil item.linkable_id + end + + test "rejects invalid linkable_type" do + item = NewsletterItem.new( + newsletter_section: newsletter_sections(:crows_pick), + title: "Test", + url: "https://example.com", + linkable_type: "User", + linkable_id: 1 + ) + assert_not item.valid? + assert_includes item.errors[:linkable_type], "is not included in the list" + end + test "first_flight? returns false when item has no article" do item = newsletter_items(:ruby_gem) assert_not item.first_flight? diff --git a/test/models/ruby_gem_test.rb b/test/models/ruby_gem_test.rb index f111daa..d9ddaa9 100644 --- a/test/models/ruby_gem_test.rb +++ b/test/models/ruby_gem_test.rb @@ -142,7 +142,7 @@ class RubyGemTest < ActiveSupport::TestCase assert_equal "new", new_gem.activity_type end - test "sync_from_api! new gems overwrite updated gems with same name" do + test "sync_from_api! updated gems take precedence over new gems with same name" do shared_gem = { "name" => "shared_gem", "version" => "1.0.0", @@ -164,7 +164,7 @@ class RubyGemTest < ActiveSupport::TestCase RubyGem.sync_from_api! gem = RubyGem.find_by(name: "shared_gem") - assert_equal "new", gem.activity_type + assert_equal "updated", gem.activity_type end test "sync_from_api! returns empty array on api error" do From bb9564f0c02926825f3f4433488f5e88121634c5 Mon Sep 17 00:00:00 2001 From: Alex Koval Date: Mon, 23 Feb 2026 12:27:32 +0100 Subject: [PATCH 5/6] refactor: remove polymorphic linkable convenience methods from NewsletterItem Use linkable/linkable_type directly instead of per-type accessor methods. Extract article_blog_for helper for repeated blog access in mailer view. --- app/helpers/application_helper.rb | 4 + app/models/newsletter_item.rb | 72 +---------- app/views/newsletter_mailer/issue.html.erb | 24 ++-- .../previews/newsletter_mailer_preview.rb | 2 +- test/models/newsletter_issue_test.rb | 2 +- test/models/newsletter_item_test.rb | 114 ------------------ 6 files changed, 20 insertions(+), 198 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7488fa7..b4ac9b7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -6,4 +6,8 @@ def safe_external_url(url) rescue URI::InvalidURIError "#" end + + def article_blog_for(item) + item.linkable&.blog if item.linkable_type == "Article" + end end diff --git a/app/models/newsletter_item.rb b/app/models/newsletter_item.rb index 7ea1572..4330219 100644 --- a/app/models/newsletter_item.rb +++ b/app/models/newsletter_item.rb @@ -38,78 +38,10 @@ class NewsletterItem < ApplicationRecord default_scope { order(:position) } - def article - linkable if linkable_type == "Article" - end - - def article_id - linkable_id if linkable_type == "Article" - end - - def article_id=(id) - if id.present? - self.linkable_type = "Article" - self.linkable_id = id - elsif linkable_type == "Article" - self.linkable = nil - end - end - - def ruby_gem - linkable if linkable_type == "RubyGem" - end - - def ruby_gem_id - linkable_id if linkable_type == "RubyGem" - end - - def ruby_gem_id=(id) - if id.present? - self.linkable_type = "RubyGem" - self.linkable_id = id - elsif linkable_type == "RubyGem" - self.linkable = nil - end - end - - def github_repo - linkable if linkable_type == "GithubRepo" - end - - def github_repo_id - linkable_id if linkable_type == "GithubRepo" - end - - def github_repo_id=(id) - if id.present? - self.linkable_type = "GithubRepo" - self.linkable_id = id - elsif linkable_type == "GithubRepo" - self.linkable = nil - end - end - - def reddit_post - linkable if linkable_type == "RedditPost" - end - - def reddit_post_id - linkable_id if linkable_type == "RedditPost" - end - - def reddit_post_id=(id) - if id.present? - self.linkable_type = "RedditPost" - self.linkable_id = id - elsif linkable_type == "RedditPost" - self.linkable = nil - end - end - def first_flight? - return false unless article&.blog_id + return false unless linkable_type == "Article" && linkable&.blog_id - blog_id = article.blog_id + blog_id = linkable.blog_id current_issue = newsletter_section.newsletter_issue !NewsletterItem diff --git a/app/views/newsletter_mailer/issue.html.erb b/app/views/newsletter_mailer/issue.html.erb index 94b56b3..0caa624 100644 --- a/app/views/newsletter_mailer/issue.html.erb +++ b/app/views/newsletter_mailer/issue.html.erb @@ -60,10 +60,10 @@

- <%= item.article.blog.name %> + <%= blog.name %> - <% if @first_flight_blog_ids.include?(item.article.blog_id) %> + <% if @first_flight_blog_ids.include?(blog.id) %> diff --git a/test/mailers/previews/newsletter_mailer_preview.rb b/test/mailers/previews/newsletter_mailer_preview.rb index faedd69..92520e5 100644 --- a/test/mailers/previews/newsletter_mailer_preview.rb +++ b/test/mailers/previews/newsletter_mailer_preview.rb @@ -38,7 +38,7 @@ def seed_preview_data items.each_with_index do |attrs, item_idx| url = "https://example.com/preview-#{SecureRandom.hex(4)}" article = blog.articles.create!(title: attrs[:title], url: url, summary: attrs[:description], published_at: item_idx.days.ago) - item = section.newsletter_items.create!(title: attrs[:title], description: attrs[:description], url: url, position: item_idx, article: article) + item = section.newsletter_items.create!(title: attrs[:title], description: attrs[:description], url: url, position: item_idx, linkable: article) utm_url = "#{url}?utm_source=rubycrow&utm_medium=email&utm_campaign=issue_9999" item.create_tracked_link!(destination_url: utm_url) diff --git a/test/models/newsletter_issue_test.rb b/test/models/newsletter_issue_test.rb index c761fe7..1581be2 100644 --- a/test/models/newsletter_issue_test.rb +++ b/test/models/newsletter_issue_test.rb @@ -71,6 +71,6 @@ class NewsletterIssueTest < ActiveSupport::TestCase issue.create_tracked_links! link = issue.tracked_links.first - assert_equal article, link.trackable.article + assert_equal article, link.trackable.linkable end end diff --git a/test/models/newsletter_item_test.rb b/test/models/newsletter_item_test.rb index 42ef586..65bb580 100644 --- a/test/models/newsletter_item_test.rb +++ b/test/models/newsletter_item_test.rb @@ -51,7 +51,6 @@ class NewsletterItemTest < ActiveSupport::TestCase item = newsletter_items(:rails_update) assert_equal "Article", item.linkable_type assert_equal article, item.linkable - assert_equal article, item.article end test "linkable can be a ruby gem" do @@ -59,119 +58,6 @@ class NewsletterItemTest < ActiveSupport::TestCase item = newsletter_items(:ruby_gem) assert_equal "RubyGem", item.linkable_type assert_equal gem, item.linkable - assert_equal gem, item.ruby_gem - end - - test "article returns nil when linkable is a ruby gem" do - item = newsletter_items(:ruby_gem) - assert_nil item.article - assert_nil item.article_id - end - - test "ruby_gem returns nil when linkable is an article" do - item = newsletter_items(:rails_update) - assert_nil item.ruby_gem - assert_nil item.ruby_gem_id - end - - test "article_id= sets linkable to article" do - article = articles(:rails_performance) - item = NewsletterItem.new( - newsletter_section: newsletter_sections(:crows_pick), - title: "Test", - url: "https://example.com" - ) - item.article_id = article.id - assert_equal "Article", item.linkable_type - assert_equal article.id, item.linkable_id - end - - test "ruby_gem_id= sets linkable to ruby gem" do - gem = ruby_gems(:rack_updated) - item = NewsletterItem.new( - newsletter_section: newsletter_sections(:crows_pick), - title: "Test", - url: "https://example.com" - ) - item.ruby_gem_id = gem.id - assert_equal "RubyGem", item.linkable_type - assert_equal gem.id, item.linkable_id - end - - test "article_id= with blank clears article linkable" do - item = newsletter_items(:rails_update) - item.article_id = "" - assert_nil item.linkable_type - assert_nil item.linkable_id - end - - test "ruby_gem_id= with blank clears gem linkable" do - item = newsletter_items(:ruby_gem) - item.ruby_gem_id = "" - assert_nil item.linkable_type - assert_nil item.linkable_id - end - - test "github_repo_id= sets linkable to github repo" do - repo = github_repos(:rails_repo) - item = NewsletterItem.new( - newsletter_section: newsletter_sections(:crows_pick), - title: "Test", - url: "https://example.com" - ) - item.github_repo_id = repo.id - assert_equal "GithubRepo", item.linkable_type - assert_equal repo.id, item.linkable_id - end - - test "reddit_post_id= sets linkable to reddit post" do - post_record = reddit_posts(:ruby_post) - item = NewsletterItem.new( - newsletter_section: newsletter_sections(:crows_pick), - title: "Test", - url: "https://example.com" - ) - item.reddit_post_id = post_record.id - assert_equal "RedditPost", item.linkable_type - assert_equal post_record.id, item.linkable_id - end - - test "github_repo_id= with blank clears github repo linkable" do - item = NewsletterItem.new( - newsletter_section: newsletter_sections(:crows_pick), - title: "Test", - url: "https://example.com", - linkable_type: "GithubRepo", - linkable_id: github_repos(:rails_repo).id - ) - item.github_repo_id = "" - assert_nil item.linkable_type - assert_nil item.linkable_id - end - - test "reddit_post_id= with blank clears reddit post linkable" do - item = NewsletterItem.new( - newsletter_section: newsletter_sections(:crows_pick), - title: "Test", - url: "https://example.com", - linkable_type: "RedditPost", - linkable_id: reddit_posts(:ruby_post).id - ) - item.reddit_post_id = "" - assert_nil item.linkable_type - assert_nil item.linkable_id - end - - test "github_repo returns nil when linkable is an article" do - item = newsletter_items(:rails_update) - assert_nil item.github_repo - assert_nil item.github_repo_id - end - - test "reddit_post returns nil when linkable is an article" do - item = newsletter_items(:rails_update) - assert_nil item.reddit_post - assert_nil item.reddit_post_id end test "clear_blank_linkable nils both fields when linkable_type is blank" do From e1b100f71b3bc9a3db499f6735cba22dcfd2044a Mon Sep 17 00:00:00 2001 From: Alex Koval Date: Mon, 23 Feb 2026 13:56:10 +0100 Subject: [PATCH 6/6] fix: address code review findings for other sources feature - Extract shared concerns (NewsletterSource, HttpFetchable, PeriodFilterable) - Remove default_scope from all models, use explicit named scopes - Fix search controllers to use find_by with proper 404 responses - Fix N+1 query in newsletter mailer with preload_article_blogs - Fix merge order in RubyGem.sync_from_api! (new gems take precedence) - Add source attribution labels in newsletter email templates - Switch linkable autofill controller to @rails/request.js - Upgrade @hotwired/turbo-rails and @hotwired/turbo to 8.0.23 - Add accessibility attributes to drag handles - Add mailer tests and fixtures for all linkable types --- .../admin/article_searches_controller.rb | 3 +- app/controllers/admin/articles_controller.rb | 10 ++-- .../admin/gem_searches_controller.rb | 3 +- .../admin/github_repo_searches_controller.rb | 11 ++-- .../admin/github_repos_controller.rb | 10 ++-- .../admin/reddit_post_searches_controller.rb | 3 +- .../admin/reddit_posts_controller.rb | 10 ++-- app/controllers/admin/ruby_gems_controller.rb | 10 ++-- app/controllers/articles_controller.rb | 2 +- .../concerns/admin/period_filterable.rb | 11 ++++ app/helpers/application_helper.rb | 10 ++++ .../linkable_autofill_controller.js | 31 ++++++----- app/mailers/newsletter_mailer.rb | 20 ++++++- app/models/article.rb | 9 ++-- app/models/concerns/http_fetchable.rb | 16 ++++++ app/models/concerns/newsletter_source.rb | 11 ++++ app/models/github_repo.rb | 34 +++++------- app/models/reddit_post.rb | 24 ++------- app/models/ruby_gem.rb | 28 +++------- .../newsletter_issues/_item_fields.html.erb | 53 ++++++++++++------ .../_section_fields.html.erb | 15 ++++-- app/views/newsletter_mailer/issue.html.erb | 11 ++++ app/views/newsletter_mailer/issue.text.erb | 5 +- package.json | 4 +- test/fixtures/newsletter_items.yml | 16 ++++++ test/fixtures/tracked_links.yml | 14 +++++ test/mailers/newsletter_mailer_test.rb | 54 +++++++++++++++++++ .../previews/newsletter_mailer_preview.rb | 36 +++++++++---- test/models/article_test.rb | 5 +- test/models/github_repo_test.rb | 11 ++-- test/models/newsletter_item_test.rb | 14 +++++ test/models/reddit_post_test.rb | 22 ++++++-- test/models/ruby_gem_test.rb | 25 +++++---- yarn.lock | 33 +++++++----- 34 files changed, 385 insertions(+), 189 deletions(-) create mode 100644 app/controllers/concerns/admin/period_filterable.rb create mode 100644 app/models/concerns/http_fetchable.rb create mode 100644 app/models/concerns/newsletter_source.rb create mode 100644 test/mailers/newsletter_mailer_test.rb diff --git a/app/controllers/admin/article_searches_controller.rb b/app/controllers/admin/article_searches_controller.rb index 4b5a85a..4f9cbfd 100644 --- a/app/controllers/admin/article_searches_controller.rb +++ b/app/controllers/admin/article_searches_controller.rb @@ -2,7 +2,8 @@ module Admin class ArticleSearchesController < BaseController def index if params[:id].present? - article = Article.includes(:blog).find(params[:id]) + article = Article.includes(:blog).find_by(id: params[:id]) + return render json: {error: "not found"}, status: :not_found unless article render json: {id: article.id, title: article.title, url: article.url, description: article.summary} elsif params[:q].present? articles = Article.includes(:blog).search_by_title(params[:q]).recent(20) diff --git a/app/controllers/admin/articles_controller.rb b/app/controllers/admin/articles_controller.rb index 535bf90..340eadc 100644 --- a/app/controllers/admin/articles_controller.rb +++ b/app/controllers/admin/articles_controller.rb @@ -1,15 +1,11 @@ module Admin class ArticlesController < BaseController - before_action :set_article, only: [:show, :edit, :update, :destroy] + include PeriodFilterable - PERIOD_FILTERS = { - "last_week" => 1.week, - "last_2_weeks" => 2.weeks, - "last_month" => 1.month - }.freeze + before_action :set_article, only: [:show, :edit, :update, :destroy] def index - scope = Article.includes(:blog) + scope = Article.includes(:blog).by_publish_date scope = scope.where(blog_id: params[:blog_id]) if params[:blog_id].present? @period = params[:period] diff --git a/app/controllers/admin/gem_searches_controller.rb b/app/controllers/admin/gem_searches_controller.rb index d6cc51b..e6e2250 100644 --- a/app/controllers/admin/gem_searches_controller.rb +++ b/app/controllers/admin/gem_searches_controller.rb @@ -2,7 +2,8 @@ module Admin class GemSearchesController < BaseController def index if params[:id].present? - gem = RubyGem.find(params[:id]) + gem = RubyGem.find_by(id: params[:id]) + return render json: {error: "not found"}, status: :not_found unless gem render json: {id: gem.id, title: gem.name, url: gem.project_url, description: gem.info} elsif params[:q].present? gems = RubyGem.search_by_name(params[:q]).recent(20) diff --git a/app/controllers/admin/github_repo_searches_controller.rb b/app/controllers/admin/github_repo_searches_controller.rb index 6df985f..3d504a8 100644 --- a/app/controllers/admin/github_repo_searches_controller.rb +++ b/app/controllers/admin/github_repo_searches_controller.rb @@ -1,8 +1,11 @@ module Admin class GithubRepoSearchesController < BaseController + include ActionView::Helpers::NumberHelper + def index if params[:id].present? - repo = GithubRepo.find(params[:id]) + repo = GithubRepo.find_by(id: params[:id]) + return render json: {error: "not found"}, status: :not_found unless repo render json: {id: repo.id, title: repo.full_name, url: repo.url, description: repo.description} elsif params[:q].present? repos = GithubRepo.search_by_name(params[:q]).recent(20) @@ -13,11 +16,5 @@ def index render json: [] end end - - private - - def number_with_delimiter(number) - ActiveSupport::NumberHelper.number_to_delimited(number) - end end end diff --git a/app/controllers/admin/github_repos_controller.rb b/app/controllers/admin/github_repos_controller.rb index 02bd6ae..550a1b6 100644 --- a/app/controllers/admin/github_repos_controller.rb +++ b/app/controllers/admin/github_repos_controller.rb @@ -1,15 +1,11 @@ module Admin class GithubReposController < BaseController - before_action :set_github_repo, only: [:show, :edit, :update, :destroy] + include PeriodFilterable - PERIOD_FILTERS = { - "last_week" => 1.week, - "last_2_weeks" => 2.weeks, - "last_month" => 1.month - }.freeze + before_action :set_github_repo, only: [:show, :edit, :update, :destroy] def index - scope = GithubRepo.all + scope = GithubRepo.by_push_date @period = params[:period] scope = scope.where("repo_pushed_at >= ?", PERIOD_FILTERS[@period].ago) if PERIOD_FILTERS.key?(@period) diff --git a/app/controllers/admin/reddit_post_searches_controller.rb b/app/controllers/admin/reddit_post_searches_controller.rb index b385fc7..77d4783 100644 --- a/app/controllers/admin/reddit_post_searches_controller.rb +++ b/app/controllers/admin/reddit_post_searches_controller.rb @@ -2,7 +2,8 @@ module Admin class RedditPostSearchesController < BaseController def index if params[:id].present? - post = RedditPost.find(params[:id]) + post = RedditPost.find_by(id: params[:id]) + return render json: {error: "not found"}, status: :not_found unless post render json: {id: post.id, title: post.title, url: post.url, description: "r/#{post.subreddit} by #{post.author}"} elsif params[:q].present? posts = RedditPost.search_by_title(params[:q]).recent(20) diff --git a/app/controllers/admin/reddit_posts_controller.rb b/app/controllers/admin/reddit_posts_controller.rb index 501a55a..a7079c6 100644 --- a/app/controllers/admin/reddit_posts_controller.rb +++ b/app/controllers/admin/reddit_posts_controller.rb @@ -1,15 +1,11 @@ module Admin class RedditPostsController < BaseController - before_action :set_reddit_post, only: [:show, :edit, :update, :destroy] + include PeriodFilterable - PERIOD_FILTERS = { - "last_week" => 1.week, - "last_2_weeks" => 2.weeks, - "last_month" => 1.month - }.freeze + before_action :set_reddit_post, only: [:show, :edit, :update, :destroy] def index - scope = RedditPost.all + scope = RedditPost.by_post_date scope = scope.from_subreddit(params[:subreddit]) if params[:subreddit].present? @period = params[:period] diff --git a/app/controllers/admin/ruby_gems_controller.rb b/app/controllers/admin/ruby_gems_controller.rb index c0589e5..9f11f65 100644 --- a/app/controllers/admin/ruby_gems_controller.rb +++ b/app/controllers/admin/ruby_gems_controller.rb @@ -1,15 +1,11 @@ module Admin class RubyGemsController < BaseController - before_action :set_ruby_gem, only: [:show, :edit, :update, :destroy] + include PeriodFilterable - PERIOD_FILTERS = { - "last_week" => 1.week, - "last_2_weeks" => 2.weeks, - "last_month" => 1.month - }.freeze + before_action :set_ruby_gem, only: [:show, :edit, :update, :destroy] def index - scope = RubyGem.all + scope = RubyGem.by_version_date scope = scope.where(activity_type: params[:activity_type]) if params[:activity_type].present? @period = params[:period] diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb index 9e29660..a1de01c 100644 --- a/app/controllers/articles_controller.rb +++ b/app/controllers/articles_controller.rb @@ -1,5 +1,5 @@ class ArticlesController < ApplicationController def index - @articles = Article.includes(:blog).recent(15) + @articles = Article.includes(:blog).by_publish_date.recent(15) end end diff --git a/app/controllers/concerns/admin/period_filterable.rb b/app/controllers/concerns/admin/period_filterable.rb new file mode 100644 index 0000000..f716d4b --- /dev/null +++ b/app/controllers/concerns/admin/period_filterable.rb @@ -0,0 +1,11 @@ +module Admin + module PeriodFilterable + extend ActiveSupport::Concern + + PERIOD_FILTERS = { + "last_week" => 1.week, + "last_2_weeks" => 2.weeks, + "last_month" => 1.month + }.freeze + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b4ac9b7..43e0096 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -10,4 +10,14 @@ def safe_external_url(url) def article_blog_for(item) item.linkable&.blog if item.linkable_type == "Article" end + + def source_label_for(item) + case item.linkable_type + when "RubyGem" then "RubyGems" + when "GithubRepo" then "GitHub" + when "RedditPost" + sub = item.linkable&.subreddit + sub ? "r/#{sub}" : "Reddit" + end + end end diff --git a/app/javascript/controllers/linkable_autofill_controller.js b/app/javascript/controllers/linkable_autofill_controller.js index 10f18b0..74d94eb 100644 --- a/app/javascript/controllers/linkable_autofill_controller.js +++ b/app/javascript/controllers/linkable_autofill_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus" +import { get } from "@rails/request.js" export default class extends Controller { static values = { url: String } @@ -34,24 +35,26 @@ export default class extends Controller { } async itemSelected(itemId) { - const url = `${this.urlValue}?id=${encodeURIComponent(itemId)}` - const response = await fetch(url, { - headers: { "Accept": "application/json" } - }) + try { + const url = `${this.urlValue}?id=${encodeURIComponent(itemId)}` + const response = await get(url, { responseKind: "json" }) - if (!response.ok) return + if (!response.ok) return - const data = await response.json() + const data = await response.json - const wrapper = this.element.closest("[data-nested-form-wrapper]") - if (!wrapper) return + const wrapper = this.element.closest("[data-nested-form-wrapper]") + if (!wrapper) return - const titleInput = wrapper.querySelector('input[name$="[title]"]') - const urlInput = wrapper.querySelector('input[name$="[url]"]') - const descInput = wrapper.querySelector('textarea[name$="[description]"]') + const titleInput = wrapper.querySelector('input[name$="[title]"]') + const urlInput = wrapper.querySelector('input[name$="[url]"]') + const descInput = wrapper.querySelector('textarea[name$="[description]"]') - if (titleInput) titleInput.value = data.title - if (urlInput) urlInput.value = data.url - if (descInput) descInput.value = data.description || "" + if (titleInput) titleInput.value = data.title + if (urlInput) urlInput.value = data.url + if (descInput) descInput.value = data.description || "" + } catch { + // silently ignore network errors + } } } diff --git a/app/mailers/newsletter_mailer.rb b/app/mailers/newsletter_mailer.rb index dca75e1..7399245 100644 --- a/app/mailers/newsletter_mailer.rb +++ b/app/mailers/newsletter_mailer.rb @@ -1,8 +1,13 @@ class NewsletterMailer < ApplicationMailer + helper ApplicationHelper + def issue(newsletter_issue:, subscriber:) @newsletter_issue = newsletter_issue @subscriber = subscriber - @sections = newsletter_issue.newsletter_sections.includes(newsletter_items: [:tracked_link, :linkable]) + @sections = newsletter_issue.newsletter_sections + .order(:position) + .includes(newsletter_items: [:tracked_link, :linkable]) + preload_article_blogs(@sections) @first_flight_blog_ids = first_flight_blog_ids(newsletter_issue) attachments.inline["rubycrow.png"] = { @@ -18,6 +23,19 @@ def issue(newsletter_issue:, subscriber:) private + def preload_article_blogs(sections) + article_linkables = sections.flat_map(&:newsletter_items) + .select { |item| item.linkable_type == "Article" } + .filter_map(&:linkable) + + if article_linkables.any? + ActiveRecord::Associations::Preloader.new( + records: article_linkables, + associations: :blog + ).call + end + end + def first_flight_blog_ids(newsletter_issue) article_ids = newsletter_issue.newsletter_items .where(linkable_type: "Article") diff --git a/app/models/article.rb b/app/models/article.rb index 75c760d..f76d532 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -29,17 +29,14 @@ # fk_rails_... (blog_id => blogs.id) # class Article < ApplicationRecord + include NewsletterSource + self.ignored_columns += ["content_snippet"] belongs_to :blog - has_many :newsletter_items, as: :linkable, dependent: :nullify - - default_scope { order(Arel.sql("published_at DESC NULLS LAST")) } validates :title, presence: true validates :url, presence: true, uniqueness: true - scope :recent, ->(limit = 15) { limit(limit) } - scope :unprocessed, -> { where(processed: false) } - scope :featured, -> { where.not(featured_in_issue: nil) } + scope :by_publish_date, -> { order(Arel.sql("published_at DESC NULLS LAST")) } scope :search_by_title, ->(query) { where("title ILIKE ?", "%#{sanitize_sql_like(query)}%") } end diff --git a/app/models/concerns/http_fetchable.rb b/app/models/concerns/http_fetchable.rb new file mode 100644 index 0000000..5602ebe --- /dev/null +++ b/app/models/concerns/http_fetchable.rb @@ -0,0 +1,16 @@ +module HttpFetchable + extend ActiveSupport::Concern + + class_methods do + private + + def http_client(headers: {}) + Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| + f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" + headers.each { |key, value| f.headers[key] = value } + f.response :follow_redirects + f.adapter Faraday.default_adapter + end + end + end +end diff --git a/app/models/concerns/newsletter_source.rb b/app/models/concerns/newsletter_source.rb new file mode 100644 index 0000000..e16eff3 --- /dev/null +++ b/app/models/concerns/newsletter_source.rb @@ -0,0 +1,11 @@ +module NewsletterSource + extend ActiveSupport::Concern + + included do + has_many :newsletter_items, as: :linkable, dependent: :nullify + + scope :unprocessed, -> { where(processed: false) } + scope :featured, -> { where.not(featured_in_issue: nil) } + scope :recent, ->(limit = 15) { limit(limit) } + end +end diff --git a/app/models/github_repo.rb b/app/models/github_repo.rb index 0842a75..97aae9c 100644 --- a/app/models/github_repo.rb +++ b/app/models/github_repo.rb @@ -1,20 +1,17 @@ class GithubRepo < ApplicationRecord + include NewsletterSource + include HttpFetchable + API_BASE = "https://api.github.com/search/repositories" API_TIMEOUT = 15 - has_many :newsletter_items, as: :linkable, dependent: :nullify - validates :full_name, presence: true, uniqueness: true validates :name, presence: true validates :url, presence: true - default_scope { order(repo_pushed_at: :desc) } - - scope :recent, ->(limit = 15) { limit(limit) } - scope :unprocessed, -> { where(processed: false) } - scope :featured, -> { where.not(featured_in_issue: nil) } + scope :by_push_date, -> { order(repo_pushed_at: :desc) } scope :search_by_name, ->(query) { where("full_name ILIKE ?", "%#{sanitize_sql_like(query)}%") } - scope :popular, -> { unscoped.order(stars: :desc) } + scope :popular, -> { order(stars: :desc) } def self.sync_from_api! daily_repos = fetch_repos(1.day.ago) @@ -31,14 +28,11 @@ def self.sync_from_api! unique_by: :index_github_repos_on_full_name, update_only: %i[name description url stars forks language owner_name owner_avatar_url topics repo_pushed_at last_synced_at] ) - rescue Faraday::Error, JSON::ParserError => e - Rails.logger.error("GithubRepo sync failed: #{e.message}") - [] end def self.fetch_repos(pushed_after) date = pushed_after.strftime("%Y-%m-%d") - response = http_client.get(API_BASE) do |req| + response = github_client.get(API_BASE) do |req| req.params["q"] = "language:ruby pushed:>#{date}" req.params["sort"] = "stars" req.params["order"] = "desc" @@ -74,16 +68,12 @@ def self.fetch_repos(pushed_after) {} end - def self.http_client - Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| - f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" - f.headers["Accept"] = "application/vnd.github+json" - token = Rails.application.credentials.github_token - f.headers["Authorization"] = "Bearer #{token}" if token.present? - f.response :follow_redirects - f.adapter Faraday.default_adapter - end + def self.github_client + token = Rails.application.credentials.github_token + headers = {"Accept" => "application/vnd.github+json"} + headers["Authorization"] = "Bearer #{token}" if token.present? + http_client(headers: headers) end - private_class_method :fetch_repos, :http_client + private_class_method :fetch_repos, :github_client end diff --git a/app/models/reddit_post.rb b/app/models/reddit_post.rb index d5b7b2f..92214f3 100644 --- a/app/models/reddit_post.rb +++ b/app/models/reddit_post.rb @@ -1,20 +1,17 @@ class RedditPost < ApplicationRecord + include NewsletterSource + include HttpFetchable + SUBREDDITS = %w[ruby rails].freeze FEED_TIMEOUT = 15 - has_many :newsletter_items, as: :linkable, dependent: :nullify - validates :reddit_id, presence: true, uniqueness: true validates :title, presence: true validates :url, presence: true validates :subreddit, presence: true, inclusion: {in: SUBREDDITS} - default_scope { order(posted_at: :desc) } - - scope :recent, ->(limit = 15) { limit(limit) } - scope :unprocessed, -> { where(processed: false) } + scope :by_post_date, -> { order(posted_at: :desc) } scope :from_subreddit, ->(sub) { where(subreddit: sub) } - scope :featured, -> { where.not(featured_in_issue: nil) } scope :search_by_title, ->(query) { where("title ILIKE ?", "%#{sanitize_sql_like(query)}%") } def self.sync_from_api! @@ -34,9 +31,6 @@ def self.sync_from_api! unique_by: :index_reddit_posts_on_reddit_id, update_only: %i[title url last_synced_at] ) - rescue Faraday::Error, Feedjira::NoParserAvailable => e - Rails.logger.error("RedditPost sync failed: #{e.message}") - [] end def self.fetch_feed(subreddit) @@ -85,13 +79,5 @@ def self.extract_external_url(entry) link end - def self.http_client - Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| - f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" - f.response :follow_redirects - f.adapter Faraday.default_adapter - end - end - - private_class_method :fetch_feed, :extract_reddit_id, :extract_external_url, :http_client + private_class_method :fetch_feed, :extract_reddit_id, :extract_external_url end diff --git a/app/models/ruby_gem.rb b/app/models/ruby_gem.rb index 8b86ba8..b801e81 100644 --- a/app/models/ruby_gem.rb +++ b/app/models/ruby_gem.rb @@ -31,32 +31,29 @@ # index_ruby_gems_on_version_created_at (version_created_at) # class RubyGem < ApplicationRecord + include NewsletterSource + include HttpFetchable + API_BASE = "https://rubygems.org/api/v1/activity" API_TIMEOUT = 15 ACTIVITY_TYPES = %w[new updated].freeze - has_many :newsletter_items, as: :linkable, dependent: :nullify - validates :name, presence: true, uniqueness: true validates :version, presence: true validates :project_url, presence: true validates :activity_type, presence: true, inclusion: {in: ACTIVITY_TYPES} - default_scope { order(version_created_at: :desc) } - - scope :recent, ->(limit = 15) { limit(limit) } - scope :unprocessed, -> { where(processed: false) } + scope :by_version_date, -> { order(version_created_at: :desc) } scope :newly_created, -> { where(activity_type: "new") } scope :recently_updated, -> { where(activity_type: "updated") } - scope :featured, -> { where.not(featured_in_issue: nil) } scope :search_by_name, ->(query) { where("name ILIKE ?", "%#{sanitize_sql_like(query)}%") } - scope :popular, -> { unscoped.order(downloads: :desc) } + scope :popular, -> { order(downloads: :desc) } def self.sync_from_api! updated_gems = fetch_gems("just_updated.json", "updated") new_gems = fetch_gems("latest.json", "new") - records = new_gems.merge(updated_gems) + records = updated_gems.merge(new_gems) return [] if records.empty? now = Time.current @@ -67,9 +64,6 @@ def self.sync_from_api! unique_by: :index_ruby_gems_on_name, update_only: %i[version authors info licenses downloads project_url homepage_url source_code_url version_created_at activity_type last_synced_at] ) - rescue Faraday::Error, JSON::ParserError => e - Rails.logger.error("RubyGem sync failed: #{e.message}") - [] end def self.fetch_gems(endpoint, activity_type) @@ -103,13 +97,5 @@ def self.fetch_gems(endpoint, activity_type) {} end - def self.http_client - Faraday.new(ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}) do |f| - f.headers["User-Agent"] = "RubyCrow/1.0 (+https://rubycrow.com)" - f.response :follow_redirects - f.adapter Faraday.default_adapter - end - end - - private_class_method :fetch_gems, :http_client + private_class_method :fetch_gems end diff --git a/app/views/admin/newsletter_issues/_item_fields.html.erb b/app/views/admin/newsletter_issues/_item_fields.html.erb index 0f70619..8840432 100644 --- a/app/views/admin/newsletter_issues/_item_fields.html.erb +++ b/app/views/admin/newsletter_issues/_item_fields.html.erb @@ -5,7 +5,14 @@ style="border-color: var(--lui-theme-border); background: var(--lui-theme-surface-secondary);" >

- + + ☰ +
@@ -24,9 +31,13 @@ ) %>
-
"> +
" + > <%= lui.combobox( name: :linkable_id, form: item_form, @@ -38,9 +49,13 @@ ) %>
-
"> +
" + > <%= lui.combobox( name: :linkable_id, form: item_form, @@ -52,9 +67,13 @@ ) %>
-
"> +
" + > <%= lui.combobox( name: :linkable_id, form: item_form, @@ -66,9 +85,13 @@ ) %>
-
"> +
" + > <%= lui.combobox( name: :linkable_id, form: item_form, @@ -81,9 +104,7 @@
-
- <%= lui.button(type: :button, style: :outline, icon: "trash", data: { action: "nested-form#remove" }) %> -
+ <%= lui.button(type: :button, style: :outline, icon: "trash", data: { action: "nested-form#remove" }) %>
<%= lui.input(name: :title, form: item_form, label: "Title") %> diff --git a/app/views/admin/newsletter_issues/_section_fields.html.erb b/app/views/admin/newsletter_issues/_section_fields.html.erb index ed36b96..c59a34c 100644 --- a/app/views/admin/newsletter_issues/_section_fields.html.erb +++ b/app/views/admin/newsletter_issues/_section_fields.html.erb @@ -7,11 +7,18 @@ style="border-color: var(--lui-theme-border); background: var(--lui-theme-surface-secondary);" >
- + + ☰ + +
<%= lui.input(name: :title, form: section_form, label: "Section Title") %>
-
- <%= lui.button(type: :button, style: :outline, icon: "trash", data: { action: "nested-form#remove" }) %> -
+ + <%= lui.button(type: :button, style: :outline, icon: "trash", data: { action: "nested-form#remove" }) %>
<%= section_form.hidden_field :id %> diff --git a/app/views/newsletter_mailer/issue.html.erb b/app/views/newsletter_mailer/issue.html.erb index 0caa624..4728ea1 100644 --- a/app/views/newsletter_mailer/issue.html.erb +++ b/app/views/newsletter_mailer/issue.html.erb @@ -70,6 +70,9 @@ 🆕 First Flight <% end %> + <% elsif (source = source_label_for(item)) %> + via + <%= source %> <% end %>

@@ -126,6 +129,9 @@ 🆕 First Flight <% end %> + <% elsif (source = source_label_for(item)) %> + via + <%= source %> <% end %>

@@ -186,6 +192,9 @@ 🆕 First Flight <% end %> + <% elsif (source = source_label_for(item)) %> + via + <%= source %> <% end %>

@@ -238,6 +247,8 @@ <% end %>

+ <% elsif (source = source_label_for(item)) %> +

via <%= source %>

<% end %> diff --git a/app/views/newsletter_mailer/issue.text.erb b/app/views/newsletter_mailer/issue.text.erb index a6dfcdb..6b0716a 100644 --- a/app/views/newsletter_mailer/issue.text.erb +++ b/app/views/newsletter_mailer/issue.text.erb @@ -8,8 +8,9 @@ RubyCrow - Issue #<%= @newsletter_issue.issue_number %> <%= "-" * 20 %> <% items.each do |item| %> <%= item.title %> -<%= item.article&.blog&.name %><%= " [🆕 First Flight]" if item.article&.blog_id && @first_flight_blog_ids.include?(item.article.blog_id) %> -<%= item.description %> +<% if (blog = article_blog_for(item)) %><%= blog.name %><%= " [First Flight]" if @first_flight_blog_ids.include?(blog.id) %> +<% elsif (source = source_label_for(item)) %>via <%= source %> +<% end %><%= item.description %> <%= item.tracked_link.tracked_url(subscriber: @subscriber) %> <% end %> diff --git a/package.json b/package.json index 4b0a289..bcf90a2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,9 @@ "private": "true", "dependencies": { "@hotwired/stimulus": "^3.2.2", - "@hotwired/turbo-rails": "^7.3.0", + "@hotwired/turbo": "8.0.23", + "@hotwired/turbo-rails": "8.0.23", + "@rails/request.js": "^0.0.13", "@tailwindcss/cli": "^4.0.9", "esbuild": "0.17.19", "sortablejs": "^1.15.7", diff --git a/test/fixtures/newsletter_items.yml b/test/fixtures/newsletter_items.yml index 16a0937..ca2aa1f 100644 --- a/test/fixtures/newsletter_items.yml +++ b/test/fixtures/newsletter_items.yml @@ -39,6 +39,22 @@ ruby_gem: url: "https://rubygems.org/gems/rack" position: 0 +github_repo: + newsletter_section: shiny_objects + linkable: rails_repo (GithubRepo) + title: "rails/rails" + description: "Ruby on Rails" + url: "https://github.com/rails/rails" + position: 1 + +reddit_post: + newsletter_section: shiny_objects + linkable: ruby_post (RedditPost) + title: "What's new in Ruby 4.0?" + description: "Discussion about Ruby 4.0 features" + url: "https://www.reddit.com/r/ruby/comments/abc123/whats_new_in_ruby_40/" + position: 2 + issue_two_speedshop: newsletter_section: issue_two_picks linkable: older_article (Article) diff --git a/test/fixtures/tracked_links.yml b/test/fixtures/tracked_links.yml index 72163dd..9485760 100644 --- a/test/fixtures/tracked_links.yml +++ b/test/fixtures/tracked_links.yml @@ -30,3 +30,17 @@ link_two: destination_url: "https://evilmartians.com/chronicles/viewcomponent?utm_source=rubycrow&utm_medium=email&utm_campaign=issue_1" total_clicks: 2 unique_clicks: 1 + +link_github_repo: + token: "ghi789token" + trackable: github_repo (NewsletterItem) + destination_url: "https://github.com/rails/rails?utm_source=rubycrow&utm_medium=email&utm_campaign=issue_1" + total_clicks: 0 + unique_clicks: 0 + +link_reddit_post: + token: "jkl012token" + trackable: reddit_post (NewsletterItem) + destination_url: "https://www.reddit.com/r/ruby/comments/abc123/whats_new_in_ruby_40/?utm_source=rubycrow&utm_medium=email&utm_campaign=issue_1" + total_clicks: 0 + unique_clicks: 0 diff --git a/test/mailers/newsletter_mailer_test.rb b/test/mailers/newsletter_mailer_test.rb new file mode 100644 index 0000000..cc7843d --- /dev/null +++ b/test/mailers/newsletter_mailer_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class NewsletterMailerTest < ActiveSupport::TestCase + test "issue renders with article linkable" do + issue = newsletter_issues(:issue_one) + subscriber = subscribers(:one) + + mail = NewsletterMailer.issue(newsletter_issue: issue, subscriber: subscriber) + + assert_equal issue.subject, mail.subject + assert_equal [subscriber.email], mail.to + assert_includes mail.html_part.body.to_s, "Rails 8.1 Released" + end + + test "issue renders with ruby gem linkable" do + issue = newsletter_issues(:issue_one) + subscriber = subscribers(:one) + + mail = NewsletterMailer.issue(newsletter_issue: issue, subscriber: subscriber) + + assert_includes mail.html_part.body.to_s, "Rack 3.1.0" + end + + test "issue renders with github repo linkable" do + issue = newsletter_issues(:issue_one) + subscriber = subscribers(:one) + + mail = NewsletterMailer.issue(newsletter_issue: issue, subscriber: subscriber) + + assert_includes mail.html_part.body.to_s, "rails/rails" + end + + test "issue renders with reddit post linkable" do + issue = newsletter_issues(:issue_one) + subscriber = subscribers(:one) + + mail = NewsletterMailer.issue(newsletter_issue: issue, subscriber: subscriber) + body = mail.html_part.body.to_s + + assert_includes body, "What's new in Ruby 4.0?" + end + + test "issue includes source attribution for non-article items" do + issue = newsletter_issues(:issue_one) + subscriber = subscribers(:one) + + mail = NewsletterMailer.issue(newsletter_issue: issue, subscriber: subscriber) + body = mail.html_part.body.to_s + + assert_includes body, ">RubyGems" + assert_includes body, ">GitHub" + assert_includes body, ">r/ruby" + end +end diff --git a/test/mailers/previews/newsletter_mailer_preview.rb b/test/mailers/previews/newsletter_mailer_preview.rb index 92520e5..c796c52 100644 --- a/test/mailers/previews/newsletter_mailer_preview.rb +++ b/test/mailers/previews/newsletter_mailer_preview.rb @@ -11,22 +11,37 @@ def issue def seed_preview_data blog = Blog.first || Blog.create!(name: "SpeedShop", url: "https://www.speedshop.co", rss_url: "https://www.speedshop.co/feed.xml") + gem = RubyGem.first || RubyGem.create!( + name: "solid_queue", version: "1.0.0", project_url: "https://rubygems.org/gems/solid_queue", + activity_type: "new", info: "Database-backed Active Job backend" + ) + + repo = GithubRepo.first || GithubRepo.create!( + full_name: "rails/rails", name: "rails", url: "https://github.com/rails/rails", + description: "Ruby on Rails", stars: 56000 + ) + + reddit = RedditPost.first || RedditPost.create!( + reddit_id: "preview123", title: "What's new in Ruby 4.0?", + url: "https://www.reddit.com/r/ruby/comments/preview123/test/", subreddit: "ruby" + ) + sections_data = { "Crows Pick" => [ - {title: "Building a Zero-Downtime Deployment Pipeline with Kamal 2", description: "A deep dive into configuring Kamal 2 for production Rails apps with zero-downtime deploys, health checks, and rollback strategies."} + {title: "Building a Zero-Downtime Deployment Pipeline with Kamal 2", description: "A deep dive into configuring Kamal 2 for production Rails apps with zero-downtime deploys, health checks, and rollback strategies.", linkable: blog.articles.create!(title: "Building a Zero-Downtime Deployment Pipeline with Kamal 2", url: "https://example.com/preview-#{SecureRandom.hex(4)}", summary: "A deep dive into configuring Kamal 2", published_at: 1.day.ago)} ], "Shiny Objects" => [ - {title: "How We Reduced Our Sidekiq Memory Usage by 60%", description: "Practical techniques for profiling and reducing memory bloat in Sidekiq workers, including GC tuning and object allocation patterns."}, - {title: "Ruby 3.4's New Pattern Matching Features You Missed", description: "An overlooked addition in Ruby 3.4 that makes pattern matching significantly more expressive for complex data structures."}, - {title: "Understanding ActiveRecord's Query Cache", description: "The query cache is great until it isn't. Learn when it silently causes issues and how to debug memory bloat in long-running requests."} + {title: "How We Reduced Our Sidekiq Memory Usage by 60%", description: "Practical techniques for profiling and reducing memory bloat in Sidekiq workers.", linkable: blog.articles.create!(title: "How We Reduced Our Sidekiq Memory Usage by 60%", url: "https://example.com/preview-#{SecureRandom.hex(4)}", published_at: 2.days.ago)}, + {title: gem.name, description: gem.info, linkable: gem}, + {title: repo.full_name, description: repo.description, linkable: repo} ], "Crow Call" => [ - {title: "Building a Custom Authentication System Without Devise", description: "A beautifully written walkthrough of building auth from scratch in Rails 8, covering sessions, password resets, and magic links."} + {title: "Building a Custom Authentication System Without Devise", description: "A beautifully written walkthrough of building auth from scratch in Rails 8.", linkable: blog.articles.create!(title: "Building a Custom Authentication System Without Devise", url: "https://example.com/preview-#{SecureRandom.hex(4)}", published_at: 3.days.ago)} ], "Quick Gems" => [ - {title: "Rails 8.1 Beta Released", description: nil}, - {title: "dry-rb 2.0 is Here", description: nil}, - {title: "New Rubocop 2.0 Rules You Should Enable", description: nil} + {title: reddit.title, description: nil, linkable: reddit}, + {title: "dry-rb 2.0 is Here", description: nil, linkable: nil}, + {title: "New Rubocop 2.0 Rules You Should Enable", description: nil, linkable: nil} ] } @@ -36,9 +51,8 @@ def seed_preview_data section = issue.newsletter_sections.create!(title: section_title, position: section_idx) items.each_with_index do |attrs, item_idx| - url = "https://example.com/preview-#{SecureRandom.hex(4)}" - article = blog.articles.create!(title: attrs[:title], url: url, summary: attrs[:description], published_at: item_idx.days.ago) - item = section.newsletter_items.create!(title: attrs[:title], description: attrs[:description], url: url, position: item_idx, linkable: article) + url = attrs[:linkable]&.try(:url) || attrs[:linkable]&.try(:project_url) || "https://example.com/preview-#{SecureRandom.hex(4)}" + item = section.newsletter_items.create!(title: attrs[:title], description: attrs[:description], url: url, position: item_idx, linkable: attrs[:linkable]) utm_url = "#{url}?utm_source=rubycrow&utm_medium=email&utm_campaign=issue_9999" item.create_tracked_link!(destination_url: utm_url) diff --git a/test/models/article_test.rb b/test/models/article_test.rb index b031b19..b00ae97 100644 --- a/test/models/article_test.rb +++ b/test/models/article_test.rb @@ -28,9 +28,10 @@ class ArticleTest < ActiveSupport::TestCase assert_equal blogs(:speedshop), articles(:rails_performance).blog end - test "default scope orders by published_at desc" do - articles = Article.all + test "by_publish_date scope orders by published_at desc" do + articles = Article.by_publish_date dates = articles.map(&:published_at).compact + assert dates.any? assert_equal dates, dates.sort.reverse end diff --git a/test/models/github_repo_test.rb b/test/models/github_repo_test.rb index ca772db..b43f072 100644 --- a/test/models/github_repo_test.rb +++ b/test/models/github_repo_test.rb @@ -30,18 +30,21 @@ class GithubRepoTest < ActiveSupport::TestCase assert_includes repo.errors[:url], "can't be blank" end - test "default scope orders by repo_pushed_at desc" do - repos = GithubRepo.all + test "by_push_date scope orders by repo_pushed_at desc" do + repos = GithubRepo.by_push_date dates = repos.map(&:repo_pushed_at).compact + assert dates.any? assert_equal dates, dates.sort.reverse end test "recent scope limits results" do - assert GithubRepo.recent(2).count <= 2 + assert_equal 2, GithubRepo.recent(2).count end test "unprocessed scope returns unprocessed repos" do - GithubRepo.unprocessed.each do |repo| + unprocessed = GithubRepo.unprocessed + assert unprocessed.any? + unprocessed.each do |repo| assert_not repo.processed? end end diff --git a/test/models/newsletter_item_test.rb b/test/models/newsletter_item_test.rb index 65bb580..6da0f0d 100644 --- a/test/models/newsletter_item_test.rb +++ b/test/models/newsletter_item_test.rb @@ -60,6 +60,20 @@ class NewsletterItemTest < ActiveSupport::TestCase assert_equal gem, item.linkable end + test "linkable can be a github repo" do + repo = github_repos(:rails_repo) + item = newsletter_items(:github_repo) + assert_equal "GithubRepo", item.linkable_type + assert_equal repo, item.linkable + end + + test "linkable can be a reddit post" do + post = reddit_posts(:ruby_post) + item = newsletter_items(:reddit_post) + assert_equal "RedditPost", item.linkable_type + assert_equal post, item.linkable + end + test "clear_blank_linkable nils both fields when linkable_type is blank" do item = NewsletterItem.new( newsletter_section: newsletter_sections(:crows_pick), diff --git a/test/models/reddit_post_test.rb b/test/models/reddit_post_test.rb index 31a5ae5..874cf09 100644 --- a/test/models/reddit_post_test.rb +++ b/test/models/reddit_post_test.rb @@ -46,24 +46,28 @@ class RedditPostTest < ActiveSupport::TestCase assert post.valid? end - test "default scope orders by posted_at desc" do - posts = RedditPost.all + test "by_post_date scope orders by posted_at desc" do + posts = RedditPost.by_post_date dates = posts.map(&:posted_at).compact + assert dates.any? assert_equal dates, dates.sort.reverse end test "recent scope limits results" do - assert RedditPost.recent(2).count <= 2 + assert_equal 2, RedditPost.recent(2).count end test "unprocessed scope returns unprocessed posts" do - RedditPost.unprocessed.each do |post| + unprocessed = RedditPost.unprocessed + assert unprocessed.any? + unprocessed.each do |post| assert_not post.processed? end end test "from_subreddit scope filters by subreddit" do ruby_posts = RedditPost.from_subreddit("ruby") + assert ruby_posts.any? ruby_posts.each do |post| assert_equal "ruby", post.subreddit end @@ -129,6 +133,16 @@ class RedditPostTest < ActiveSupport::TestCase assert_equal [], result end + test "sync_from_api! handles malformed XML" do + stub_request(:get, "https://www.reddit.com/r/ruby/hot/.rss?limit=50") + .to_return(status: 200, body: "not valid xml at all", headers: {"Content-Type" => "application/xml"}) + stub_request(:get, "https://www.reddit.com/r/rails/hot/.rss?limit=50") + .to_return(status: 200, body: 'r/rails', headers: {"Content-Type" => "application/xml"}) + + result = RedditPost.sync_from_api! + assert_equal [], result + end + test "sync_from_api! preserves first_seen_at on re-sync" do rss_body = <<~XML diff --git a/test/models/ruby_gem_test.rb b/test/models/ruby_gem_test.rb index d9ddaa9..a94e6c0 100644 --- a/test/models/ruby_gem_test.rb +++ b/test/models/ruby_gem_test.rb @@ -46,30 +46,37 @@ class RubyGemTest < ActiveSupport::TestCase assert gem.valid? end - test "default scope orders by version_created_at desc" do - gems = RubyGem.all + test "by_version_date scope orders by version_created_at desc" do + gems = RubyGem.by_version_date dates = gems.map(&:version_created_at).compact + assert dates.any? assert_equal dates, dates.sort.reverse end test "recent scope limits results" do - assert RubyGem.recent(2).count <= 2 + assert_equal 2, RubyGem.recent(2).count end test "unprocessed scope returns unprocessed gems" do - RubyGem.unprocessed.each do |gem| + unprocessed = RubyGem.unprocessed + assert unprocessed.any? + unprocessed.each do |gem| assert_not gem.processed? end end test "newly_created scope returns new gems" do - RubyGem.newly_created.each do |gem| + newly_created = RubyGem.newly_created + assert newly_created.any? + newly_created.each do |gem| assert_equal "new", gem.activity_type end end test "recently_updated scope returns updated gems" do - RubyGem.recently_updated.each do |gem| + recently_updated = RubyGem.recently_updated + assert recently_updated.any? + recently_updated.each do |gem| assert_equal "updated", gem.activity_type end end @@ -142,7 +149,7 @@ class RubyGemTest < ActiveSupport::TestCase assert_equal "new", new_gem.activity_type end - test "sync_from_api! updated gems take precedence over new gems with same name" do + test "sync_from_api! new gems take precedence over updated gems with same name" do shared_gem = { "name" => "shared_gem", "version" => "1.0.0", @@ -164,12 +171,12 @@ class RubyGemTest < ActiveSupport::TestCase RubyGem.sync_from_api! gem = RubyGem.find_by(name: "shared_gem") - assert_equal "updated", gem.activity_type + assert_equal "new", gem.activity_type end test "sync_from_api! returns empty array on api error" do stub_request(:get, "https://rubygems.org/api/v1/activity/just_updated.json") - .to_return(status: 500) + .to_return(status: 200, body: "[]") stub_request(:get, "https://rubygems.org/api/v1/activity/latest.json") .to_return(status: 200, body: "[]") diff --git a/yarn.lock b/yarn.lock index 1d2fbf4..77e2866 100644 --- a/yarn.lock +++ b/yarn.lock @@ -139,18 +139,18 @@ resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608" integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A== -"@hotwired/turbo-rails@^7.3.0": - version "7.3.0" - resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-7.3.0.tgz#422c21752509f3edcd6c7b2725bbe9e157815f51" - integrity sha512-fvhO64vp/a2UVQ3jue9WTc2JisMv9XilIC7ViZmXAREVwiQ2S4UC7Go8f9A1j4Xu7DBI6SbFdqILk5ImqVoqyA== +"@hotwired/turbo-rails@8.0.23": + version "8.0.23" + resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-8.0.23.tgz#7a84ad041cb0f3e5d9ff97d1a1f0291550a93fc9" + integrity sha512-iBILwda3qmQC7FYM70+4s6kEQ7Fx9dJ6+yGxjPyrz9a5JDx1+y7OAA5TA7GGVOZJoicMLrKGdFDNorl40X35lw== dependencies: - "@hotwired/turbo" "^7.3.0" - "@rails/actioncable" "^7.0" + "@hotwired/turbo" "^8.0.23" + "@rails/actioncable" ">=7.0" -"@hotwired/turbo@^7.3.0": - version "7.3.0" - resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.3.0.tgz#2226000fff1aabda9fd9587474565c9929dbf15d" - integrity sha512-Dcu+NaSvHLT7EjrDrkEmH4qET2ZJZ5IcCWmNXxNQTBwlnE5tBZfN6WxZ842n5cHV52DH/AKNirbPBtcEXDLW4g== +"@hotwired/turbo@8.0.23", "@hotwired/turbo@^8.0.23": + version "8.0.23" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.23.tgz#a6eebc9ab4a5faadae265a4cbec8cfcb5731e77c" + integrity sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ== "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" @@ -284,10 +284,15 @@ "@parcel/watcher-win32-ia32" "2.5.6" "@parcel/watcher-win32-x64" "2.5.6" -"@rails/actioncable@^7.0": - version "7.2.300" - resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.2.300.tgz#5bf24ffded6dd659d8b42cc67fb68fb51767b989" - integrity sha512-bmrp+HgCPd2BlhDfJ81hJ6Nvd6yh0B2hIW8ThOmUdOr3L+sFjU1glpBmn+MoQF2K0HLvnWCGqF3TxT/S0jj3QA== +"@rails/actioncable@>=7.0": + version "8.1.200" + resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-8.1.200.tgz#acecf74ab4846144eefdbc16618786df0cebedf9" + integrity sha512-on0DSb7AFUkq1ocxivDNQhhGW/RQpY91zvRVyyaEWP4gOOZWy33P/UyxjQk74IENWNrTqs8+zOGHwTjiiFruRw== + +"@rails/request.js@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@rails/request.js/-/request.js-0.0.13.tgz#3c7cbd0303ea9ef51bd3e7acaab86e9324efc52f" + integrity sha512-7MXmjFOPuaxpjG8brqKJG0EfIe9ak6R0wRnjCBtRuADNFbdlRxETdKx1T5NVU4Ato3iZOkEpeSUEuLboL3tCGA== "@tailwindcss/cli@^4.0.9": version "4.1.18"