Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: add new routes for donations and donors #7699

Open
wants to merge 55 commits into
base: epic/campaigns
Choose a base branch
from

Conversation

glaubersilva
Copy link
Contributor

@glaubersilva glaubersilva commented Jan 28, 2025

Related to GIVE-1392 and GIVE-1393

Description

This PR implements 4 new REST API endpoints to retrieve Donations and Donors. In the endpoints that return multiple entries, is possible to filter the returned data using custom parameters in the request and also is possible use pagination and sort the results using the page, per_page, sort and direction parameters.

Another thing to consider is that sensitive data will be returned only if the user making the request is the site administrator and the includeSensitiveData param is set to true.

Sensitive data for donations:

$sensitiveData = [
    'donorIp',
    'email',
    'phone',
    'billingAddress',
];

Sensitive data for donors:

$sensitiveData = [
    'userId',
    'email',
    'phone',
    'additionalEmails',
];

The new endpoints to retrieve a single entry:

/givewp/v3/donations/(?P<id>[0-9]+)

/givewp/v3/donors/(?P<id>[0-9]+)

The new endpoints to retrieve multiple entries:

/givewp/v3/donations

/givewp/v3/donors

Important: These endpoints allow filtering the returned data through the campaignId parameter. It's also possible to use the includeSensitiveData (only available for admins) or anonymousDonations and anonymousDonors (only admins can include anonymous donations/donors without redacted data) parameters to exclude from the results the donations/donors that made anonymous donations and/or sensitive data that can be used to contact/localize the donors. Beyond that, on the /givewp/v3/donors endpoint, it is possible to use the onlyWithDonations parameter to retrieve all donors or just the ones that have valid donations completed. For more details, check this comment: #7699 (comment)

Sample request including anonymous donations in the results with redact data:

$request = new WP_REST_Request('GET' 'givewp/v3/donations');

$request->set_query_params(
    [
        'anonymousDonations' => 'redact',   // Data that can identify donors will be displayed as "anonymous"
        //'campaignId' => $campaign1->id, // Uncomment this line to filter by campaign
    ]
);

Sample request including anonymous donors in the results with redact data:

$request = new WP_REST_Request('GET' 'givewp/v3/donors');

$request->set_query_params(
    [
        'anonymousDonors' => 'redact',   // Data that can identify donors will be displayed as "anonymous"
        //'campaignId' => $campaign1->id, // Uncomment this line to filter by campaign
    ]
);

Sample request to retrieve the 5 most recent donations:

$request = new WP_REST_Request('GET' 'givewp/v3/donations');

$request->set_query_params(
    [
        'page' => 1,
        'per_page' => 5,
        'sort' => 'createdAt',
        'direction' => 'DESC',
        'anonymousDonations' => 'redact', // Data that can identify donors will be displayed as "anonymous"
        //'campaignId' => $campaign1->id, // Uncomment this line to filter by campaign
    ]
);

Sample request to retrieve the top 5 donors:

$request = new WP_REST_Request('GET' 'givewp/v3/donors');

$request->set_query_params(
    [
        'page' => 1,
        'per_page' => 5,
        'sort' => 'totalAmountDonated',
        'direction' => 'DESC',
        'anonymousDonors' => 'redact',   // Data that can identify donors will be displayed as "anonymous"
        //'campaignId' => $campaign1->id, // Uncomment this line to filter by campaign
    ]
);

Affects

GiveWP Rest API endpoints available for public use.

Testing Instructions

In your terminal, run the following commands:

composer test -- --filter GetDonationRouteTest

composer test -- --filter GetDonationsRouteTest

composer test -- --filter GetDonorRouteTest

composer test -- --filter GetDonorsRouteTest

Pre-review Checklist

  • Acceptance criteria satisfied and marked in related issue
  • Relevant @unreleased tags included in DocBlocks
  • Includes unit tests
  • Reviewed by the designer (if follows a design)
  • Self Review of code and UX completed

@glaubersilva glaubersilva self-assigned this Jan 28, 2025
@glaubersilva glaubersilva marked this pull request as ready for review January 29, 2025 17:40
@glaubersilva glaubersilva requested a review from kjohnson January 29, 2025 17:43
Copy link
Contributor

