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 diff --git a/app/controllers/admin/article_searches_controller.rb b/app/controllers/admin/article_searches_controller.rb index fa5479d..4f9cbfd 100644 --- a/app/controllers/admin/article_searches_controller.rb +++ b/app/controllers/admin/article_searches_controller.rb @@ -2,8 +2,9 @@ module Admin 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} + 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) render json: articles.map { |a| 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/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 27a9121..1ecee6e 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -10,6 +10,12 @@ 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 + @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 new file mode 100644 index 0000000..e6e2250 --- /dev/null +++ b/app/controllers/admin/gem_searches_controller.rb @@ -0,0 +1,18 @@ +module Admin + class GemSearchesController < BaseController + def index + if params[:id].present? + 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) + 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/github_repo_searches_controller.rb b/app/controllers/admin/github_repo_searches_controller.rb new file mode 100644 index 0000000..3d504a8 --- /dev/null +++ b/app/controllers/admin/github_repo_searches_controller.rb @@ -0,0 +1,20 @@ +module Admin + class GithubRepoSearchesController < BaseController + include ActionView::Helpers::NumberHelper + + def index + if params[:id].present? + 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) + render json: repos.map { |r| + {value: r.id, label: "#{r.full_name} (#{number_with_delimiter(r.stars)} stars)"} + } + else + render json: [] + end + 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..550a1b6 --- /dev/null +++ b/app/controllers/admin/github_repos_controller.rb @@ -0,0 +1,62 @@ +module Admin + class GithubReposController < BaseController + include PeriodFilterable + + before_action :set_github_repo, only: [:show, :edit, :update, :destroy] + + def index + scope = GithubRepo.by_push_date + + @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 9c9864e..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, :_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..77d4783 --- /dev/null +++ b/app/controllers/admin/reddit_post_searches_controller.rb @@ -0,0 +1,18 @@ +module Admin + class RedditPostSearchesController < BaseController + def index + if params[:id].present? + 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) + 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..a7079c6 --- /dev/null +++ b/app/controllers/admin/reddit_posts_controller.rb @@ -0,0 +1,63 @@ +module Admin + class RedditPostsController < BaseController + include PeriodFilterable + + before_action :set_reddit_post, only: [:show, :edit, :update, :destroy] + + def index + scope = RedditPost.by_post_date + 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/controllers/admin/ruby_gems_controller.rb b/app/controllers/admin/ruby_gems_controller.rb new file mode 100644 index 0000000..9f11f65 --- /dev/null +++ b/app/controllers/admin/ruby_gems_controller.rb @@ -0,0 +1,63 @@ +module Admin + class RubyGemsController < BaseController + include PeriodFilterable + + before_action :set_ruby_gem, only: [:show, :edit, :update, :destroy] + + def index + scope = RubyGem.by_version_date + 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/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 7488fa7..43e0096 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -6,4 +6,18 @@ def safe_external_url(url) rescue URI::InvalidURIError "#" end + + 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/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 5b3c039..6f4053e 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -30,8 +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 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/linkable_autofill_controller.js b/app/javascript/controllers/linkable_autofill_controller.js new file mode 100644 index 0000000..74d94eb --- /dev/null +++ b/app/javascript/controllers/linkable_autofill_controller.js @@ -0,0 +1,60 @@ +import { Controller } from "@hotwired/stimulus" +import { get } from "@rails/request.js" + +export default class extends Controller { + static values = { url: String } + + connect() { + 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) { + 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.itemSelected(val) + } + } + }) + } + + async itemSelected(itemId) { + try { + const url = `${this.urlValue}?id=${encodeURIComponent(itemId)}` + const response = await get(url, { responseKind: "json" }) + + if (!response.ok) return + + const data = 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 = data.title + if (urlInput) urlInput.value = data.url + if (descInput) descInput.value = data.description || "" + } catch { + // silently ignore network errors + } + } +} 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/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..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, {article: :blog}]) + @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,21 +23,38 @@ 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) - 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..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, 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 new file mode 100644 index 0000000..97aae9c --- /dev/null +++ b/app/models/github_repo.rb @@ -0,0 +1,79 @@ +class GithubRepo < ApplicationRecord + include NewsletterSource + include HttpFetchable + + API_BASE = "https://api.github.com/search/repositories" + API_TIMEOUT = 15 + + validates :full_name, presence: true, uniqueness: true + validates :name, presence: true + validates :url, presence: true + + scope :by_push_date, -> { order(repo_pushed_at: :desc) } + scope :search_by_name, ->(query) { where("full_name ILIKE ?", "%#{sanitize_sql_like(query)}%") } + scope :popular, -> { order(stars: :desc) } + + def self.sync_from_api! + daily_repos = fetch_repos(1.day.ago) + weekly_repos = fetch_repos(1.week.ago) + + records = weekly_repos.merge(daily_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] + ) + end + + def self.fetch_repos(pushed_after) + date = pushed_after.strftime("%Y-%m-%d") + response = github_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.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, :github_client +end diff --git a/app/models/newsletter_item.rb b/app/models/newsletter_item.rb index 99e0154..4330219 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,26 +25,40 @@ # 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 + 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 default_scope { order(:position) } 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 .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 + + 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 new file mode 100644 index 0000000..92214f3 --- /dev/null +++ b/app/models/reddit_post.rb @@ -0,0 +1,83 @@ +class RedditPost < ApplicationRecord + include NewsletterSource + include HttpFetchable + + SUBREDDITS = %w[ruby rails].freeze + FEED_TIMEOUT = 15 + + validates :reddit_id, presence: true, uniqueness: true + validates :title, presence: true + validates :url, presence: true + validates :subreddit, presence: true, inclusion: {in: SUBREDDITS} + + scope :by_post_date, -> { order(posted_at: :desc) } + scope :from_subreddit, ->(sub) { where(subreddit: sub) } + 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 last_synced_at] + ) + 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 + + 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 new file mode 100644 index 0000000..b801e81 --- /dev/null +++ b/app/models/ruby_gem.rb @@ -0,0 +1,101 @@ +# == 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 + include NewsletterSource + include HttpFetchable + + API_BASE = "https://rubygems.org/api/v1/activity" + API_TIMEOUT = 15 + ACTIVITY_TYPES = %w[new updated].freeze + + validates :name, presence: true, uniqueness: true + validates :version, presence: true + validates :project_url, presence: true + validates :activity_type, presence: true, inclusion: {in: ACTIVITY_TYPES} + + scope :by_version_date, -> { order(version_created_at: :desc) } + scope :newly_created, -> { where(activity_type: "new") } + scope :recently_updated, -> { where(activity_type: "updated") } + scope :search_by_name, ->(query) { where("name ILIKE ?", "%#{sanitize_sql_like(query)}%") } + 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 = 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] + ) + 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 + + private_class_method :fetch_gems +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? %> +
<%= @pagy.count %> repos total
+No GitHub repos yet
+ <%= lui.button(url: new_admin_github_repo_path, icon: "plus") { "Add your first repo" } %> +<%= @pagy.count %> posts total
+No Reddit posts yet
+ <%= lui.button(url: new_admin_reddit_post_path, icon: "plus") { "Add your first post" } %> +<%= @pagy.count %> gems total
+No gems yet
+ <%= lui.button(url: new_admin_ruby_gem_path, icon: "plus") { "Add your first gem" } %> +- <%= item.article.blog.name %> + <%= blog.name %> - <% if @first_flight_blog_ids.include?(item.article.blog_id) %> + <% if @first_flight_blog_ids.include?(blog.id) %> @@ -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/config/routes.rb b/config/routes.rb index f35df20..aa85b4d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,9 @@ resources :blogs resources :articles + resources :ruby_gems + resources :github_repos + resources :reddit_posts resources :newsletter_issues do member do get :preview @@ -25,6 +28,9 @@ end 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 8718f3e..58c2006 100644 --- a/config/scheduler/development.yml +++ b/config/scheduler/development.yml @@ -2,3 +2,15 @@ sync_blog_registry: every: "6h" class: SyncBlogRegistryJob queue: default +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 3682723..85f379a 100644 --- a/config/scheduler/production.yml +++ b/config/scheduler/production.yml @@ -6,3 +6,15 @@ parse_rss_feeds: every: "2h" class: ParseRssFeedsJob queue: default +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/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/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 d916f06..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_21_184321) 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 @@ -81,15 +107,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 +131,56 @@ 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" + 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/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/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/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/newsletter_issues_controller_test.rb b/test/controllers/admin/newsletter_issues_controller_test.rb index ea7824f..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,14 +88,37 @@ 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 linkable" 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, linkable_type: "RubyGem", linkable_id: gem.id} + } + } + } + }} + end + item = NewsletterIssue.last.newsletter_sections.first.newsletter_items.first + assert_equal gem.id, item.linkable_id + assert_equal "RubyGem", item.linkable_type end test "create with nested items auto-generates tracked links" do 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/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/newsletter_items.yml b/test/fixtures/newsletter_items.yml index c8d7494..ca2aa1f 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,31 @@ 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 +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 - article: older_article + linkable: older_article (Article) title: "Ruby Memory Management" description: "Understanding Ruby memory" url: "https://example.com/memory" @@ -47,7 +65,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/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/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/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/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/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/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 faedd69..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, article: 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 new file mode 100644 index 0000000..b43f072 --- /dev/null +++ b/test/models/github_repo_test.rb @@ -0,0 +1,156 @@ +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 "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_equal 2, GithubRepo.recent(2).count + end + + test "unprocessed scope returns unprocessed repos" do + unprocessed = GithubRepo.unprocessed + assert unprocessed.any? + 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/newsletter_issue_test.rb b/test/models/newsletter_issue_test.rb index 5a55e38..1581be2 100644 --- a/test/models/newsletter_issue_test.rb +++ b/test/models/newsletter_issue_test.rb @@ -66,11 +66,11 @@ 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! 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 211b87c..6da0f0d 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.article_id + assert_nil item.linkable end - test "belongs_to article when set" do + 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 + 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 + 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), - title: article.title, - url: article.url, - position: 0, - article: article + title: "Test", + url: "https://example.com", + linkable_type: "", + linkable_id: 1 ) - assert item.valid? - assert_equal article, item.article + 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 @@ -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/reddit_post_test.rb b/test/models/reddit_post_test.rb new file mode 100644 index 0000000..874cf09 --- /dev/null +++ b/test/models/reddit_post_test.rb @@ -0,0 +1,182 @@ +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 "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_equal 2, RedditPost.recent(2).count + end + + test "unprocessed scope returns unprocessed posts" do + 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 + 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 + +