diff --git a/app/controllers/api/v1/bounty_email_subscriptions_controller.rb b/app/controllers/api/v1/bounty_email_subscriptions_controller.rb new file mode 100644 index 000000000..db4b3edfe --- /dev/null +++ b/app/controllers/api/v1/bounty_email_subscriptions_controller.rb @@ -0,0 +1,77 @@ +class Api::V1::BountyEmailSubscriptionsController < ApplicationController + before_action :require_auth + before_action :find_subscription, only: [:show, :update, :destroy] + + # GET /api/user/bounty_email_subscriptions + def index + @subscriptions = @person.bounty_email_subscriptions.order(created_at: :desc) + render json: { + bounty_email_subscriptions: @subscriptions.map { |s| serialize(s) } + } + end + + # POST /api/user/bounty_email_subscriptions + def create + @subscription = @person.bounty_email_subscriptions.build(subscription_params) + if @subscription.save + render json: { bounty_email_subscription: serialize(@subscription) }, status: :created + else + render json: { error: @subscription.errors.full_messages.join(", ") }, status: :unprocessable_entity + end + end + + # GET /api/user/bounty_email_subscriptions/:id + def show + render json: { bounty_email_subscription: serialize(@subscription) } + end + + # PUT/PATCH /api/user/bounty_email_subscriptions/:id + def update + if @subscription.update(subscription_params) + render json: { bounty_email_subscription: serialize(@subscription) } + else + render json: { error: @subscription.errors.full_messages.join(", ") }, status: :unprocessable_entity + end + end + + # DELETE /api/user/bounty_email_subscriptions/:id + def destroy + @subscription.destroy + head :no_content + end + + private + + def find_subscription + @subscription = @person.bounty_email_subscriptions.find(params[:id]) + end + + def subscription_params + allowed = params.permit(:name, :query, :min_amount, :tracker_name, :language, :active) + if params[:form_data].present? + fd = params.require(:form_data).permit(:name, :query, :search, :min_amount, :min_bounty, :tracker_name, :project, :language, :active) + allowed[:name] ||= fd[:name] + allowed[:query] ||= fd[:query].presence || fd[:search] + allowed[:min_amount] ||= fd[:min_amount].presence || fd[:min_bounty] + allowed[:tracker_name] ||= fd[:tracker_name].presence || fd[:project] + allowed[:language] ||= fd[:language] + allowed[:active] = fd[:active] unless fd[:active].nil? + end + allowed + end + + def serialize(s) + { + id: s.id, + name: s.name, + query: s.query, + min_amount: s.min_amount.to_f, + tracker_name: s.tracker_name, + language: s.language, + active: s.active, + last_notified_at: s.last_notified_at, + created_at: s.created_at, + updated_at: s.updated_at + } + end +end diff --git a/app/mailers/mailer.rb b/app/mailers/mailer.rb index 02176b900..10b9a32b3 100644 --- a/app/mailers/mailer.rb +++ b/app/mailers/mailer.rb @@ -118,6 +118,35 @@ def bounty_placed(options) mail(to: @person.email, from: %(Bountysource Alerts ), subject: "Bounty posted for #{@bounty.issue.title} on #{@bounty.issue.tracker.name}") end + # Saved-search email when a new bounty matches subscription criteria (#1141) + def bounty_search_match(options) + @person = options[:person] + @bounty = options[:bounty] + @subscription = options[:subscription] + + @unsubscribe_token = Unsubscribe.object_to_token(@person) + @unsubscribe_category = "bounty_alerts_search_#{@subscription.id}" + + @issue_analytics_params = { + utm_campaign: "alerts", + utm_source: "bounty_search", + utm_medium: "email", + utm_content: "subscription/#{@subscription.id}" + } + + subj_bits = [] + subj_bits << number_to_dollars(@bounty.amount) if @bounty.amount + subj_bits << (@bounty.issue.try(:title) || "new bounty") + mail( + to: @person.email, + from: %(Bountysource Alerts ), + subject: "Bounty match for \"#{@subscription.name}\": #{subj_bits.join(' — ')}" + ) do |format| + format.text + format.html + end + end + def repository_donation_made(options) @person = options[:person] @repo = options[:repo] diff --git a/app/models/bounty.rb b/app/models/bounty.rb index e7d46a497..0e148f1c5 100644 --- a/app/models/bounty.rb +++ b/app/models/bounty.rb @@ -201,6 +201,13 @@ def send_bounty_placed_emails targets -= [person] targets.uniq.each { |person| person.send_email(:bounty_placed, bounty: self) } + + # Email users whose saved-search subscriptions match this bounty (#1141) + delay(priority: 30).notify_bounty_email_subscriptions + end + + def notify_bounty_email_subscriptions + BountyEmailSubscription.notify_matching_for_bounty!(self) end def refundable? diff --git a/app/models/bounty_email_subscription.rb b/app/models/bounty_email_subscription.rb new file mode 100644 index 000000000..d596b72fc --- /dev/null +++ b/app/models/bounty_email_subscription.rb @@ -0,0 +1,92 @@ +# == Schema Information +# +# Table name: bounty_email_subscriptions +# +# id :integer not null, primary key +# person_id :integer not null +# name :string not null +# query :string default(""), not null +# min_amount :decimal(10, 2) default(0.0), not null +# tracker_name :string +# language :string +# active :boolean default(TRUE), not null +# last_notified_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_bounty_email_subscriptions_on_person_id (person_id) +# index_bounty_email_subscriptions_on_person_id_and_active (person_id, active) +# + +# Email alert when a new (or increased) bounty matches saved search criteria. +# Implements https://github.com/bountysource/core/issues/1141 +class BountyEmailSubscription < ApplicationRecord + belongs_to :person + + validates :person, presence: true + validates :name, presence: true, length: { maximum: 120 } + validates :query, length: { maximum: 500 }, allow_blank: true + validates :min_amount, numericality: { greater_than_or_equal_to: 0 } + validates :tracker_name, length: { maximum: 200 }, allow_blank: true + validates :language, length: { maximum: 80 }, allow_blank: true + + scope :active, -> { where(active: true) } + + # Does this bounty/issue match the subscription criteria? + def matches?(bounty) + return false unless bounty && bounty.issue + return false if min_amount.to_f > 0 && bounty.amount.to_f < min_amount.to_f + + issue = bounty.issue + haystack = [ + issue.title, + issue.body.to_s, + (issue.tracker.try(:name) || ""), + (issue.tracker.try(:full_name) || "") + ].join(" ").downcase + + if query.present? + tokens = query.to_s.downcase.split(/\s+/).reject(&:blank?) + return false unless tokens.all? { |t| haystack.include?(t) } + end + + if tracker_name.present? + tname = (issue.tracker.try(:name) || "").downcase + full = (issue.tracker.try(:full_name) || "").downcase + needle = tracker_name.downcase + return false unless tname.include?(needle) || full.include?(needle) + end + + if language.present? + langs = Array(issue.tracker.try(:languages)).map { |l| l.try(:name).to_s.downcase } + return false unless langs.any? { |l| l.include?(language.downcase) } + end + + true + end + + # Notify all matching active subscriptions for a newly placed / increased bounty. + def self.notify_matching_for_bounty!(bounty) + return unless bounty && bounty.issue + return if bounty.person_id.blank? && bounty.amount.to_f <= 0 + + active.includes(:person).find_each do |sub| + person = sub.person + next unless person && person.email.present? + next if person.id == bounty.person_id # don't email the poster + next unless sub.matches?(bounty) + + person.send_email( + :bounty_search_match, + bounty: bounty, + subscription: sub + ) + sub.update_column(:last_notified_at, Time.current) + end + rescue => e + Rails.logger.error("[BountyEmailSubscription] notify failed: #{e.class}: #{e.message}") + NewRelic::Agent.notice_error(e) if defined?(NewRelic) + end +end diff --git a/app/models/person.rb b/app/models/person.rb index b60b05f5d..8012bb3fb 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -63,6 +63,7 @@ class Person < ApplicationRecord has_many :bounty_claim_events has_many :access_tokens has_many :saved_search_tabs + has_many :bounty_email_subscriptions, dependent: :destroy # there is no longer a Github::Commit model -- CAB # has_many :commits, class_name: 'Github::Commit' diff --git a/app/views/mailer/bounty_search_match.html.erb b/app/views/mailer/bounty_search_match.html.erb new file mode 100644 index 000000000..dde9cdc07 --- /dev/null +++ b/app/views/mailer/bounty_search_match.html.erb @@ -0,0 +1,33 @@ +

