diff --git a/app/controllers/api/v1/recommended_articles_lists_controller.rb b/app/controllers/api/v1/recommended_articles_lists_controller.rb new file mode 100644 index 000000000..2f78b8105 --- /dev/null +++ b/app/controllers/api/v1/recommended_articles_lists_controller.rb @@ -0,0 +1,54 @@ +module Api + module V1 + class RecommendedArticlesListsController < ApiController + before_action :authenticate_with_api_key! + before_action :require_admin + + rescue_from ArgumentError, with: :error_unprocessable_entity + + def index + @recommended_articles_lists = RecommendedArticlesList.order(id: :desc).page(params[:page]).per(50) + if params[:search].present? + @recommended_articles_lists = @recommended_articles_lists.search(params[:search]) + end + render json: @recommended_articles_lists + end + + def show + @recommended_articles_list = RecommendedArticlesList.find(params[:id]) + render json: @recommended_articles_list + end + + def create + # Actually uspert than a pure create + # We upsert by user_id and placement_area + # In the future we may want to allow multiple lists per user and placement_area, + # and we will use a special param for that. + @recommended_articles_list = RecommendedArticlesList.where( + user_id: permitted_params[:user_id], + placement_area: permitted_params[:placement_area], + ).first_or_initialize + @recommended_articles_list.assign_attributes(permitted_params) + @recommended_articles_list.save! + render json: @recommended_articles_list, status: :created + end + + def update + @recommended_articles_list = RecommendedArticlesList.find(params[:id]) + @recommended_articles_list.update!(permitted_params) + render json: @recommended_articles_list + end + + private + + def require_admin + authorize RecommendedArticlesList, :access?, policy_class: InternalPolicy + end + + def permitted_params + params.permit :name, :placement_area, :expires_at, :user_id, + :article_ids, article_ids: [] + end + end + end +end diff --git a/app/models/articles/feeds.rb b/app/models/articles/feeds.rb index 09d3f5a8b..92cf649f4 100644 --- a/app/models/articles/feeds.rb +++ b/app/models/articles/feeds.rb @@ -355,6 +355,7 @@ module Articles user_required: false, select_fragment: "articles.score", group_by_fragment: "articles.score") + relevancy_lever(:language_match, label: "Weight to give based on whether the language matches any of the user's languages", range: "[0..1]", # 0 for no match, 1 for match @@ -367,7 +368,26 @@ module Articles joins_fragments: ["LEFT OUTER JOIN user_languages ON user_languages.user_id = :user_id"], group_by_fragment: "articles.language") + + relevancy_lever(:recommended_articles_match, + label: "Weight to give based on whether the article is in the first non-expired recommendations", + range: "[0..1]", # 0 for no match, 1 for match + user_required: true, + select_fragment: "CASE + WHEN COUNT(first_matching_list.id) = 0 THEN 0 + WHEN articles.id = ANY(array_agg(first_matching_list.article_ids)) THEN 1 + ELSE 0 + END", + joins_fragments: ["LEFT OUTER JOIN + (SELECT * FROM recommended_articles_lists + WHERE expires_at > CURRENT_TIMESTAMP + AND placement_area = 0 + ORDER BY created_at ASC + LIMIT 1) AS first_matching_list + ON first_matching_list.user_id = :user_id"], + group_by_fragment: "articles.id") end + private_constant :LEVER_CATALOG # rubocop:enable Metrics/BlockLength end diff --git a/app/models/recommended_articles_list.rb b/app/models/recommended_articles_list.rb new file mode 100644 index 000000000..f11215988 --- /dev/null +++ b/app/models/recommended_articles_list.rb @@ -0,0 +1,22 @@ +class RecommendedArticlesList < ApplicationRecord + belongs_to :user + validates :name, presence: true, length: { maximum: 120 } + + enum placement_area: { main_feed: 0 } # Only main feed for now, could be used in Digest, trending, etc. + + scope :active, -> { where("expires_at > ?", Time.current) } + + before_save :set_default_values + + def set_default_values + self.expires_at = 1.day.from_now if expires_at.nil? + end + + # exclude_article_ids is an integer array, web inputs are comma-separated strings + # ActiveRecord normalizes these in a bad way, so we are intervening + def article_ids=(input) + adjusted_input = input.is_a?(String) ? input.split(",") : input + adjusted_input = adjusted_input&.filter_map { |value| value.presence&.to_i } + write_attribute :article_ids, (adjusted_input || []) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 4f25596a1..dac174763 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -100,6 +100,7 @@ class User < ApplicationRecord has_many :profile_pins, as: :profile, inverse_of: :profile, dependent: :delete_all has_many :segmented_users, dependent: :destroy has_many :audience_segments, through: :segmented_users + has_many :recommended_articles_lists, dependent: :destroy # we keep rating votes as they belong to the article, not to the user who viewed it has_many :rating_votes, dependent: :nullify diff --git a/config/routes.rb b/config/routes.rb index b43220d4d..77add8874 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,8 @@ Rails.application.routes.draw do post "/reactions", to: "reactions#create" post "/reactions/toggle", to: "reactions#toggle" + resources :recommended_articles_lists, only: %i[index show create update] + resources :billboards, only: %i[index show create update] do put "unpublish", on: :member end diff --git a/db/migrate/20231121133843_create_recommended_articles_lists.rb b/db/migrate/20231121133843_create_recommended_articles_lists.rb new file mode 100644 index 000000000..a7a8a409e --- /dev/null +++ b/db/migrate/20231121133843_create_recommended_articles_lists.rb @@ -0,0 +1,13 @@ +class CreateRecommendedArticlesLists < ActiveRecord::Migration[7.0] + def change + create_table :recommended_articles_lists do |t| + t.string :name + t.integer :article_ids, array: true, default: [] + t.integer :placement_area, default: 0 + t.datetime :expires_at + t.references :user, null: false, foreign_key: true + t.timestamps + end + end +end + #add_column :display_ads, :exclude_article_ids, :integer, array: true, default: [] diff --git a/db/migrate/20231121140731_add_indexes_for_recommended_articles_list.rb b/db/migrate/20231121140731_add_indexes_for_recommended_articles_list.rb new file mode 100644 index 000000000..75b782fec --- /dev/null +++ b/db/migrate/20231121140731_add_indexes_for_recommended_articles_list.rb @@ -0,0 +1,8 @@ +class AddIndexesForRecommendedArticlesList < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + def change + add_index :recommended_articles_lists, :article_ids, using: :gin, algorithm: :concurrently + add_index :recommended_articles_lists, :placement_area, algorithm: :concurrently + add_index :recommended_articles_lists, :expires_at, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 6be894204..d150cee65 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[7.0].define(version: 2023_11_14_154619) do +ActiveRecord::Schema[7.0].define(version: 2023_11_21_140731) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "ltree" @@ -998,6 +998,20 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_14_154619) do t.index ["user_id", "reactable_id", "reactable_type", "category"], name: "index_reactions_on_user_id_reactable_id_reactable_type_category", unique: true end + create_table "recommended_articles_lists", force: :cascade do |t| + t.integer "article_ids", default: [], array: true + t.datetime "created_at", null: false + t.datetime "expires_at" + t.string "name" + t.integer "placement_area", default: 0 + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["article_ids"], name: "index_recommended_articles_lists_on_article_ids", using: :gin + t.index ["expires_at"], name: "index_recommended_articles_lists_on_expires_at" + t.index ["placement_area"], name: "index_recommended_articles_lists_on_placement_area" + t.index ["user_id"], name: "index_recommended_articles_lists_on_user_id" + end + create_table "response_templates", force: :cascade do |t| t.text "content", null: false t.string "content_type", null: false @@ -1465,6 +1479,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_14_154619) do add_foreign_key "rating_votes", "articles", on_delete: :cascade add_foreign_key "rating_votes", "users", on_delete: :nullify add_foreign_key "reactions", "users", on_delete: :cascade + add_foreign_key "recommended_articles_lists", "users" add_foreign_key "response_templates", "users" add_foreign_key "segmented_users", "audience_segments" add_foreign_key "segmented_users", "users" diff --git a/spec/factories/recommended_articles_lists.rb b/spec/factories/recommended_articles_lists.rb new file mode 100644 index 000000000..b90c2728e --- /dev/null +++ b/spec/factories/recommended_articles_lists.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :recommended_articles_list do + sequence(:name) { |n| "#{Faker::Lorem.sentence}#{n}" } + sequence(:article_ids) { |n| [n, n + 1, n + 2] } + expires_at { 1.day.from_now } + placement_area { "main_feed" } + user + end +end diff --git a/spec/models/recommended_articles_list_spec.rb b/spec/models/recommended_articles_list_spec.rb new file mode 100644 index 000000000..ef4bf89d2 --- /dev/null +++ b/spec/models/recommended_articles_list_spec.rb @@ -0,0 +1,69 @@ +require "rails_helper" + +RSpec.describe RecommendedArticlesList do + # Test associations + describe "associations" do + it { is_expected.to belong_to(:user) } + end + + # Test validations + describe "validations" do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(120) } + end + + # Test scopes + describe ".active" do + let!(:active_list) { create(:recommended_articles_list, expires_at: 2.days.from_now) } + let!(:inactive_list) { create(:recommended_articles_list, expires_at: 2.days.ago) } + + it "includes lists that have not expired" do + expect(described_class.active).to include(active_list) + end + + it "excludes lists that have expired" do + expect(described_class.active).not_to include(inactive_list) + end + end + + # Test callbacks + describe "before_save" do + context "when expires_at is not set" do + let(:user) { create(:user) } + let(:list) { build(:recommended_articles_list, expires_at: nil, user: user) } + + it "sets expires_at to one day from now" do + Timecop.freeze(Time.current) do + list.save! + expect(list.reload.expires_at).to be_within(5.seconds).of(1.day.from_now) + end + end + end + end + + # Test custom methods + describe "#article_ids=" do + let(:list) { build(:recommended_articles_list) } + + context "when input is a comma-separated string" do + it "converts it to an array of integers" do + list.article_ids = "1,2,3" + expect(list.article_ids).to eq([1, 2, 3]) + end + end + + context "when input is an array" do + it "keeps it as an array of integers" do + list.article_ids = [1, 2, 3] + expect(list.article_ids).to eq([1, 2, 3]) + end + end + + context "when input includes nil or empty values" do + it "filters out invalid entries" do + list.article_ids = [1, "", nil, 3] + expect(list.article_ids).to eq([1, 3]) + end + end + end +end diff --git a/spec/requests/api/v1/recommended_articles_lists_spec.rb b/spec/requests/api/v1/recommended_articles_lists_spec.rb new file mode 100644 index 000000000..4bfd7460d --- /dev/null +++ b/spec/requests/api/v1/recommended_articles_lists_spec.rb @@ -0,0 +1,115 @@ +require "rails_helper" + +RSpec.describe "Api::V1::RecommendedArticlesLists" do + let!(:v1_headers) { { "content-type" => "application/json", "Accept" => "application/vnd.forem.api-v1+json" } } + + let(:list_params) do + { + name: "Sample List", + placement_area: "main_feed", + expires_at: 1.week.from_now, + user_id: user.id, + article_ids: [article1.id, article2.id] + } + end + + let(:api_secret) { create(:api_secret) } + let(:user) { api_secret.user } + let(:article1) { create(:article) } + let(:article2) { create(:article) } + let(:auth_header) { v1_headers.merge({ "api-key" => api_secret.secret }) } + + shared_context "when user is authorized" do + before { user.add_role(:admin) } + end + + describe "GET /api/v1/recommended_articles_lists" do + context "when authenticated and authorized" do + include_context "when user is authorized" + + it "returns json response with all recommended article lists" do + get api_recommended_articles_lists_path, headers: auth_header + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + # Expectations regarding the content of the response + end + end + + context "when unauthenticated" do + it "returns unauthorized" do + get api_recommended_articles_lists_path, headers: v1_headers + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe "GET /api/v1/recommended_articles_lists/:id" do + let(:existing_list) { create(:recommended_articles_list, user: user) } + + context "when authenticated and authorized" do + include_context "when user is authorized" + + it "returns json response with the specified recommended article list" do + get api_recommended_articles_list_path(existing_list.id), headers: auth_header + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + end + end + + context "when unauthenticated" do + it "returns unauthorized" do + get api_recommended_articles_list_path(existing_list.id), headers: v1_headers + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe "POST /api/v1/recommended_articles_lists" do + context "when authenticated and authorized" do + include_context "when user is authorized" + + it "creates a new recommended articles list" do + expect do + post api_recommended_articles_lists_path, params: list_params.to_json, headers: auth_header + end.to change(RecommendedArticlesList, :count).by(1) + + expect(response).to have_http_status(:created) + expect(response.media_type).to eq("application/json") + end + end + + context "when unauthenticated" do + it "returns unauthorized" do + post api_recommended_articles_lists_path, params: list_params.to_json, headers: v1_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe "PUT /api/v1/recommended_articles_lists/:id" do + let(:existing_list) { create(:recommended_articles_list, user: user) } + + context "when authenticated and authorized" do + include_context "when user is authorized" + + it "updates an existing recommended articles list" do + user.add_role(:admin) + put api_recommended_articles_list_path(existing_list.id), params: list_params + .merge(name: "Updated List").to_json, headers: auth_header + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq("application/json") + # Assertions for the updated attributes + end + end + + context "when unauthenticated" do + it "returns unauthorized" do + put api_recommended_articles_list_path(existing_list.id), params: list_params.to_json, headers: v1_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end +end