2019-07-30 21:29:24 +00:00
# frozen_string_literal: true
2020-06-12 20:03:43 +00:00
# License: AGPL-3.0-or-later WITH WTO-AP-3.0-or-later
# Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE
2018-03-25 17:30:42 +00:00
require 'qexpr'
require 'psql'
require 'email'
require 'format/currency'
require 'format/csv'
module QuerySupporters
# Query supporters and their donations and gift levels for a campaign
def self . campaign_list_expr ( np_id , campaign_id , query )
expr = Qexpr . new . from ( 'supporters' )
2019-07-30 21:29:24 +00:00
. left_outer_join ( 'donations' , 'donations.supporter_id=supporters.id' )
. left_outer_join ( 'campaign_gifts' , 'donations.id=campaign_gifts.donation_id' )
. left_outer_join ( 'campaign_gift_options' , 'campaign_gifts.campaign_gift_option_id=campaign_gift_options.id' )
. join_lateral ( :payments , Qx
2018-12-04 22:40:48 +00:00
. select ( 'payments.id, payments.gross_amount' ) . from ( :payments )
. where ( 'payments.donation_id = donations.id' )
. order_by ( 'payments.created_at ASC' )
. limit ( 1 ) . parse , true )
2019-07-30 21:29:24 +00:00
. join ( Qx . select ( 'id, profile_id' ) . from ( 'campaigns' )
2019-01-22 21:06:49 +00:00
. where ( " id IN ( #{ QueryCampaigns
. get_campaign_and_children ( campaign_id )
. parse } ) " ).as('campaigns').parse,
2019-07-30 21:29:24 +00:00
'donations.campaign_id=campaigns.id' )
. join ( Qx . select ( 'users.id, profiles.id AS profiles_id, users.email' )
2019-01-22 21:06:49 +00:00
. from ( 'users' )
. add_join ( 'profiles' , 'profiles.user_id = users.id' )
2019-07-30 21:29:24 +00:00
. as ( 'users' ) . parse , 'users.profiles_id=campaigns.profile_id' )
. where ( 'supporters.nonprofit_id=$id' , id : np_id )
. group_by ( 'supporters.id' )
. order_by ( 'MAX(donations.date) DESC' )
2018-03-25 17:30:42 +00:00
2019-07-30 21:29:24 +00:00
if query [ :search ] . present?
expr = expr . where ( %(
2018-03-25 17:30:42 +00:00
supporters . name ILIKE $search
OR supporters . email ILIKE $search
OR campaign_gift_options . name ILIKE $search
) , search : '%' + query [ :search ] + '%' )
end
2019-07-30 21:29:24 +00:00
expr
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
# Used in the campaign donor listing
def self . campaign_list ( np_id , campaign_id , query )
2018-03-25 17:30:42 +00:00
limit = 50
offset = Qexpr . page_offset ( limit , query [ :page ] )
data = Psql . execute (
campaign_list_expr ( np_id , campaign_id , query ) . select (
'supporters.id' ,
'supporters.name' ,
'supporters.email' ,
2018-12-04 22:40:48 +00:00
'SUM(payments.gross_amount) AS total_raised' ,
2018-03-25 17:30:42 +00:00
'ARRAY_AGG(DISTINCT campaign_gift_options.name) AS campaign_gift_names' ,
'DATE(MAX(donations.created_at)) AS latest_gift' ,
2019-01-22 21:06:49 +00:00
'ARRAY_AGG(DISTINCT users.email) AS campaign_creator_emails'
2018-03-25 17:30:42 +00:00
) . limit ( limit ) . offset ( offset )
)
total_count = Psql . execute (
2019-07-30 21:29:24 +00:00
Qexpr . new . select ( 'COUNT(s)' )
2018-03-25 17:30:42 +00:00
. from ( campaign_list_expr ( np_id , campaign_id , query ) . remove ( :order_by ) . select ( 'supporters.id' ) . as ( 's' ) . parse )
) . first [ 'count' ]
2019-07-30 21:29:24 +00:00
{
data : data ,
total_count : total_count ,
remaining : Qexpr . remaining_count ( total_count , limit , query [ :page ] )
2018-03-25 17:30:42 +00:00
}
2019-07-30 21:29:24 +00:00
end
2018-03-25 17:30:42 +00:00
def self . full_search_metrics ( np_id , query )
total_count = full_filter_expr ( np_id , query )
2019-07-30 21:29:24 +00:00
. select ( 'COUNT(supporters)' )
. remove_clause ( :order_by )
. execute . first [ 'count' ]
2018-03-25 17:30:42 +00:00
2019-07-30 21:29:24 +00:00
{
2018-03-25 17:30:42 +00:00
total_count : total_count ,
remaining_count : Qexpr . remaining_count ( total_count , 30 , query [ :page ] )
}
end
# Full supporter search mainly for /nonprofits/id/supporters dashboard
def self . full_search ( np_id , query )
select = [
'supporters.name' ,
'supporters.email' ,
'supporters.is_unsubscribed_from_emails' ,
'supporters.id AS id' ,
'tags.names AS tags' ,
2021-05-21 20:27:37 +00:00
' to_char (
timezone (
COALESCE ( nonprofits . timezone , \ 'UTC\' ) ,
timezone ( \ 'UTC\' , payments . max_date )
) ,
\ 'MM/DD/YY\'
) AS last_contribution ' ,
2018-03-25 17:30:42 +00:00
'payments.sum AS total_raised'
]
2019-07-30 21:29:24 +00:00
select += query [ :select ] . split ( ',' ) if query [ :select ]
2018-03-25 17:30:42 +00:00
supps = full_filter_expr ( np_id , query )
2019-07-30 21:29:24 +00:00
. select ( * select )
. paginate ( query [ :page ] . to_i , 30 )
. execute
2018-03-25 17:30:42 +00:00
2019-07-30 21:29:24 +00:00
{ data : supps }
2018-03-25 17:30:42 +00:00
end
def self . _full_search ( np_id , query )
select = [
2019-07-30 21:29:24 +00:00
'supporters.name' ,
'supporters.email' ,
'supporters.is_unsubscribed_from_emails' ,
'supporters.id AS id' ,
'tags.names AS tags' ,
" to_char(payments.max_date, 'MM/DD/YY') AS last_contribution " ,
'payments.sum AS total_raised'
2018-03-25 17:30:42 +00:00
]
2019-07-30 21:29:24 +00:00
select += query [ :select ] . split ( ',' ) if query [ :select ]
2018-03-25 17:30:42 +00:00
supps = full_filter_expr ( np_id , query )
2019-07-30 21:29:24 +00:00
. select ( * select )
. paginate ( query [ :page ] . to_i , query [ :page_length ] . to_i )
. execute
2018-03-25 17:30:42 +00:00
2019-07-30 21:29:24 +00:00
{ data : supps }
2018-03-25 17:30:42 +00:00
end
2018-04-16 20:11:25 +00:00
# # Given a list of supporters, you may want to remove duplicates from those supporters.
# # @param [Enumerable[Supporter]] supporters
# def self._remove_dupes_on_a_list_of_supporters(supporters, np_id)
#
# new_supporters =supporters.clone.to_a
#
# QuerySupporters.dupes_on_name_and_email(np_id).each{|duplicates|
# matched_in_group = false
# duplicates.each{|i|
# supporter = new_supporters.find{|s| s.id == i}
# if (supporter)
# if (matched_in_group)
# new_supporters.delete(supporter)
# else
# matched_in_group = true
# end
# end
# }
#
# }
#
# return new_supporters
# end
2018-03-25 17:30:42 +00:00
# Perform all filters and search for /nonprofits/id/supporters dashboard and export
def self . full_filter_expr ( np_id , query )
2018-06-25 18:49:26 +00:00
payments_subquery =
2019-07-30 21:29:24 +00:00
Qx . select ( 'supporter_id' , 'SUM(gross_amount)' , 'MAX(date) AS max_date' , 'MIN(date) AS min_date' , 'COUNT(*) AS count' )
. from (
Qx . select ( 'supporter_id' , 'date' , 'gross_amount' )
. from ( :payments )
. join ( Qx . select ( 'id' )
. from ( :supporters )
. where ( " supporters.nonprofit_id = $id and deleted != 'true' " , id : np_id )
. as ( 'payments_to_supporters' ) , 'payments_to_supporters.id = payments.supporter_id' )
. as ( 'outer_from_payment_to_supporter' )
. parse
)
. group_by ( :supporter_id )
. as ( :payments )
tags_subquery = Qx . select ( 'tag_joins.supporter_id' , 'ARRAY_AGG(tag_masters.id) AS ids' , 'ARRAY_AGG(tag_masters.name::text) AS names' )
. from ( :tag_joins )
. join ( :tag_masters , 'tag_masters.id=tag_joins.tag_master_id' )
. where ( 'tag_masters.deleted IS NULL' )
. group_by ( 'tag_joins.supporter_id' )
. as ( :tags )
2018-03-25 17:30:42 +00:00
2021-05-21 20:27:37 +00:00
expr = Qx . select ( 'supporters.id' )
. from ( :supporters )
. join ( 'nonprofits' , 'nonprofits.id=supporters.nonprofit_id' )
. where (
[ 'supporters.nonprofit_id=$id' , id : np_id . to_i ] ,
[ 'supporters.deleted != true' ]
)
. left_join (
[ tags_subquery , 'tags.supporter_id=supporters.id' ] ,
[ payments_subquery , 'payments.supporter_id=supporters.id' ]
)
. order_by ( 'payments.max_date DESC NULLS LAST' )
2018-03-25 17:30:42 +00:00
if query [ :last_payment_after ] . present?
2021-05-21 20:27:37 +00:00
expr = expr . and_where ( " payments.max_date > timezone(COALESCE(nonprofits.timezone, \' UTC \' ), timezone( \' UTC \' , $d)) " , d : Chronic . parse ( query [ :last_payment_after ] ) . beginning_of_day )
2018-03-25 17:30:42 +00:00
end
if query [ :last_payment_before ] . present?
2021-05-21 20:27:37 +00:00
expr = expr . and_where ( " payments.max_date < timezone(COALESCE(nonprofits.timezone, \' UTC \' ), timezone( \' UTC \' , $d)) " , d : Chronic . parse ( query [ :last_payment_before ] ) . beginning_of_day )
2018-03-25 17:30:42 +00:00
end
if query [ :first_payment_after ] . present?
2021-05-21 20:27:37 +00:00
expr = expr . and_where ( " payments.min_date > timezone(COALESCE(nonprofits.timezone, \' UTC \' ), timezone( \' UTC \' , $d)) " , d : Chronic . parse ( query [ :first_payment_after ] ) . beginning_of_day )
2018-03-25 17:30:42 +00:00
end
if query [ :first_payment_before ] . present?
2021-05-21 20:27:37 +00:00
expr = expr . and_where ( " payments.min_date < timezone(COALESCE(nonprofits.timezone, \' UTC \' ), timezone( \' UTC \' , $d)) " , d : Chronic . parse ( query [ :first_payment_before ] ) . beginning_of_day )
2018-03-25 17:30:42 +00:00
end
if query [ :total_raised_greater_than ] . present?
2019-07-30 21:29:24 +00:00
expr = expr . and_where ( 'payments.sum > $amount' , amount : query [ :total_raised_greater_than ] . to_i * 100 )
2018-03-25 17:30:42 +00:00
end
if query [ :total_raised_less_than ] . present?
2019-07-30 21:29:24 +00:00
expr = expr . and_where ( 'payments.sum < $amount OR payments.supporter_id IS NULL' , amount : query [ :total_raised_less_than ] . to_i * 100 )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
if %w[ week month quarter year ] . include? query [ :has_contributed_during ]
2018-03-25 17:30:42 +00:00
d = Time . current . send ( 'beginning_of_' + query [ :has_contributed_during ] )
2021-05-21 20:27:37 +00:00
expr = expr . and_where ( " payments.max_date >= timezone(COALESCE(nonprofits.timezone, \' UTC \' ), timezone( \' UTC \' , $d)) " , d : d )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
if %w[ week month quarter year ] . include? query [ :has_not_contributed_during ]
2018-03-25 17:30:42 +00:00
d = Time . current . send ( 'beginning_of_' + query [ :has_not_contributed_during ] )
2021-05-21 20:27:37 +00:00
expr = expr . and_where ( " payments.count = 0 OR payments.max_date <= timezone(COALESCE(nonprofits.timezone, \' UTC \' ), timezone( \' UTC \' , $d)) " , d : d )
2018-03-25 17:30:42 +00:00
end
if query [ :MAX_payment_before ] . present?
date_ago = Timespan :: TimeUnits [ query [ :MAX_payment_before ] ] . utc
2021-05-21 20:27:37 +00:00
expr = expr . and_where ( " payments.max_date < timezone(COALESCE(nonprofits.timezone, \' UTC \' ), timezone( \' UTC \' , $date)) OR payments.count = 0 " , date : date_ago )
2018-03-25 17:30:42 +00:00
end
if query [ :search ] . present?
2019-07-30 21:29:24 +00:00
expr = expr . and_where ( %(
2018-03-25 17:30:42 +00:00
supporters . name ILIKE $search
OR supporters . email ILIKE $search
OR supporters . organization ILIKE $search
) , search : '%' + query [ :search ] + '%' )
end
if query [ :notes ] . present?
notes_subquery = Qx . select ( " STRING_AGG(content, ' ') as content, supporter_id " )
2019-07-30 21:29:24 +00:00
. from ( :supporter_notes )
. group_by ( :supporter_id )
. as ( :notes )
expr = expr . add_left_join ( notes_subquery , 'notes.supporter_id=supporters.id' )
. and_where ( " to_tsvector('english', notes.content) @@ plainto_tsquery('english', $notes) " , notes : query [ :notes ] )
2018-03-25 17:30:42 +00:00
end
if query [ :custom_fields ] . present?
2019-07-30 21:29:24 +00:00
c_f_subquery = Qx . select ( " STRING_AGG(value, ' ') as value " , 'supporter_id' )
. from ( :custom_field_joins )
. group_by ( 'custom_field_joins.supporter_id' )
. as ( :custom_fields )
expr = expr . add_left_join ( c_f_subquery , 'custom_fields.supporter_id=supporters.id' )
. and_where ( " to_tsvector('english', custom_fields.value) @@ plainto_tsquery('english', $custom_fields) " , custom_fields : query [ :custom_fields ] )
2018-03-25 17:30:42 +00:00
end
if query [ :location ] . present?
2019-07-30 21:29:24 +00:00
expr = expr . and_where ( 'lower(supporters.city) LIKE $city OR lower(supporters.zip_code) LIKE $zip' , city : query [ :location ] . downcase , zip : query [ :location ] . downcase )
2018-03-25 17:30:42 +00:00
end
if query [ :recurring ] . present?
2019-07-30 21:29:24 +00:00
rec_ps_subquery = Qx . select ( 'payments.count' , 'payments.supporter_id' )
. from ( :payments )
. where ( " kind='RecurringDonation' " )
. group_by ( 'payments.supporter_id' )
. as ( :rec_ps )
expr = expr . add_left_join ( rec_ps_subquery , 'rec_ps.supporter_id=supporters.id' )
. and_where ( 'rec_ps.count > 0' )
2018-03-25 17:30:42 +00:00
end
if query [ :ids ] . present?
2019-07-30 21:29:24 +00:00
expr = expr . and_where ( 'supporters.id IN ($ids)' , ids : query [ :ids ] . split ( ',' ) . map ( & :to_i ) )
2018-03-25 17:30:42 +00:00
end
if query [ :select ] . present?
2019-07-30 21:29:24 +00:00
expr = expr . select ( * query [ :select ] . split ( ',' ) . map { | x | Qx . quote_ident ( x ) } )
2018-03-25 17:30:42 +00:00
end
# Sort by supporters who have all of the list of tag names
if query [ :tags ] . present?
tag_ids = ( query [ :tags ] . is_a? ( String ) ? query [ :tags ] . split ( ',' ) : query [ :tags ] ) . map ( & :to_i )
2019-07-30 21:29:24 +00:00
expr = expr . and_where ( 'tags.ids @> ARRAY[$tag_ids]' , tag_ids : tag_ids )
2018-03-25 17:30:42 +00:00
end
if query [ :campaign_id ] . present?
2019-07-30 21:29:24 +00:00
expr = expr . add_join ( 'donations' , " donations.supporter_id=supporters.id AND donations.campaign_id IN ( #{ QueryCampaigns
2018-11-20 22:17:57 +00:00
. get_campaign_and_children ( query [ :campaign_id ] . to_i )
. parse } ) " )
2018-03-25 17:30:42 +00:00
end
2018-06-22 20:30:39 +00:00
2018-03-25 17:30:42 +00:00
if query [ :event_id ] . present?
2019-07-30 21:29:24 +00:00
select_tickets_supporters = Qx . select ( 'event_ticket_supporters.supporter_id' )
. from (
Qx . select ( 'MAX(tickets.event_id) AS event_id' , 'tickets.supporter_id' )
. from ( :tickets )
. where ( 'event_id = $event_id' , event_id : query [ :event_id ] )
. group_by ( :supporter_id ) . as ( 'event_ticket_supporters' ) . parse . to_s
)
2018-06-22 20:30:39 +00:00
select_donation_supporters =
2019-07-30 21:29:24 +00:00
Qx . select ( 'event_donation_supporters.supporter_id' )
. from (
Qx . select ( 'MAX(donations.event_id) AS event_id' , 'donations.supporter_id' )
. from ( :donations )
. where ( 'event_id = $event_id' , event_id : query [ :event_id ] )
. group_by ( :supporter_id ) . as ( 'event_donation_supporters' ) . parse . to_s
)
2018-06-22 20:30:39 +00:00
union_expr = " (
#{select_tickets_supporters.parse}
UNION DISTINCT
#{select_donation_supporters.parse}
) AS event_supporters "
2018-03-25 17:30:42 +00:00
expr = expr
2019-07-30 21:29:24 +00:00
. add_join (
union_expr ,
'event_supporters.supporter_id=supporters.id'
)
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
if %w[ asc desc ] . include? query [ :sort_name ]
expr = expr . order_by ( [ 'supporters.name' , query [ :sort_name ] ] )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
if %w[ asc desc ] . include? query [ :sort_contributed ]
expr = expr . and_where ( 'payments.sum > 0' ) . order_by ( [ 'payments.sum' , query [ :sort_contributed ] ] )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
if %w[ asc desc ] . include? query [ :sort_last_payment ]
expr = expr . order_by ( [ 'payments.max_date' , " #{ query [ :sort_last_payment ] . upcase } NULLS LAST " ] )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
expr
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
def self . for_export_enumerable ( npo_id , query , chunk_limit = 35_000 )
ParamValidation . new ( { npo_id : npo_id , query : query } , npo_id : { required : true , is_int : true } ,
query : { required : true , is_hash : true } )
2018-05-07 21:05:27 +00:00
2019-07-30 21:29:24 +00:00
QxQueryChunker . for_export_enumerable ( chunk_limit ) do | offset , limit , skip_header |
2018-05-07 21:05:27 +00:00
get_chunk_of_export ( npo_id , query , offset , limit , skip_header )
end
end
2019-07-30 21:29:24 +00:00
def self . get_chunk_of_export ( np_id , query , offset = nil , limit = nil , skip_header = false )
QxQueryChunker . get_chunk_of_query ( offset , limit , skip_header ) do
2018-05-07 21:05:27 +00:00
expr = full_filter_expr ( np_id , query )
selects = supporter_export_selections . concat ( [
2019-07-30 21:29:24 +00:00
'(payments.sum / 100)::money::text AS total_contributed' ,
'supporters.id AS id'
2018-05-07 21:05:27 +00:00
] )
if query [ :export_custom_fields ]
# Add a select/csv-column for every custom field master for this nonprofit
# and add a left join for every custom field master
# eg if the npo has a custom field like Employer with id 99, then the query will be
# SELECT export_cfj_Employer.value AS Employer, ...
# FROM supporters
# LEFT JOIN custom_field_joins AS export_cfj_Employer ON export_cfj_Employer.supporter_id=supporters.id AND export_cfj_Employer.custom_field_master_id=99
# ...
ids = query [ :export_custom_fields ] . split ( ',' ) . map ( & :to_i )
if ids . any?
2019-07-30 21:29:24 +00:00
cfms = Qx . select ( 'name' , 'id' ) . from ( :custom_field_masters ) . where ( nonprofit_id : np_id ) . and_where ( 'id IN ($ids)' , ids : ids ) . ex
2018-05-07 21:05:27 +00:00
cfms . compact . map do | cfm |
2019-07-30 21:29:24 +00:00
table_alias = " cfjs_ #{ cfm [ 'name' ] . delete ( '$' ) } "
2018-05-07 21:05:27 +00:00
table_alias_quot = " \" #{ table_alias } \" "
2019-07-30 21:29:24 +00:00
field_join_subq = Qx . select ( " STRING_AGG(value, ',') as value " , 'supporter_id' )
. from ( 'custom_field_joins' )
. join ( 'custom_field_masters' , 'custom_field_masters.id=custom_field_joins.custom_field_master_id' )
. where ( 'custom_field_masters.id=$id' , id : cfm [ 'id' ] )
. group_by ( :supporter_id )
. as ( table_alias )
2018-05-07 21:05:27 +00:00
expr . add_left_join ( field_join_subq , " #{ table_alias_quot } .supporter_id=supporters.id " )
selects = selects . concat ( [ " #{ table_alias_quot } .value AS \" #{ cfm [ 'name' ] } \" " ] )
end
end
end
2019-07-30 21:29:24 +00:00
get_last_payment_query = Qx . select ( 'supporter_id' , 'MAX(date) AS date' )
. from ( :payments )
. group_by ( 'supporter_id' )
. as ( 'last_payment' )
2018-05-07 21:05:27 +00:00
expr . add_left_join ( get_last_payment_query , 'last_payment.supporter_id = supporters.id' )
selects = selects . concat ( [ 'last_payment.date as "Last Payment Received"' ] )
2019-07-30 21:29:24 +00:00
supporter_note_query = Qx . select ( " STRING_AGG(supporter_notes.created_at || ': ' || supporter_notes.content, ' \r \n ' ORDER BY supporter_notes.created_at DESC) as notes " , 'supporter_notes.supporter_id' )
. from ( :supporter_notes )
. group_by ( 'supporter_notes.supporter_id' )
. as ( 'supporter_note_query' )
2018-05-07 21:05:27 +00:00
expr . add_left_join ( supporter_note_query , 'supporter_note_query.supporter_id=supporters.id' )
2019-07-30 21:29:24 +00:00
selects = selects . concat ( [ 'supporter_note_query.notes AS notes' ] ) . concat ( [ " ARRAY_TO_STRING(tags.names, ',') as tags " ] )
2018-05-07 21:05:27 +00:00
expr . select ( selects )
end
end
2019-07-30 21:29:24 +00:00
def self . supporter_note_export_enumerable ( npo_id , query , chunk_limit = 35_000 )
ParamValidation . new ( { npo_id : npo_id , query : query } , npo_id : { required : true , is_int : true } ,
query : { required : true , is_hash : true } )
2019-03-20 19:36:01 +00:00
2019-07-30 21:29:24 +00:00
QxQueryChunker . for_export_enumerable ( chunk_limit ) do | offset , limit , skip_header |
2019-03-20 19:36:01 +00:00
get_chunk_of_supporter_note_export ( npo_id , query , offset , limit , skip_header )
end
end
2018-05-07 21:05:27 +00:00
2019-07-30 21:29:24 +00:00
def self . get_chunk_of_supporter_note_export ( np_id , query , offset = nil , limit = nil , skip_header = false )
QxQueryChunker . get_chunk_of_query ( offset , limit , skip_header ) do
2019-03-20 19:36:01 +00:00
expr = full_filter_expr ( np_id , query )
supporter_note_select = [
'supporters.id' ,
'supporters.email' ,
'supporter_notes.created_at as "Note Created At"' ,
'supporter_notes.content "Note Contents"'
]
expr . add_join ( :supporter_notes , 'supporter_notes.supporter_id = supporters.id' )
expr . select ( supporter_note_select )
end
end
2018-05-07 21:05:27 +00:00
2018-03-25 17:30:42 +00:00
# Give supp data for csv
def self . for_export ( np_id , query )
expr = full_filter_expr ( np_id , query )
selects = supporter_export_selections . concat ( [
2019-07-30 21:29:24 +00:00
'(payments.sum / 100)::money::text AS total_contributed' ,
'supporters.id AS id'
] )
2018-03-25 17:30:42 +00:00
if query [ :export_custom_fields ]
# Add a select/csv-column for every custom field master for this nonprofit
# and add a left join for every custom field master
2019-07-30 21:29:24 +00:00
# eg if the npo has a custom field like Employer with id 99, then the query will be
# SELECT export_cfj_Employer.value AS Employer, ...
# FROM supporters
2018-03-25 17:30:42 +00:00
# LEFT JOIN custom_field_joins AS export_cfj_Employer ON export_cfj_Employer.supporter_id=supporters.id AND export_cfj_Employer.custom_field_master_id=99
# ...
ids = query [ :export_custom_fields ] . split ( ',' ) . map ( & :to_i )
if ids . any?
2019-07-30 21:29:24 +00:00
cfms = Qx . select ( 'name' , 'id' ) . from ( :custom_field_masters ) . where ( nonprofit_id : np_id ) . and_where ( 'id IN ($ids)' , ids : ids ) . ex
2018-03-25 17:30:42 +00:00
cfms . compact . map do | cfm |
2019-07-30 21:29:24 +00:00
table_alias = " cfjs_ #{ cfm [ 'name' ] . delete ( '$' ) } "
2018-03-25 17:30:42 +00:00
table_alias_quot = " \" #{ table_alias } \" "
2019-07-30 21:29:24 +00:00
field_join_subq = Qx . select ( " STRING_AGG(value, ',') as value " , 'supporter_id' )
. from ( 'custom_field_joins' )
. join ( 'custom_field_masters' , 'custom_field_masters.id=custom_field_joins.custom_field_master_id' )
. where ( 'custom_field_masters.id=$id' , id : cfm [ 'id' ] )
. group_by ( :supporter_id )
. as ( table_alias )
2018-03-25 17:30:42 +00:00
expr . add_left_join ( field_join_subq , " #{ table_alias_quot } .supporter_id=supporters.id " )
selects = selects . concat ( [ " #{ table_alias_quot } .value AS \" #{ cfm [ 'name' ] } \" " ] )
end
end
end
2019-07-30 21:29:24 +00:00
supporter_note_query = Qx . select ( " STRING_AGG(supporter_notes.created_at || ': ' || supporter_notes.content, ' \r \n ' ORDER BY supporter_notes.created_at DESC) as notes " , 'supporter_notes.supporter_id' )
. from ( :supporter_notes )
. group_by ( 'supporter_notes.supporter_id' )
. as ( 'supporter_note_query' )
2018-03-25 17:30:42 +00:00
expr . add_left_join ( supporter_note_query , 'supporter_note_query.supporter_id=supporters.id' )
2019-07-30 21:29:24 +00:00
selects = selects . concat ( [ 'supporter_note_query.notes AS notes' ] )
2018-03-25 17:30:42 +00:00
expr . select ( selects ) . execute ( format : 'csv' )
end
def self . supporter_export_selections
[
2019-01-30 20:14:11 +00:00
" substring(trim(both from supporters.name) from '^.+ ([^ \s ]+)$') AS \" Last Name \" " ,
" substring(trim(both from supporters.name) from '^(.+) [^ \s ]+$') AS \" First Name \" " ,
2019-07-30 21:29:24 +00:00
'trim(both from supporters.name) AS "Full Name"' ,
'supporters.organization AS "Organization"' ,
'supporters.email "Email"' ,
'supporters.phone "Phone"' ,
'supporters.address "Address"' ,
'supporters.city "City"' ,
'supporters.state_code "State"' ,
'supporters.zip_code "Postal Code"' ,
'supporters.country "Country"' ,
'supporters.anonymous "Anonymous?"' ,
'supporters.id "Supporter ID"'
2018-03-25 17:30:42 +00:00
]
end
# Return an array of groups of ids, where sub-array is a group of duplicates
# Partial sql expression
def self . dupes_expr ( np_id )
2019-07-30 21:29:24 +00:00
Qx . select ( 'ARRAY_AGG(id) AS ids' )
2018-03-25 17:30:42 +00:00
. from ( :supporters )
2019-07-30 21:29:24 +00:00
. where ( 'nonprofit_id=$id' , id : np_id )
2018-03-25 17:30:42 +00:00
. and_where ( " deleted='f' OR deleted IS NULL " )
. having ( 'COUNT(id) > 1' )
end
# Merge on exact supporter and email match
# Find all duplicate supporters by the email column
# returns array of arrays of ids
# (each sub-array is a group of duplicates)
def self . dupes_on_email ( np_id )
dupes_expr ( np_id )
2019-07-30 21:29:24 +00:00
. and_where ( 'email IS NOT NULL' )
2018-03-25 17:30:42 +00:00
. and_where ( " email != '' " )
. group_by ( :email )
. execute ( format : 'csv' ) [ 1 .. - 1 ]
. map ( & :flatten )
end
# Find all duplicate supporters by the name column
def self . dupes_on_name ( np_id )
dupes_expr ( np_id )
2019-07-30 21:29:24 +00:00
. and_where ( 'name IS NOT NULL' )
2018-03-25 17:30:42 +00:00
. group_by ( :name )
. execute ( format : 'csv' ) [ 1 .. - 1 ]
. map ( & :flatten )
end
# Find all duplicate supporters that match on both name/email
# @return [Array[Array]] an array containing arrays of the ids of duplicate supporters
def self . dupes_on_name_and_email ( np_id )
dupes_expr ( np_id )
. and_where ( " name IS NOT NULL AND email IS NOT NULL AND email != '' " )
2019-07-30 21:29:24 +00:00
. group_by ( 'name, email' )
2018-03-25 17:30:42 +00:00
. execute ( format : 'csv' ) [ 1 .. - 1 ]
. map ( & :flatten )
end
# Create an export that lists donors with their total contributed amounts
# Underneath each donor, we separately list each individual payment
# Only including payments for the given year
def self . end_of_year_donor_report ( np_id , year )
supporter_expr = Qexpr . new
2019-07-30 21:29:24 +00:00
. select ( supporter_export_selections . concat ( [ " (payments.sum / 100.0)::money::text AS \" Total Contributions #{ year } \" " , 'supporters.id' ] ) )
. from ( :supporters )
. join ( Qexpr . new
. select ( 'SUM(gross_amount)' , 'supporter_id' )
2018-03-25 17:30:42 +00:00
. from ( :payments )
. group_by ( :supporter_id )
2019-07-30 21:29:24 +00:00
. where ( 'date >= $date' , date : " #{ year } -01-01 00:00:00 UTC " )
. where ( 'date < $date' , date : " #{ year + 1 } -01-01 00:00:00 UTC " )
. as ( :payments ) , 'payments.supporter_id=supporters.id' )
. where ( 'payments.sum > 25000' )
. as ( 'supporters' )
2018-03-25 17:30:42 +00:00
Psql . execute_vectors (
Qexpr . new
. select (
2019-07-30 21:29:24 +00:00
'supporters.*' ,
2018-03-25 17:30:42 +00:00
'(payments.gross_amount / 100.0)::money::text AS "Donation Amount"' ,
'payments.date AS "Donation Date"' ,
'payments.towards AS "Designation"'
)
. from ( :payments )
. join ( supporter_expr , 'supporters.id = payments.supporter_id' )
. where ( 'payments.nonprofit_id = $id' , id : np_id )
. where ( 'payments.date >= $date' , date : " #{ year } -01-01 00:00:00 UTC " )
2019-07-30 21:29:24 +00:00
. where ( 'payments.date < $date' , date : " #{ year + 1 } -01-01 00:00:00 UTC " )
. order_by ( 'supporters."MAX Name", payments.date DESC' )
2018-03-25 17:30:42 +00:00
)
end
# returns an array of common selects for supporters
# which gets concated with an optional array of additional selects
# used for merging supporters, crm profile and info card
def self . profile_selects ( arr = [ ] )
2019-07-30 21:29:24 +00:00
[ 'supporters.id' ,
'supporters.name' ,
'supporters.email' ,
'supporters.address' ,
'supporters.state_code' ,
'supporters.city' ,
'supporters.zip_code' ,
'supporters.country' ,
'supporters.organization' ,
'supporters.phone' ] + arr
2018-03-25 17:30:42 +00:00
end
# used on crm profile and info card
2019-07-30 21:29:24 +00:00
def self . profile_payments_subquery
Qx . select ( 'supporter_id' , 'SUM(gross_amount)' , 'COUNT(id) AS count' )
. from ( 'payments' )
. group_by ( 'supporter_id' )
. as ( 'payments' )
2018-03-25 17:30:42 +00:00
end
# Get a large set of detailed info for a single supporter, to be displayed in
# the side panel details of the supporter listing after clicking a row.
def self . for_crm_profile ( npo_id , ids )
selects = [
2019-07-30 21:29:24 +00:00
'supporters.created_at' ,
'supporters.imported_at' ,
'supporters.anonymous AS anon' ,
'supporters.is_unsubscribed_from_emails' ,
'COALESCE(MAX(payments.sum), 0) AS raised' ,
'COALESCE(MAX(payments.count), 0) AS payments_count' ,
'COALESCE(COUNT(recurring_donations.active), 0) AS recurring_donations_count' ,
'MAX(full_contact_infos.full_name) AS fc_full_name' ,
'MAX(full_contact_infos.age) AS fc_age' ,
'MAX(full_contact_infos.location_general) AS fc_location_general' ,
'MAX(full_contact_infos.websites) AS fc_websites'
]
2018-03-25 17:30:42 +00:00
Qx . select ( * QuerySupporters . profile_selects ( selects ) )
2019-07-30 21:29:24 +00:00
. from ( 'supporters' )
2018-03-25 17:30:42 +00:00
. left_join (
2019-07-30 21:29:24 +00:00
[ 'donations' , 'donations.supporter_id=supporters.id' ] ,
[ 'full_contact_infos' , 'full_contact_infos.supporter_id=supporters.id' ] ,
[ 'recurring_donations' , 'recurring_donations.donation_id=donations.id' ] ,
[ QuerySupporters . profile_payments_subquery , 'payments.supporter_id=supporters.id' ]
)
. group_by ( 'supporters.id' )
. where ( 'supporters.id IN ($ids)' , ids : ids )
. and_where ( 'supporters.nonprofit_id = $id' , id : npo_id )
2018-03-25 17:30:42 +00:00
. execute
end
def self . for_info_card ( id )
2019-07-30 21:29:24 +00:00
selects = [ 'COALESCE(MAX(payments.sum), 0) AS raised' ]
2018-03-25 17:30:42 +00:00
Qx . select ( * QuerySupporters . profile_selects ( selects ) )
2019-07-30 21:29:24 +00:00
. from ( 'supporters' )
. left_join ( [ QuerySupporters . profile_payments_subquery , 'payments.supporter_id=supporters.id' ] )
. group_by ( 'supporters.id' )
. where ( 'supporters.id=$id' , id : id )
2018-03-25 17:30:42 +00:00
. execute . first
end
def self . merge_data ( ids )
Qx . select ( * QuerySupporters . profile_selects )
2019-07-30 21:29:24 +00:00
. from ( 'supporters' )
. group_by ( 'supporters.id' )
. where ( 'supporters.id IN ($ids)' , ids : ids . split ( ',' ) )
2018-03-25 17:30:42 +00:00
. execute
end
def self . year_aggregate_report ( npo_id , time_range_params )
npo_id = npo_id . to_i
begin
min_date , max_date = get_min_or_max_dates_for_range ( time_range_params )
rescue ArgumentError = > e
raise ParamValidation :: ValidationError . new ( e . message , { } )
end
2019-07-30 21:29:24 +00:00
ParamValidation . new ( { npo_id : npo_id } ,
npo_id : { required : true , is_integer : true } )
aggregate_dons = %(
2018-03-25 17:30:42 +00:00
array_to_string (
array_agg (
2019-01-24 20:33:55 +00:00
payments . date :: date || ' ' ||
2018-03-25 17:30:42 +00:00
( payments . gross_amount / 100 ) :: text :: money || ' ' ||
coalesce ( payments . kind , '' ) || ' ' ||
coalesce ( payments . towards , '' )
2019-01-24 20:33:55 +00:00
ORDER BY payments . date DESC
2018-03-25 17:30:42 +00:00
) ,
'\n'
) AS " Payment History "
)
selects = supporter_export_selections . concat ( [
2019-07-30 21:29:24 +00:00
'SUM(payments.gross_amount / 100)::text::money AS "Total Payments"' ,
'MAX(payments.date)::date AS "Last Payment Date"' ,
'AVG(payments.gross_amount / 100)::text::money AS "Average Payment"' ,
aggregate_dons
] )
Qx . select ( selects )
2018-03-25 17:30:42 +00:00
. from ( :supporters )
2019-07-30 21:29:24 +00:00
. join ( 'payments' , 'payments.supporter_id=supporters.id AND payments.date::date >= $min_date AND payments.date::date < $max_date' , min_date : min_date . to_date , max_date : max_date . to_date )
2018-03-25 17:30:42 +00:00
. where ( 'supporters.nonprofit_id=$id' , id : npo_id )
2019-07-30 21:29:24 +00:00
. group_by ( 'supporters.id' )
2018-03-25 17:30:42 +00:00
. order_by ( " substring(trim(supporters.name) from '^.+ ([^ \s ]+)$') " )
. execute ( format : 'csv' )
end
def self . get_min_or_max_dates_for_range ( time_range_params )
2019-07-30 21:29:24 +00:00
if time_range_params [ :year ]
if time_range_params [ :year ] . is_a? ( Integer )
return DateTime . new ( time_range_params [ :year ] , 1 , 1 ) , DateTime . new ( time_range_params [ :year ] + 1 , 1 , 1 )
2018-03-25 17:30:42 +00:00
end
2019-07-30 21:29:24 +00:00
if time_range_params [ :year ] . is_a? ( String )
wip = time_range_params [ :year ] . to_i
return DateTime . new ( wip , 1 , 1 ) , DateTime . new ( wip + 1 , 1 , 1 )
2018-03-25 17:30:42 +00:00
end
end
2019-07-30 21:29:24 +00:00
if time_range_params [ :start ]
start = parse_convert_datetime ( time_range_params [ :start ] )
if time_range_params [ :end ]
end_datetime = parse_convert_datetime ( time_range_params [ :end ] )
end
2018-03-25 17:30:42 +00:00
2019-07-30 21:29:24 +00:00
return start , end_datetime || start + 1 . year unless start . nil?
end
raise ArgumentError , 'no valid time range provided'
rescue StandardError
raise ArgumentError , 'no valid time range provided'
2018-03-25 17:30:42 +00:00
end
def self . tag_joins ( nonprofit_id , supporter_id )
2019-07-30 21:29:24 +00:00
Qx . select ( 'tag_masters.id' , 'tag_masters.name' )
2018-03-25 17:30:42 +00:00
. from ( 'tag_joins' )
. left_join ( 'tag_masters' , 'tag_masters.id = tag_joins.tag_master_id' )
. where (
[ 'tag_joins.supporter_id = $id' , id : supporter_id ] ,
[ 'coalesce(tag_masters.deleted, FALSE) = FALSE' ] ,
[ 'tag_masters.nonprofit_id = $id' , id : nonprofit_id ]
)
. execute
end
# this is inefficient, don't use in live code
def self . find_supporters_with_multiple_recurring_donations_evil_way ( npo_id )
supporters = Supporter . where ( 'supporters.nonprofit_id = ?' , npo_id ) . includes ( :recurring_donations )
2019-07-30 21:29:24 +00:00
supporters . select { | s | s . recurring_donations . length > 1 }
2018-03-25 17:30:42 +00:00
end
# this is inefficient, don't use in live code
def self . find_supporters_with_multiple_active_recurring_donations_evil_way ( npo_id )
supporters = Supporter . where ( 'supporters.nonprofit_id = ?' , npo_id ) . includes ( :recurring_donations )
2019-07-30 21:29:24 +00:00
supporters . select { | s | s . recurring_donations . select ( & :active ) . length > 1 }
2018-03-25 17:30:42 +00:00
end
2018-10-23 15:43:05 +00:00
def self . parse_convert_datetime ( date )
2019-07-30 21:29:24 +00:00
return date if date . is_a? ( DateTime )
return date . to_datetime if date . is_a? ( Date )
return DateTime . parse ( date ) if date . is_a? ( String )
2018-10-23 15:43:05 +00:00
end
2018-03-25 17:30:42 +00:00
end