Added ?paid query parameter to Admin API members browse endpoint (#11892)

no issue

- NQL does not support the relationship setup that members->stripe customer<->stripe subscriptions uses so it wasn't possible to use the `filter` param to query against having an active subscription
- adds `customQuery` bookshelf plugin that allows customisation of SQL query used in `findPage` method by individual models
- use `customQuery` in Member model to set up joins and conditionals to select free/paid members when `options.paid` is present
- allow `?paid` param through API and permitted options for member model
This commit is contained in:
Kevin Ansfield 2020-06-12 12:12:10 +01:00 committed by GitHub
parent f1291058ec
commit f4d9a41d3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 153 additions and 20 deletions

View File

@ -131,7 +131,8 @@ const members = {
'order',
'debug',
'page',
'search'
'search',
'paid'
],
permissions: true,
validation: {},

View File

@ -36,6 +36,9 @@ ghostBookshelf.plugin('registry');
// Add committed/rollback events.
ghostBookshelf.plugin(plugins.transactionEvents);
// Load the Ghost custom-query plugin, which applying a custom query to findPage requests
ghostBookshelf.plugin(plugins.customQuery);
// Load the Ghost filter plugin, which handles applying a 'filter' to findPage requests
ghostBookshelf.plugin(plugins.filter);
@ -886,6 +889,9 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
// Set this to true or pass ?debug=true as an API option to get output
itemCollection.debug = options.debug && config.get('env') !== 'production';
// Apply model-specific query behaviour
itemCollection.applyCustomQuery(options);
// Add Filter behaviour
itemCollection.applyDefaultAndCustomFilters(options);

View File

@ -159,6 +159,40 @@ const Member = ghostBookshelf.Model.extend({
queryBuilder.orWhere('email', 'like', `%${query}%`);
},
// TODO: hacky way to filter by members with an active subscription,
// replace with a proper way to do this via filter param.
// NOTE: assumes members will have a single subscription
customQuery: function customQuery(queryBuilder, options) {
if (options.paid === true) {
queryBuilder.innerJoin(
'members_stripe_customers',
'members.id',
'members_stripe_customers.member_id'
);
queryBuilder.innerJoin(
'members_stripe_customers_subscriptions',
function () {
this.on(
'members_stripe_customers.customer_id',
'members_stripe_customers_subscriptions.customer_id'
).andOn(
'members_stripe_customers_subscriptions.status',
ghostBookshelf.knex.raw('?', ['active'])
);
}
);
}
if (options.paid === false) {
queryBuilder.leftJoin(
'members_stripe_customers',
'members.id',
'members_stripe_customers.member_id'
);
queryBuilder.whereNull('members_stripe_customers.member_id');
}
},
toJSON(unfilteredOptions) {
const options = Member.filterOptions(unfilteredOptions, 'toJSON');
const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
@ -184,7 +218,8 @@ const Member = ghostBookshelf.Model.extend({
let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
if (['findPage', 'findAll'].includes(methodName)) {
options = options.concat(['search']);
// TODO: remove 'paid' once it's possible to use in a filter
options = options.concat(['search', 'paid']);
}
return options;

View File

@ -0,0 +1,16 @@
const customQueryPlug = function customQueryPlug(Bookshelf) {
const Model = Bookshelf.Model.extend({
// override this on the model itself
customQuery() {},
applyCustomQuery: function applyCustomQuery(options) {
this.query((qb) => {
this.customQuery(qb, options);
});
}
});
Bookshelf.Model = Model;
};
module.exports = customQueryPlug;

View File

@ -1,5 +1,6 @@
module.exports = {
filter: require('./filter'),
customQuery: require('./custom-query'),
search: require('./search'),
includeCount: require('./include-count'),
pagination: require('./pagination'),

View File

@ -42,7 +42,7 @@ describe('Members API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(2);
jsonResponse.members.should.have.length(3);
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe');
testUtils.API.isISO8601(jsonResponse.members[0].created_at).should.be.true();
@ -51,7 +51,7 @@ describe('Members API', function () {
jsonResponse.meta.pagination.should.have.property('page', 1);
jsonResponse.meta.pagination.should.have.property('limit', 15);
jsonResponse.meta.pagination.should.have.property('pages', 1);
jsonResponse.meta.pagination.should.have.property('total', 2);
jsonResponse.meta.pagination.should.have.property('total', 3);
jsonResponse.meta.pagination.should.have.property('next', null);
jsonResponse.meta.pagination.should.have.property('prev', null);
});
@ -96,6 +96,26 @@ describe('Members API', function () {
});
});
it('Can browse with paid', function () {
return request
.get(localUtils.API.getApiQuery('members/?paid=true'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
jsonResponse.members[0].email.should.equal('paid@test.com');
localUtils.API.checkResponse(jsonResponse, 'members');
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe');
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
});
});
it('Can read', function () {
return request
.get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[0].id}/`))
@ -338,8 +358,8 @@ describe('Members API', function () {
should.exist(jsonResponse.total_on_date);
should.exist(jsonResponse.new_today);
// 2 from fixtures, 2 from above posts, 2 from above import
jsonResponse.total.should.equal(6);
// 3 from fixtures, 2 from above posts, 2 from above import
jsonResponse.total.should.equal(7);
});
});
});

View File

@ -273,8 +273,8 @@ describe('Members API', function () {
should.exist(jsonResponse.total_on_date);
should.exist(jsonResponse.new_today);
// 2 from fixtures and 5 imported in previous tests
jsonResponse.total.should.equal(6);
// 3 from fixtures and 5 imported in previous tests
jsonResponse.total.should.equal(7);
});
});
@ -297,8 +297,8 @@ describe('Members API', function () {
should.exist(jsonResponse.total_on_date);
should.exist(jsonResponse.new_today);
// 2 from fixtures and 5 imported in previous tests
jsonResponse.total.should.equal(6);
// 3 from fixtures and 5 imported in previous tests
jsonResponse.total.should.equal(7);
});
});
@ -321,8 +321,8 @@ describe('Members API', function () {
should.exist(jsonResponse.total_on_date);
should.exist(jsonResponse.new_today);
// 2 from fixtures and 5 imported in previous tests
jsonResponse.total.should.equal(6);
// 3 from fixtures and 5 imported in previous tests
jsonResponse.total.should.equal(7);
});
});

View File

@ -316,6 +316,11 @@ DataGenerator.Content = {
{
id: ObjectId.generate(),
email: 'member2@test.com'
},
{
id: ObjectId.generate(),
email: 'paid@test.com',
name: 'Egon Spengler'
}
],
@ -332,6 +337,34 @@ DataGenerator.Content = {
}
],
members_stripe_customers: [
{
id: ObjectId.generate(),
member_id: null, // relation added later
customer_id: 'cus_HR3tBmNhx4QsZY',
name: 'Egon Spengler',
email: 'paid@test.com'
}
],
members_stripe_customers_subscriptions: [
{
id: ObjectId.generate(),
customer_id: 'cus_HR3tBmNhx4QsZY',
subscription_id: 'sub_HR3tLNgGAHsa7b',
plan_id: '173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730bb8',
status: 'active',
cancel_at_period_end: false,
current_period_end: '2020-07-09 19:01:20',
start_date: '2020-06-09 19:01:20',
default_payment_card_last4: '4242',
plan_nickname: 'Monthly',
plan_interval: 'month',
plan_amount: '1000',
plan_currency: 'usd'
}
],
webhooks: [
{
id: ObjectId.generate(),
@ -411,6 +444,7 @@ DataGenerator.Content.api_keys[0].integration_id = DataGenerator.Content.integra
DataGenerator.Content.api_keys[1].integration_id = DataGenerator.Content.integrations[0].id;
DataGenerator.Content.emails[0].post_id = DataGenerator.Content.posts[0].id;
DataGenerator.Content.emails[1].post_id = DataGenerator.Content.posts[1].id;
DataGenerator.Content.members_stripe_customers[0].member_id = DataGenerator.Content.members[2].id;
DataGenerator.forKnex = (function () {
function createBasic(overrides) {
@ -875,7 +909,8 @@ DataGenerator.forKnex = (function () {
const members = [
createMember(DataGenerator.Content.members[0]),
createMember(DataGenerator.Content.members[1])
createMember(DataGenerator.Content.members[1]),
createMember(DataGenerator.Content.members[2])
];
const labels = [
@ -883,12 +918,18 @@ DataGenerator.forKnex = (function () {
];
const members_labels = [
{
id: ObjectId.generate(),
member_id: DataGenerator.Content.members[0].id,
label_id: DataGenerator.Content.labels[0].id,
sort_order: 0
}
createMembersLabels(
DataGenerator.Content.members[0].id,
DataGenerator.Content.labels[0].id
)
];
const members_stripe_customers = [
createBasic(DataGenerator.Content.members_stripe_customers[0])
];
const stripe_customer_subscriptions = [
createBasic(DataGenerator.Content.members_stripe_customers_subscriptions[0])
];
return {
@ -909,6 +950,8 @@ DataGenerator.forKnex = (function () {
createMember,
createLabel,
createMembersLabels,
createMembersStripeCustomer: createBasic,
createStripeCustomerSubscription: createBasic,
createInvite,
createWebhook,
createIntegration,
@ -927,7 +970,9 @@ DataGenerator.forKnex = (function () {
emails,
labels,
members,
members_labels
members_labels,
members_stripe_customers,
stripe_customer_subscriptions
};
}());

View File

@ -483,8 +483,17 @@ fixtures = {
});
member.labels = memberLabelRelations;
return models.Member.add(member, module.exports.context.internal);
});
}).then(function () {
return Promise.each(_.cloneDeep(DataGenerator.forKnex.members_stripe_customers), function (customer) {
return models.MemberStripeCustomer.add(customer, module.exports.context.internal);
});
}).then(function () {
return Promise.each(_.cloneDeep(DataGenerator.forKnex.stripe_customer_subscriptions), function (subscription) {
return models.StripeCustomerSubscription.add(subscription, module.exports.context.internal);
});
});
}
};