Skip to content
Draft
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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ This codebase (Rails 8.1)

| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~80 files |
| `app/models/` | ActiveRecord models | ~81 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~57 files |
| `app/jobs/` | SolidQueue background jobs | 5 files |
| `app/models/concerns/` | Shared model modules | 16 concerns |
Expand Down Expand Up @@ -105,7 +105,8 @@ This codebase (Rails 8.1)
| `OtherResponse` | A free-text "Other" typed on a form question, captured at submission time (registration, scholarship, bulk payment). Polymorphic `owner`: a **sector** "Other" is owned by the `Person` (promotable into a `Sector`, shown on their profile/edit chip); an **organization_type** "Other" is owned by the `Organization` (stored now, not promotable until `OrganizationType` is a model). `generic` questions aren't captured β€” that stays searchable in the form answers. `field_identifier` records the question; `kind` is derived. Curated at `/other_responses` (grouped by kind/question): `promote` (sectors only), `keep`, `dismiss`. `dismissed` hides the chip from the profile but stays in the review queue (still promotable later); only `promoted` leaves the queue. Admins deep-link there from a person's chip. |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Grant` | Funds (polymorphic `funder`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation` |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation`. Tri-state `agreement_response_status` (pending/accepted/declined) drives the agreement; declined awards zero their allocation and drop out of all totals |
| `ScholarshipAgreementResponse` | Append-only history of a scholarship's accept ↔ decline back-and-forth (status, reason, responder, amount at the time); the scholarship's `agreement_response_status` is the denormalized latest row, and `responded_at`/reason are read from the latest response, not stored on the scholarship |
| `ProfessionalLicense` | A license a `Person` holds (`number`, `kind`, `issuing_state`, `expires_on`); a null `number` is a placeholder. `find_or_create_for` keeps one license per (person, number) |
| `ContinuingEducationRegistration` | A registrant's CE for one event against one `ProfessionalLicense`; billable `allocatable` (`Registerable`) with stored `hours` + `cost_cents` (default from the event). Payment is computed (no stored status); the certificate is delivered via `certificate_sent_at` and gated by its own `certificate_available?` |
| `TopicSubscription` | A `Person`'s standing subscription to a `TopicSubscriptionType`, optionally narrowed to a specific `interested_event` (null = the topic broadly). State is timestamp-driven (`unsubscribed_at IS NULL` = active β€” `active?`/`unsubscribe!`/`resubscribe` β€” non-bang, since reviving can collide with a newer active row, no status column); `subscribed_at` + `source` mirror the `mailing_list_consent_*` provenance pattern. Distinct from the `mailing_list_consent_*` flag (consent = "you may email me"; subscription = "what I want to hear about") and from an `EventRegistration` (an actual enrollment). One active subscription per (person, type, event) |
Expand Down
61 changes: 58 additions & 3 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ def scholarship
end

# Records the recipient agreeing, from their scholarship page, to complete the
# scholarship's tasks. The Agree button submits agreement=yes, which stamps
# agreement_signed_at via the model.
# scholarship's tasks. The Agree button submits agreement=yes, which records an
# "accepted" response via the model.
def sign_agreement
scholarship = @event_registration.scholarships.first
unless scholarship
Expand All @@ -65,13 +65,45 @@ def sign_agreement
end

if params[:agreement] == "yes"
scholarship.update!(agreement_signed: true) unless scholarship.agreement_signed?
newly_signed = !scholarship.agreement_signed?
scholarship.accept_agreement!(by: "recipient")
notify_scholarship_agreement_signed(scholarship) if newly_signed
redirect_to registration_scholarship_path(@event_registration.slug), notice: "Thanks β€” your agreement has been recorded."
else
redirect_to registration_scholarship_path(@event_registration.slug), alert: "Something went wrong recording your agreement. Please try again."
end
end

# Records the recipient declining the scholarship, from their scholarship page,
# with an optional reason. Stamps the decline and emails the admin team an FYI
# so they can follow up. Only the first decline emails β€” re-submitting is a no-op.
def decline_agreement
scholarship = @event_registration.scholarships.first
unless scholarship
redirect_to registration_scholarship_path(@event_registration.slug)
return
end

if scholarship.agreement_declined?
redirect_to registration_scholarship_path(@event_registration.slug), notice: "You've already declined this scholarship. Contact us if you'd like to reconsider."
return
end

reason = params[:decline_reason].to_s.strip
scholarship.decline_agreement!(reason)

NotificationServices::CreateNotification.call(
noticeable: scholarship,
kind: :scholarship_agreement_declined_fyi,
recipient_role: :admin,
recipient_email: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"),
notification_type: 0,
custom_message: reason.presence
)

redirect_to registration_scholarship_path(@event_registration.slug), notice: "Thanks for letting us know β€” we've told the team and they'll follow up with you."
end

# CE hours status: hours, amount owed, and license number. The heading and the
# requirements copy live on the materialized ce_hours callout row now.
def ce
Expand Down Expand Up @@ -189,6 +221,29 @@ def faq

private

# On the recipient signing: confirm to them (with a link back to their ticket)
# and send the team an FYI.
def notify_scholarship_agreement_signed(scholarship)
recipient_email = scholarship.recipient&.preferred_email
if recipient_email.present?
NotificationServices::CreateNotification.call(
noticeable: scholarship,
kind: :scholarship_agreement_signed,
recipient_role: :person,
recipient_email: recipient_email,
notification_type: 0
)
end

NotificationServices::CreateNotification.call(
noticeable: scholarship,
kind: :scholarship_agreement_signed_fyi,
recipient_role: :admin,
recipient_email: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"),
notification_type: 0
)
end

# Whether the event's built-in callout for this key is materialized and
# published (visible). These public pages gate on that alone now β€” the admin's
# published/hidden choice on the row decides whether the page is reachable, so
Expand Down
15 changes: 14 additions & 1 deletion app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class ScholarshipsController < ApplicationController
before_action :set_scholarship, only: [ :show, :edit, :update, :destroy, :toggle_tasks ]
before_action :set_scholarship, only: [ :show, :edit, :update, :destroy, :toggle_tasks, :reoffer ]
before_action :set_grant, only: [ :new, :create ]

def index
Expand Down Expand Up @@ -108,6 +108,19 @@ def toggle_tasks
end
end

# Re-offer a declined award: back to pending and re-fund the allocation, so the
# recipient can respond again. Explicit admin action (editing the amount alone no
# longer reactivates a decline).
def reoffer
authorize! @scholarship, to: :update?
@scholarship.reoffer_agreement!(by: "admin")
redirect_to edit_scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence),
notice: "Scholarship re-offered β€” awaiting the recipient's response."
rescue ActiveRecord::RecordInvalid => e
redirect_to edit_scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence),
alert: e.record.errors.full_messages.to_sentence.presence || "Couldn't re-offer this scholarship."
end

private

# Filter state for the shared report filter partials (time period, event,
Expand Down
4 changes: 2 additions & 2 deletions app/decorators/grant_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ def remaining_percentage
# completed/total. .size / Enumerable count use the preloaded association
# (index eager-loads :scholarships) so these add no per-row queries.
def scholarships_count
object.scholarships.size
object.scholarships.reject(&:agreement_declined?).size
end

def completed_scholarships_count
object.scholarships.count(&:tasks_completed?)
object.scholarships.reject(&:agreement_declined?).count(&:tasks_completed?)
end

# Where the index "Scholarships" count links. When every event-funded
Expand Down
17 changes: 17 additions & 0 deletions app/decorators/scholarship_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,21 @@ def tasks_completed?
def agreement_signed?
object.agreement_signed?
end

def agreement_declined?
object.agreement_declined?
end

# A single agreement-status pill shared by every surface that lists a
# scholarship (indexes, event/registration edit, grant show) so the declined
# state is visible everywhere: Declined (red), Signed (fuchsia), Pending (amber).
def agreement_status_label
return "Declined" if object.agreement_declined?
object.agreement_signed? ? "Signed" : "Pending"
end

def agreement_status_classes
return "bg-red-50 text-red-700 border-red-200" if object.agreement_declined?
object.agreement_signed? ? "bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200" : "bg-amber-50 text-amber-700 border-amber-200"
end
end
5 changes: 4 additions & 1 deletion app/jobs/notification_mailer_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ def perform(notification_id, persist_delivered_email: true)
"event_registration_cancelled_fyi" => ->(n) { NotificationMailer.event_registration_cancelled_fyi(n) },
"event_registration_reminder" => ->(n) { EventMailer.event_registration_reminder(n.noticeable, custom_message: n.custom_message, custom_subject: n.custom_subject) },
"bulk_payment_confirmation" => ->(n) { EventMailer.bulk_payment_confirmation(n.noticeable) },
"bulk_payment_confirmation_fyi" => ->(n) { NotificationMailer.bulk_payment_confirmation_fyi(n) }
"bulk_payment_confirmation_fyi" => ->(n) { NotificationMailer.bulk_payment_confirmation_fyi(n) },
"scholarship_agreement_signed" => ->(n) { NotificationMailer.scholarship_agreement_signed(n) },
"scholarship_agreement_signed_fyi" => ->(n) { NotificationMailer.scholarship_agreement_signed_fyi(n) },
"scholarship_agreement_declined_fyi" => ->(n) { NotificationMailer.scholarship_agreement_declined_fyi(n) }
}

mailer = mailer_map[notification.kind]&.call(notification)
Expand Down
4 changes: 4 additions & 0 deletions app/mailers/contact_us_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ def hello(contact_us, user = nil)
@contact_us = contact_us
@user = user

# When the message came from a scholarship page, link the team to that
# registration (threaded through as a slug on the form).
@registration = EventRegistration.find_by(slug: contact_us[:registration_id]) if contact_us[:registration_id].present?

@mail_to = ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org")

sender_name = if user.present?
Expand Down
37 changes: 37 additions & 0 deletions app/mailers/notification_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,43 @@ def workshop_log_submitted_fyi(notification)
)
end

def scholarship_agreement_signed(notification)
@scholarship = notification.noticeable
@person = @scholarship.recipient
@event = @scholarship.event&.decorate
registration = @scholarship.event_registration
@ticket_url = registration_ticket_url(registration.slug) if registration
@notification_type = "Scholarship agreement confirmation"

mail(
to: notification.recipient_email,
subject: "#{SUBJECT_PREFIX} Your scholarship agreement is confirmed"
)
end

def scholarship_agreement_signed_fyi(notification)
@scholarship = notification.noticeable
@person = @scholarship.recipient
@event = @scholarship.event&.decorate
@notification_type = "Scholarship agreement signed"

mail(
subject: "#{FYI_PREFIX} Scholarship agreement signed by #{@person&.full_name}"
)
end

def scholarship_agreement_declined_fyi(notification)
@scholarship = notification.noticeable
@person = @scholarship.recipient
@event = @scholarship.event&.decorate
@reason = notification.custom_message
@notification_type = "Scholarship declined"

mail(
subject: "#{FYI_PREFIX} Scholarship declined by #{@person&.full_name}"
)
end

private

def extract_attachments(noticeable)
Expand Down
2 changes: 1 addition & 1 deletion app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ class EventRegistration < ApplicationRecord
WHERE allocations.allocatable_type = 'EventRegistration'
AND allocations.allocatable_id = event_registrations.id
AND allocations.source_type = 'Scholarship'
AND scholarships.agreement_signed_at IS NOT NULL
AND scholarships.agreement_response_status = 'accepted'
)
SQL
}
Expand Down
12 changes: 6 additions & 6 deletions app/models/grant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def self.self_funded_ids
# funds scopes so they stay flat WHERE clauses β€” no GROUP BY/HAVING, which would
# break will_paginate's total_entries count on the paginated index.
ALLOCATED_CENTS_SUBQUERY =
"COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id), 0)".freeze
"COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id AND scholarships.agreement_response_status <> 'declined'), 0)".freeze

# Grants that still have unallocated funds (donation amount exceeds the sum of
# scholarships drawn against them).
Expand All @@ -41,11 +41,11 @@ def self.self_funded_ids
# exclude grant-less scholarships (grant_id IS NULL) β€” a stray NULL in the
# NOT IN set below would otherwise make all_tasks_completed match nothing.
scope :tasks_outstanding, -> {
where(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
where(id: Scholarship.not_declined.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
}
scope :all_tasks_completed, -> {
where(id: Scholarship.where.not(grant_id: nil).select(:grant_id))
.where.not(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
where(id: Scholarship.not_declined.where.not(grant_id: nil).select(:grant_id))
.where.not(id: Scholarship.not_declined.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
}

# Grants offered in a scholarship's "Funded by grant" picker: every grant with
Expand Down Expand Up @@ -96,9 +96,9 @@ def name_with_funder
# association in memory when present (the index eager-loads :scholarships) to
# avoid a per-row SQL SUM; otherwise issues a single aggregate query.
def scholarships_total_cents
return scholarships.sum { |s| s.amount_cents.to_i } if scholarships.loaded?
return scholarships.reject(&:agreement_declined?).sum { |s| s.amount_cents.to_i } if scholarships.loaded?

scholarships.sum(:amount_cents)
scholarships.not_declined.sum(:amount_cents)
end

def remaining_cents
Expand Down
8 changes: 8 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ class Notification < ApplicationRecord
workshop_log_submitted
workshop_log_submitted_fyi

scholarship_agreement_signed
scholarship_agreement_signed_fyi
scholarship_agreement_declined_fyi

manual_log
].freeze

Expand All @@ -62,6 +66,7 @@ class Notification < ApplicationRecord
FormSubmission
Person
Report
Scholarship
StoryIdea
User
WorkshopLog
Expand All @@ -80,6 +85,9 @@ class Notification < ApplicationRecord
[ "Admin FYI: idea submitted", "submission by" ],
[ "Admin FYI: password reset", "[FYI] New password reset" ],
[ "Admin FYI: workshop log submission", "New WorkshopLog submission" ],
[ "Admin FYI: scholarship agreement signed", "Scholarship agreement signed" ],
[ "Admin FYI: scholarship declined", "Scholarship declined" ],
[ "Scholarship: agreement confirmation", "scholarship agreement is confirmed" ],
[ "Admin FYI: contact form submission", "contact form submission" ],
[ "Contact: form confirmation", "We received your message" ],
[ "Event registration cancelled", "Event registration cancelled" ],
Expand Down
Loading