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
206 changes: 134 additions & 72 deletions app/legacy_lib/query_event_metrics.rb
Original file line number Diff line number Diff line change
@@ -1,86 +1,148 @@
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later

module QueryEventMetrics
def self.expression(additional_selects = [])
selects = [
"coalesce(tickets.total, 0) AS total_attendees",
"coalesce(tickets.checked_in_count, 0) AS checked_in_count",
"coalesce(ticket_payments.total_paid, 0) AS tickets_total_paid",
"coalesce(donations.payment_total, 0) AS donations_total_paid",
"coalesce(ticket_payments.total_paid, 0) + coalesce(donations.payment_total, 0) AS total_paid"
]

tickets_sub = Qx.select("event_id", "SUM(quantity) AS total", "SUM(tickets.checked_in::int) AS checked_in_count")
.from("tickets")
.group_by("event_id")
.as("tickets")

ticket_payments_subquery = Qx.select("payment_id", "MAX(event_id) AS event_id").from("tickets").group_by("payment_id").as("tickets")

ticket_payments_sub = Qx.select("SUM(payments.gross_amount) AS total_paid", "tickets.event_id")
.from(:payments)
.join(ticket_payments_subquery, "payments.id=tickets.payment_id")
.group_by("tickets.event_id")
.as("ticket_payments")

donations_sub = Qx.select("event_id", "SUM(payments.gross_amount) as payment_total")
.from("donations")
.group_by("event_id")
.left_join("payments", "donations.id=payments.donation_id")
.as("donations")

selects = selects.concat(additional_selects)
Qx.select(*selects)
.from("events")
.left_join(
[tickets_sub, "tickets.event_id = events.id"],
[donations_sub, "donations.event_id = events.id"],
[ticket_payments_sub, "ticket_payments.event_id=events.id"]
)
end
# For now limit to 1000
QUERY_MAX_LIMIT = 1_000

def self.with_event_ids(event_ids)
return [] if event_ids.empty?
QueryEventMetrics.expression.where("events.id in ($ids)", ids: event_ids).execute
return [] if event_ids.blank?

events = Event.select(:id, :name, :venue_name, :address, :city, :state_code,
:zip_code, :start_datetime, :end_datetime, :organizer_email)
.where(id: event_ids)
.limit(QUERY_MAX_LIMIT)

add_metrics_to_events(events)
end

def self.for_listings(id_type, id, params)
selects = [
"events.id",
"events.name",
"events.venue_name",
"events.address",
"events.city",
"events.state_code",
"events.zip_code",
"events.start_datetime",
"events.end_datetime",
"events.organizer_email"
]

exp = QueryEventMetrics.expression(selects)

if id_type == "profile"
exp = exp.and_where(["events.profile_id = $id", id: id])
end
if id_type == "nonprofit"
exp = exp.and_where(["events.nonprofit_id = $id", id: id])
def self.for_listings(id_type, profile_or_nonprofit_id, params)
events = base_events(id_type, profile_or_nonprofit_id, params)
return [] if events.blank?

# Add metrics to the events
add_metrics_to_events(events)
end

private

def self.base_events(id_type, id, params)
query = Event.select(:id, :name, :venue_name, :address, :city, :state_code,
:zip_code, :start_datetime, :end_datetime, :organizer_email)

case id_type
when "profile"
query = query.where(profile_id: id)
when "nonprofit"
query = query.where(nonprofit_id: id)
else
raise "Unknown id_type #{id_type}"
end

if params["active"].present?
exp = exp
.and_where(["events.end_datetime >= $date", date: Time.now])
.and_where(["events.published = TRUE AND coalesce(events.deleted, FALSE) = FALSE"])
query = query
.where("end_datetime >= ?", Time.current)
.where(published: true)
.where("COALESCE(deleted, false) = false")
elsif params["past"].present?
query = query
.where("end_datetime < ?", Time.current)
.where(published: true)
.where("COALESCE(deleted, false) = false")
elsif params["unpublished"].present?
query = query
.where("COALESCE(published, false) = false")
.where("COALESCE(deleted, false) = false")
elsif params["deleted"].present?
query = query.where(deleted: true)
end
if params["past"].present?
exp = exp
.and_where(["events.end_datetime < $date", date: Time.now])
.and_where(["events.published = TRUE AND coalesce(events.deleted, FALSE) = FALSE"])

query = query.order(end_datetime: :desc)
query.limit(QUERY_MAX_LIMIT)
end

def self.add_metrics_to_events(events)
return [] if events.blank?

event_ids = events.pluck(:id)