Hi <%= @person.display_name %>,

+ +

+ A bounty matched your email subscription + “<%= @subscription.name %>”: +

+ + + +

+ "> + View the issue and bounty + +

+ +

+ Manage subscriptions: + "> + settings/email-subscriptions + + <% if @unsubscribe_link %> + · Unsubscribe from this alert + <% end %> +

diff --git a/app/views/mailer/bounty_search_match.text.erb b/app/views/mailer/bounty_search_match.text.erb new file mode 100644 index 000000000..849ba14a6 --- /dev/null +++ b/app/views/mailer/bounty_search_match.text.erb @@ -0,0 +1,18 @@ +Hi <%= @person.display_name %>, + +A bounty matched your email subscription "<%= @subscription.name %>": + + Amount: <%= number_to_dollars(@bounty.amount) %> + Issue: <%= @bounty.issue.title %> + Project: <%= @bounty.issue.tracker.try(:name) %> + Criteria: query=<%= @subscription.query.presence || '(any)' %><% if @subscription.min_amount.to_f > 0 %>; min=<%= number_to_dollars(@subscription.min_amount) %><% end %> + +View and claim it: +<%= "#{Api::Application.config.www_url}issues/#{@bounty.issue.to_param}" %> + +Manage subscriptions: +<%= "#{Api::Application.config.www_url}settings/email-subscriptions" %> + +<% if @unsubscribe_link %> +Unsubscribe from this alert: <%= @unsubscribe_link %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 780d7ee9a..14b9d9fcb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -286,6 +286,10 @@ match '/tabs', controller: 'saved_search_tabs', action: 'index', via: :get + # Bounty email search subscriptions (#1141) + resources :bounty_email_subscriptions, only: [:index, :show, :create, :update, :destroy], + path: 'user/bounty_email_subscriptions' + resources :languages, only: [:index] resources :project_relations, only: [:index, :show], controller: 'tracker_relations' diff --git a/db/migrate/20260710160000_create_bounty_email_subscriptions.rb b/db/migrate/20260710160000_create_bounty_email_subscriptions.rb new file mode 100644 index 000000000..a4b80d92d --- /dev/null +++ b/db/migrate/20260710160000_create_bounty_email_subscriptions.rb @@ -0,0 +1,18 @@ +class CreateBountyEmailSubscriptions < ActiveRecord::Migration[5.0] + def change + create_table :bounty_email_subscriptions do |t| + t.integer :person_id, null: false + t.string :name, null: false + t.string :query, null: false, default: "" + t.decimal :min_amount, precision: 10, scale: 2, default: 0, null: false + t.string :tracker_name + t.string :language + t.boolean :active, default: true, null: false + t.datetime :last_notified_at + t.timestamps null: false + end + + add_index :bounty_email_subscriptions, :person_id + add_index :bounty_email_subscriptions, [:person_id, :active] + end +end diff --git a/spec/models/bounty_email_subscription_spec.rb b/spec/models/bounty_email_subscription_spec.rb new file mode 100644 index 000000000..44623cd09 --- /dev/null +++ b/spec/models/bounty_email_subscription_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +RSpec.describe BountyEmailSubscription, type: :model do + describe '#matches?' do + let(:person) { double('Person', id: 1, email: 'a@b.com') } + let(:tracker) { double('Tracker', name: 'rails/rails', full_name: 'rails/rails', languages: []) } + let(:issue) do + double( + 'Issue', + title: 'Fix memory leak in ActionCable', + body: 'Users report high memory use with websocket', + tracker: tracker + ) + end + let(:bounty) { double('Bounty', amount: 100, issue: issue, person_id: 99) } + + it 'matches when query tokens appear in title/body' do + sub = described_class.new(person_id: 1, name: 'rails mem', query: 'memory actioncable', min_amount: 0) + expect(sub.matches?(bounty)).to eq(true) + end + + it 'rejects when min_amount not met' do + sub = described_class.new(person_id: 1, name: 'big', query: 'memory', min_amount: 500) + expect(sub.matches?(bounty)).to eq(false) + end + + it 'matches min_amount when bounty is large enough' do + sub = described_class.new(person_id: 1, name: 'ok', query: 'memory', min_amount: 50) + expect(sub.matches?(bounty)).to eq(true) + end + + it 'filters by tracker_name' do + sub = described_class.new(person_id: 1, name: 'trk', query: '', min_amount: 0, tracker_name: 'rails') + expect(sub.matches?(bounty)).to eq(true) + sub.tracker_name = 'django' + expect(sub.matches?(bounty)).to eq(false) + end + + it 'returns false without issue' do + sub = described_class.new(person_id: 1, name: 'x', query: 'x', min_amount: 0) + expect(sub.matches?(double(issue: nil, amount: 10))).to eq(false) + end + end +end