@kjohnson kjohnson left a comment

Choose a reason for hiding this comment

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

Cool seeing headers and links for pagination! This is often overlooked, but good that we are starting to leverage this feature.

I added some feedback on the queries. In particular, we have the CampaignDonationQuery which we should consider using - or at least update what is here to account for subscriptions and test payments.

@glaubersilva
Copy link
Contributor Author

@JasonTheAdams I liked the idea of renaming /give-api/v2/ to /give/v3/ for new routes, aligning with the new approach we introduced in the Campaigns domain. This change would ensure consistency across all implementations using the new standards, which are designed to support entities.

I believe it would be appropriate to apply this replacement everywhere, including the routes implemented in this PR as well as those for Campaigns.

So, I think we can move forward with this change unless the other devs have concerns or objections about it, let's check with them just to make sure we are not missing something here.

@JasonTheAdams
Copy link
Contributor

Sounds great, @glaubersilva! I like the idea of retroactively applying this so long as the endpoints we're applying them to are:

  1. Truly RESTful
  2. Not in production

Comment on lines +118 to +121
'onlyWithDonations' => [
'type' => 'boolean',
'default' => true,
],
Copy link
Contributor

@JasonTheAdams JasonTheAdams Feb 17, 2025

Choose a reason for hiding this comment

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

@glaubersilva You alluded to this parameter. This weirds me out. Hahah! This would make sense if this was a constituents endpoint, but having a "I want donors that made donations" is weird. It just naturally raises the question: "How is a person a donor if they didn't donate?" 😆

Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a specific use-case for this filter?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@JasonTheAdams In my local dev site, it happens all the time when I delete all donations using the list table bulk actions but don't do the same for donors - check the attached screenshots for reference.

I believe that this scenario it's an edge case for real users, but it can happen.

So if we need for some reason to return all donors to run a specific action or routine regardless of it they have or not donations, we can set this param to false.

This was my logic to implement it here. Thoughts? 😊

image

image

Comment on lines +50 to +58
$query->join(function (JoinQueryBuilder $builder) use ($mode) {
// The donationmeta1.donation_id should be used in other "donationmeta" joins to make sure we are retrieving data from the proper donation
$builder->innerJoin('give_donationmeta', 'donationmeta1')
->joinRaw("ON donationmeta1.meta_key = '" . DonationMetaKeys::DONOR_ID . "' AND donationmeta1.meta_value = ID");

// Include only current payment "mode"
$builder->innerJoin('give_donationmeta', 'donationmeta2')
->joinRaw("ON donationmeta2.meta_key = '" . DonationMetaKeys::MODE . "' AND donationmeta2.meta_value = '{$mode}' AND donationmeta2.donation_id = donationmeta1.donation_id");
});
Copy link
Contributor

Choose a reason for hiding this comment

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

@glaubersilva @jonwaldstein
Thinking ahead to database changes, I strongly recommend that we update (or create) a DonorQueryBuilder that has declarative methods for things like this. So this craziness becomes:

$query->whereDonorsHaveDonations();

It's cool for the REST APIs to use the query builder, but we want to reduce modifications that are directly coupled to the database structure (such as referencing meta like we are here).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@JasonTheAdams If you don't mind, I would like to refactor this in a subsequent PR because these endpoints will not be immediately used in the codebase, but I'm afraid to spend too much time on it and delay the merge into the epic even more - as we are changing the namespace of the campaign routes, we need to merge it before the public release.

Also, I would like to let the folder structure changes mentioned by @jonwaldstein here be included in this subsequent PR as well.

Copy link
Contributor

@JasonTheAdams JasonTheAdams Mar 10, 2025

Choose a reason for hiding this comment

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

So long as the API itself is finalized in this PR, I'm cool with moving under-the-hood changes to a subsequent PR. BUT! You must agree to actually plan to do it. None of this, "oh yeah, we'll do that later for sure" stuff. 😁

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure! We can do it later.

TrueTrollGIF

Ok, I'm kidding! 😅