ticket_metrics = ticket_metrics(event_ids)
donation_metrics = donation_metrics(event_ids)
payment_metrics = payment_metrics(event_ids)

events.map do |event|
build_event_result(event,
ticket_metrics[event.id],
donation_metrics[event.id],
payment_metrics[event.id])
end
if params["unpublished"].present?
exp = exp.and_where(["coalesce(events.published, FALSE) = FALSE AND coalesce(events.deleted, FALSE) = FALSE"])
end

def self.build_event_result(event, tickets, donations, payments)
tickets ||= {total: 0, checked_in_count: 0}
donations ||= {donation_total: 0}
payments ||= {payment_total: 0}

{
"id" => event.id,
"name" => event.name,
"venue_name" => event.venue_name,
"address" => event.address,
"city" => event.city,
"state_code" => event.state_code,
"zip_code" => event.zip_code,
"start_datetime" => event.start_datetime,
"end_datetime" => event.end_datetime,
"organizer_email" => event.organizer_email,
"total_attendees" => tickets[:total],
"checked_in_count" => tickets[:checked_in_count],
"tickets_total_paid" => payments[:payment_total],
"donations_total_paid" => donations[:donation_total],
"total_paid" => payments[:payment_total] + donations[:donation_total]
}
end

def self.ticket_metrics(event_ids)
return {} if event_ids.blank?

ticket_data = Ticket.where(event_id: event_ids)
.group(:event_id)
.pluck(:event_id,
Arel.sql("COALESCE(SUM(quantity), 0) as total"),
Arel.sql("COALESCE(SUM(CASE WHEN checked_in THEN 1 ELSE 0 END), 0) as checked_in_count"))

ticket_data.to_h do |event_id, total, checked_in_count|
[event_id, {total:, checked_in_count:}]
end
if params["deleted"].present?
exp = exp.and_where(["events.deleted = TRUE"])
end

def self.donation_metrics(event_ids)
return {} if event_ids.blank?

donation_data = Donation.left_joins(:payments)
.where(event_id: event_ids)
.group(:event_id)
.pluck(:event_id, Arel.sql("COALESCE(SUM(payments.gross_amount), 0) as donation_total"))

donation_data.to_h do |event_id, donation_total|
[event_id, {donation_total:}]
end
end

def self.payment_metrics(event_ids)
return {} if event_ids.blank?

# TODO: consider deleted tickets too
payment_data = Payment.joins(:tickets)
.where(tickets: {event_id: event_ids})
.group("tickets.event_id")
.pluck("tickets.event_id", Arel.sql("COALESCE(SUM(payments.gross_amount), 0) as payment_total"))

payment_data.to_h do |event_id, payment_total|
[event_id, {payment_total:}]
end
exp.execute
end

def self.execute(query)
Event.connection.execute query
end
end
1 change: 1 addition & 0 deletions app/models/donation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class Donation < ApplicationRecord
has_one :offsite_payment
has_one :tracking
has_many :modern_donations

belongs_to :supporter
belongs_to :card
belongs_to :direct_debit_detail
Expand Down
2 changes: 1 addition & 1 deletion app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class Event < ApplicationRecord
scope :past, -> { where("end_datetime < ?", Date.today).published }
scope :unpublished, -> { where.not(published: true) }

validates :slug, uniqueness: {scope: :nonprofit_id, message: "You already have a campaign with that name."}
validates :slug, uniqueness: {scope: :nonprofit_id, message: "You already have an event with that name."}

before_validation(on: :create) do
unless slug
Expand Down
3 changes: 2 additions & 1 deletion app/models/payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ class Payment < ApplicationRecord
:supporter_id,
:supporter

belongs_to :donation
belongs_to :supporter
belongs_to :nonprofit

has_one :charge
has_one :offsite_payment
has_one :refund
has_one :dispute_transaction
has_many :disputes, through: :charge
belongs_to :donation
has_many :tickets
has_one :campaign, through: :donation
has_many :campaign_gifts, through: :donation
Expand Down
8 changes: 4 additions & 4 deletions app/views/events/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
<% end %>

<%= render 'components/header',
icon_class: 'icon-ticket-2',
title: 'Events',
profile: @nonprofit,
has_mosaic: true %>
icon_class: 'icon-ticket-2',
title: 'Events',
profile: @nonprofit,
has_mosaic: true %>

<main class='container u-padding--15 u-marginBottom--30'>
<% if current_user %>
Expand Down
26 changes: 16 additions & 10 deletions client/js/events/listing-item/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,30 @@ const dateTime = (startTime, endTime) => {
]
}

const metric = (label, val) =>
const metric = (label, val) =>
h('span.u-inlineBlock.u-marginRight--20', [h('strong', `${label}: `), val || '0'])

const row = (icon, content) =>
const checkedInPercent = (checkedIn = 0 , total = 0) => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes bug with divide by 0 (NaN)...

if (total === 0) return 0;
return Math.round((checkedIn * 100) / total);
}

const row = (icon, content) =>
h('tr', [
h('td.u-centered', [h(`i.fa.${icon}`)])
, h('td.u-padding--10', content)
])

module.exports = e => {
module.exports = (e, key) => {
const path = `/nonprofits/${app.nonprofit_id}/events/${e.id}`
const location = [
h('p.strong.u-margin--0', e.venue_name)
h('p.strong.u-margin--0', e.venue_name)
, h('p.u-margin--0', commaJoin([e.address, e.city, e.state_code, e.zip_code]))
]
const attendeesMetrics = [
metric('Attendees', e.total_attendees)
, metric('Checked In', e.checked_in_count)
, metric('Percent Checked In', Math.round((e.checked_in_count || 0) * 100 / (e.total_attendees || 0)) + '%')
metric('Attendees', e.total_attendees)
, metric('Checked In', e.checked_in_count)
, metric('Percent Checked In', checkedInPercent(e.checked_in_count, e.total_attendees) + '%')
]
const moneyMetrics = [
metric('Ticket Payments', '$' + format.centsToDollars(e.tickets_total_paid))
Expand All @@ -48,9 +53,10 @@ module.exports = e => {
h('a.u-marginRight--20', {props: {href: path, target: '_blank'}}, 'Event Page')
, h('a', {props: {href: path + '/tickets', target: '_blank'}}, 'Attendees Page')
]
return h('div.u-paddingTop--10.u-marginBottom--20', [
h('h5.u-paddingX--20', e.name)
, h('table.table--striped.u-margin--0', [

return h('div.u-paddingTop--10.u-paddingBottom--10', [
h(`h5.u-padding--20.u-margin--0.fundraiser--${key}`, e.name)
, h(`table.table--striped.u-margin--0.fundraiser--${key}`, [
row('fa-clock-o', dateTime(e.start_datetime, e.end_datetime))
, row('fa-map-marker', location)
, row('fa-users', attendeesMetrics)
Expand Down
30 changes: 17 additions & 13 deletions client/js/events/listings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,31 @@ module.exports = pathPrefix => {
const init = _ => {
return {
active: get('active')
, past: get('past')
, unpublished: get('unpublished')
, deleted: get('deleted')
, past: get('past')
, unpublished: get('unpublished')
, deleted: get('deleted')
}
}

const listings = (key, state) => {
const resp$ = state[key]
const mixin = content =>
h('section.u-marginBottom--30', [
h('h5.u-centered.u-marginBottom--20', key.charAt(0).toUpperCase() + key.slice(1) + ' Events')
, h(`div.fundraiser--${key}`, content)
const mixin = (content, count) =>
h('section.u-marginBottom--20.u-marginTop--30', [
h('h4.u-marginBottom--0.u-paddingX--20', count + ' ' + key.charAt(0).toUpperCase() + key.slice(1) + ' Events')
Copy link
Author

@caseyhelbling caseyhelbling Jun 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, now display the count of events in the header/title:

, h(`div`, content)
])
if(!resp$())
return mixin([h('p.u-padding--15', 'Loading...')])
if(!resp$().body.length)
return mixin([h('p.u-padding--15', `No ${key} events`)])
return mixin(resp$().body.map(listing));

if(!resp$())
return mixin([h(`p.u-padding--15.fundraiser--${key}`, 'Loading...')], 0)

const numberElems = resp$().body.length
if(!numberElems)
return mixin([h(`p.u-padding--15.fundraiser--${key}`, `No ${key} events`)], 0)

return mixin(resp$().body.map(item => listing(item, key)), numberElems);
}

const view = state =>
const view = state =>
h('div', [
listings('active', state)
, listings('past', state)
Expand Down
15 changes: 15 additions & 0 deletions db/migrate/20250617142915_add_indexes_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class AddIndexesEvents < ActiveRecord::Migration[7.1]
def change
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add missing indexes for performance.

add_index :tickets, [:event_id, :quantity, :checked_in],
name: 'idx_tickets_event_metrics'

add_index :payments, [:donation_id, :gross_amount],
name: 'idx_payments_donations'

add_index :tickets, [:payment_id, :event_id],
name: 'idx_tickets_payments'

add_index :payments, [:id, :gross_amount],
name: 'idx_payments_gross_amount'
end
end
Loading
Loading