Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions app/controllers/api/v1/bounty_email_subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions app/mailers/mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,35 @@ def bounty_placed(options)
mail(to: @person.email, from: %(Bountysource Alerts <alerts@bountysource.com>), 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 <alerts@bountysource.com>),
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]
Expand Down
7 changes: 7 additions & 0 deletions app/models/bounty.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
92 changes: 92 additions & 0 deletions app/models/bounty_email_subscription.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
33 changes: 33 additions & 0 deletions app/views/mailer/bounty_search_match.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<p>Hi <%= @person.display_name %>,</p>

<p>
A bounty matched your email subscription
<strong>&ldquo;<%= @subscription.name %>&rdquo;</strong>:
</p>

<ul>
<li><strong>Amount:</strong> <%= number_to_dollars(@bounty.amount) %></li>
<li><strong>Issue:</strong> <%= @bounty.issue.title %></li>
<li><strong>Project:</strong> <%= @bounty.issue.tracker.try(:name) %></li>
<li>
<strong>Criteria:</strong>
query=<%= @subscription.query.presence || '(any)' %><% if @subscription.min_amount.to_f > 0 %>;
min=<%= number_to_dollars(@subscription.min_amount) %><% end %>
</li>
</ul>

<p>
<a href="<%= "#{Api::Application.config.www_url}issues/#{@bounty.issue.to_param}" %>">
View the issue and bounty
</a>
</p>

<p style="color:#666;font-size:12px;">
Manage subscriptions:
<a href="<%= "#{Api::Application.config.www_url}settings/email-subscriptions" %>">
settings/email-subscriptions
</a>
<% if @unsubscribe_link %>
&middot; <a href="<%= @unsubscribe_link %>">Unsubscribe from this alert</a>
<% end %>
</p>
18 changes: 18 additions & 0 deletions app/views/mailer/bounty_search_match.text.erb
Original file line number Diff line number Diff line change
@@ -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 %>
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
18 changes: 18 additions & 0 deletions db/migrate/20260710160000_create_bounty_email_subscriptions.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions spec/models/bounty_email_subscription_spec.rb
Original file line number Diff line number Diff line change
@@ -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