My plan is to start a new PR as soon the Campaigns tasks (related to the public release) pile gets down, so probably on the next fun Friday I'll already open a PR for it.

@jonwaldstein
Copy link
Contributor

@glaubersilva there's one more update i'd like to request that I was discussing with @JasonTheAdams recently:

Let's move the actual registration of the rest endpoints into a shared top-level domain. The reason is our REST API is really it's own domain that should live outside of the business logic of our other domains. It also makes it much easier to manage 😄

It looks like we already have an API domain so we could simply add a REST/V3 folder inside and have the structure look something like this:

Screenshot 2025-02-18 at 5 20 35 PM

We do also have an existing Service Provider that registers our v2 REST endpoints. I'm not opposed of doing something similar within our new API\REST\V3 domain as it pretty clearly registers each route programatically.

Copy link
Contributor

@JasonTheAdams JasonTheAdams left a comment

Choose a reason for hiding this comment

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

Appreciate the continued work here, @glaubersilva! 🎉

Copy link
Contributor

Choose a reason for hiding this comment

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

I won't die on this hill, but I'm not personally a fan of separating the request registration from the callbacks. First, it's not the convention. Second, it makes it harder to figure out the expected parameters. I think it feels necessary now because there's so much query building going on, but once we abstract out the query details it will greatly reduce the length of the callback code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@JasonTheAdams Yeah! I agree, I just followed this pattern because we already are using it on the campaign routes. But I think we can leave it this way right now and refactor it later in a subsequent PR to improve the code maintenance.

Comment on lines 60 to 75
'sensitiveData' => [
'type' => 'string',
'default' => 'exclude',
'enum' => [
'exclude',
'include',
],
],
'anonymousDonations' => [
'type' => 'string',
'default' => 'exclude',
'enum' => [
'exclude',
'include',
'redact',
],
Copy link
Contributor

Choose a reason for hiding this comment

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

@glaubersilva Can you help me understand the two modes, here? What's each one for? Why two?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@JasonTheAdams The sensitiveData is applied to all donations and the anonymousDonations is only for anonymous donations.

Previously, I was checking inside the escDonation() method if the current user was logged as admin, and if not I was removing the sensitiveData from the results.

So to keep the consistency and the concept that we applied for the mode and anonymousDonations parameters, I moved this state control logic to the new sensitiveData param.

Therefore, the reason to have it separated into two parameters is that the admins should be able to retrieve it if they need it but these values should never be visible for non-admin users:

$sensitiveData = [
    'donorIp',
    'email',
    'phone',
    'billingAddress',
]; 

Copy link
Contributor

Choose a reason for hiding this comment

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

I see! Thanks for explaining, @glaubersilva! So do we foresee a mode wherein folks would want to distinguish anonymous data from other sensitive data? Could it all just live under a single "sensitiveData" parameter?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since we don't need to redact the sensitive data (we need only include or exclude it) I simplify it to use a boolean value instead of an enum. See: 183ec8c

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@JasonTheAdams I think about it a LOT because my initial idea was to put it together but I concluded that it's not possible (or maybe I'm missing something here) because the anonymousDonations param is designed to include (or included with redact info) or exclude entries from the query. But the includeSensitiveData doesn't have the power to change the query, it only changes (include or remove) the properties from donations or donors that will be visible in the results.

So, as admin, I can:

  • Exclude anonymous donations/donors with sensitive data available

  • Exclude anonymous donations/donors without sensitive data available

  • Include anonymous donations/donors with sensitive data available

  • Include anonymous donations/donors without sensitive data available

  • Redact anonymous donations/donors with sensitive data available

  • Redact anonymous donations/donors without sensitive data available

The difference here between the sensitive data and the anonymous data is that the first one is too personal and can be used to contact/localize the donors so only admins can have access to it and the second one is more light info that the donor can choose display or not (Redact) - this last one change how the query is build.

@glaubersilva
Copy link
Contributor Author

@JasonTheAdams @jonwaldstein I think I implemented the most important things we discussed above and other items will be implemented in a subsequent PR as discussed here, so I updated the PR description and now this is ready for re-review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants