mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-03 23:00:14 -05:00
Removed pagination from members stats endpoint and added extra day to output (#14429)
This commit is contained in:
parent
0581314796
commit
1957b5b789
3 changed files with 233 additions and 137 deletions
|
@ -28,8 +28,8 @@ class MembersStatsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the member deltas by status for all days (from new to old)
|
* Get the member deltas by status for all days, sorted ascending
|
||||||
* @returns {Promise<MemberStatusDelta[]>} The deltas of paid, free and comped users per day, sorted from new to old
|
* @returns {Promise<MemberStatusDelta[]>} The deltas of paid, free and comped users per day, sorted ascending
|
||||||
*/
|
*/
|
||||||
async fetchAllStatusDeltas() {
|
async fetchAllStatusDeltas() {
|
||||||
const knex = this.db.knex;
|
const knex = this.db.knex;
|
||||||
|
@ -54,7 +54,7 @@ class MembersStatsService {
|
||||||
ELSE 0 END
|
ELSE 0 END
|
||||||
) as free_delta`))
|
) as free_delta`))
|
||||||
.groupByRaw('DATE(created_at)')
|
.groupByRaw('DATE(created_at)')
|
||||||
.orderByRaw('DATE(created_at) DESC');
|
.orderByRaw('DATE(created_at)');
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,7 +73,11 @@ class MembersStatsService {
|
||||||
const today = DateTime.local().toISODate();
|
const today = DateTime.local().toISODate();
|
||||||
|
|
||||||
const cumulativeResults = [];
|
const cumulativeResults = [];
|
||||||
for (const row of rows) {
|
|
||||||
|
// Loop in reverse order (needed to have correct sorted result)
|
||||||
|
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||||
|
const row = rows[i];
|
||||||
|
|
||||||
// Convert JSDates to YYYY-MM-DD (in UTC)
|
// Convert JSDates to YYYY-MM-DD (in UTC)
|
||||||
const date = DateTime.fromJSDate(row.date).toISODate();
|
const date = DateTime.fromJSDate(row.date).toISODate();
|
||||||
if (date > today) {
|
if (date > today) {
|
||||||
|
@ -82,9 +86,9 @@ class MembersStatsService {
|
||||||
}
|
}
|
||||||
cumulativeResults.unshift({
|
cumulativeResults.unshift({
|
||||||
date,
|
date,
|
||||||
paid,
|
paid: Math.max(0, paid),
|
||||||
free,
|
free: Math.max(0, free),
|
||||||
comped,
|
comped: Math.max(0, comped),
|
||||||
|
|
||||||
// Deltas
|
// Deltas
|
||||||
paid_subscribed: row.paid_subscribed,
|
paid_subscribed: row.paid_subscribed,
|
||||||
|
@ -92,36 +96,28 @@ class MembersStatsService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update current counts
|
// Update current counts
|
||||||
paid = Math.max(0, paid - row.paid_subscribed + row.paid_canceled);
|
paid -= row.paid_subscribed - row.paid_canceled;
|
||||||
free = Math.max(0, free - row.free_delta);
|
free -= row.free_delta;
|
||||||
comped = Math.max(0, comped - row.comped_delta);
|
comped -= row.comped_delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always make sure we have at least one result
|
// Now also add the oldest day we have left over (this one will be zero, which is also needed as a data point for graphs)
|
||||||
if (cumulativeResults.length === 0) {
|
const oldestDate = rows.length > 0 ? DateTime.fromJSDate(rows[0].date).plus({days: -1}).toISODate() : today;
|
||||||
cumulativeResults.push({
|
|
||||||
date: today,
|
|
||||||
paid,
|
|
||||||
free,
|
|
||||||
comped,
|
|
||||||
|
|
||||||
// Deltas
|
cumulativeResults.unshift({
|
||||||
paid_subscribed: 0,
|
date: oldestDate,
|
||||||
paid_canceled: 0
|
paid: Math.max(0, paid),
|
||||||
});
|
free: Math.max(0, free),
|
||||||
}
|
comped: Math.max(0, comped),
|
||||||
|
|
||||||
|
// Deltas
|
||||||
|
paid_subscribed: 0,
|
||||||
|
paid_canceled: 0
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: cumulativeResults,
|
data: cumulativeResults,
|
||||||
meta: {
|
meta: {
|
||||||
pagination: {
|
|
||||||
page: 1,
|
|
||||||
limit: 'all',
|
|
||||||
pages: 1,
|
|
||||||
total: cumulativeResults.length,
|
|
||||||
next: null,
|
|
||||||
prev: null
|
|
||||||
},
|
|
||||||
totals
|
totals
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,14 +3,6 @@
|
||||||
exports[`Stats API Can fetch member count history 1: [body] 1`] = `
|
exports[`Stats API Can fetch member count history 1: [body] 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"meta": Object {
|
"meta": Object {
|
||||||
"pagination": Object {
|
|
||||||
"limit": "all",
|
|
||||||
"next": null,
|
|
||||||
"page": 1,
|
|
||||||
"pages": 1,
|
|
||||||
"prev": null,
|
|
||||||
"total": 1,
|
|
||||||
},
|
|
||||||
"totals": Object {
|
"totals": Object {
|
||||||
"comped": 0,
|
"comped": 0,
|
||||||
"free": 3,
|
"free": 3,
|
||||||
|
@ -34,7 +26,7 @@ exports[`Stats API Can fetch member count history 2: [headers] 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
"content-length": "231",
|
"content-length": "149",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
"vary": "Origin, Accept-Encoding",
|
"vary": "Origin, Accept-Encoding",
|
||||||
|
|
|
@ -16,19 +16,28 @@ describe('MembersStatsService', function () {
|
||||||
/**
|
/**
|
||||||
* @type {MembersStatsService.MemberStatusDelta[]}
|
* @type {MembersStatsService.MemberStatusDelta[]}
|
||||||
*/
|
*/
|
||||||
const events = [];
|
let events = [];
|
||||||
const today = '2000-01-10';
|
const today = '2000-01-10';
|
||||||
const tomorrow = '2000-01-11';
|
const tomorrow = '2000-01-11';
|
||||||
const yesterday = '2000-01-09';
|
const yesterday = '2000-01-09';
|
||||||
|
const dayBeforeYesterday = '2000-01-08';
|
||||||
|
const twoDaysBeforeYesterday = '2000-01-07';
|
||||||
const todayDate = DateTime.fromISO(today).toJSDate();
|
const todayDate = DateTime.fromISO(today).toJSDate();
|
||||||
const tomorrowDate = DateTime.fromISO(tomorrow).toJSDate();
|
const tomorrowDate = DateTime.fromISO(tomorrow).toJSDate();
|
||||||
const yesterdayDate = DateTime.fromISO(yesterday).toJSDate();
|
const yesterdayDate = DateTime.fromISO(yesterday).toJSDate();
|
||||||
|
const dayBeforeYesterdayDate = DateTime.fromISO(dayBeforeYesterday).toJSDate();
|
||||||
|
|
||||||
before(function () {
|
before(function () {
|
||||||
sinon.useFakeTimers(todayDate.getTime());
|
sinon.useFakeTimers(todayDate.getTime());
|
||||||
membersStatsService = new MembersStatsService({db: null});
|
membersStatsService = new MembersStatsService({db: null});
|
||||||
fakeTotal = sinon.stub(membersStatsService, 'getCount').resolves(currentCounts);
|
fakeTotal = sinon.stub(membersStatsService, 'getCount').resolves(currentCounts);
|
||||||
fakeStatuses = sinon.stub(membersStatsService, 'fetchAllStatusDeltas').resolves(events);
|
fakeStatuses = sinon.stub(membersStatsService, 'fetchAllStatusDeltas').callsFake(() => {
|
||||||
|
// Sort here ascending to mimic same ordering
|
||||||
|
events.sort((a, b) => {
|
||||||
|
return a.date < b.date ? -1 : 1;
|
||||||
|
});
|
||||||
|
return Promise.resolve(events);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function () {
|
||||||
|
@ -38,7 +47,7 @@ describe('MembersStatsService', function () {
|
||||||
|
|
||||||
it('Always returns at least one value', async function () {
|
it('Always returns at least one value', async function () {
|
||||||
// No status events
|
// No status events
|
||||||
events.splice(0, events.length);
|
events = [];
|
||||||
currentCounts.paid = 1;
|
currentCounts.paid = 1;
|
||||||
currentCounts.free = 2;
|
currentCounts.free = 2;
|
||||||
currentCounts.comped = 3;
|
currentCounts.comped = 3;
|
||||||
|
@ -61,108 +70,15 @@ describe('MembersStatsService', function () {
|
||||||
|
|
||||||
it('Passes paid_subscribers and paid_canceled', async function () {
|
it('Passes paid_subscribers and paid_canceled', async function () {
|
||||||
// Update faked status events
|
// Update faked status events
|
||||||
events.splice(0, events.length, {
|
events = [
|
||||||
date: todayDate,
|
|
||||||
paid_subscribed: 4,
|
|
||||||
paid_canceled: 3,
|
|
||||||
free_delta: 2,
|
|
||||||
comped_delta: 3
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update current faked counts
|
|
||||||
currentCounts.paid = 1;
|
|
||||||
currentCounts.free = 2;
|
|
||||||
currentCounts.comped = 3;
|
|
||||||
|
|
||||||
const {data: results, meta} = await membersStatsService.getCountHistory();
|
|
||||||
results.length.should.eql(1);
|
|
||||||
results[0].should.eql({
|
|
||||||
date: today,
|
|
||||||
paid: 1,
|
|
||||||
free: 2,
|
|
||||||
comped: 3,
|
|
||||||
paid_subscribed: 4,
|
|
||||||
paid_canceled: 3
|
|
||||||
});
|
|
||||||
meta.totals.should.eql(currentCounts);
|
|
||||||
|
|
||||||
fakeStatuses.calledOnce.should.eql(true);
|
|
||||||
fakeTotal.calledOnce.should.eql(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Correctly resolves deltas', async function () {
|
|
||||||
// Update faked status events
|
|
||||||
events.splice(0, events.length,
|
|
||||||
{
|
{
|
||||||
date: todayDate,
|
date: todayDate,
|
||||||
paid_subscribed: 4,
|
paid_subscribed: 4,
|
||||||
paid_canceled: 3,
|
paid_canceled: 3,
|
||||||
free_delta: 2,
|
free_delta: 2,
|
||||||
comped_delta: 3
|
comped_delta: 3
|
||||||
},
|
|
||||||
{
|
|
||||||
date: yesterdayDate,
|
|
||||||
paid_subscribed: 2,
|
|
||||||
paid_canceled: 1,
|
|
||||||
free_delta: 0,
|
|
||||||
comped_delta: 0
|
|
||||||
}
|
}
|
||||||
);
|
];
|
||||||
|
|
||||||
// Update current faked counts
|
|
||||||
currentCounts.paid = 2;
|
|
||||||
currentCounts.free = 3;
|
|
||||||
currentCounts.comped = 4;
|
|
||||||
|
|
||||||
const {data: results, meta} = await membersStatsService.getCountHistory();
|
|
||||||
results.should.eql([
|
|
||||||
{
|
|
||||||
date: yesterday,
|
|
||||||
paid: 1,
|
|
||||||
free: 1,
|
|
||||||
comped: 1,
|
|
||||||
paid_subscribed: 2,
|
|
||||||
paid_canceled: 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: today,
|
|
||||||
paid: 2,
|
|
||||||
free: 3,
|
|
||||||
comped: 4,
|
|
||||||
paid_subscribed: 4,
|
|
||||||
paid_canceled: 3
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
meta.totals.should.eql(currentCounts);
|
|
||||||
fakeStatuses.calledOnce.should.eql(true);
|
|
||||||
fakeTotal.calledOnce.should.eql(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Ignores events in the future', async function () {
|
|
||||||
// Update faked status events
|
|
||||||
events.splice(0, events.length,
|
|
||||||
{
|
|
||||||
date: tomorrowDate,
|
|
||||||
paid_subscribed: 10,
|
|
||||||
paid_canceled: 5,
|
|
||||||
free_delta: 8,
|
|
||||||
comped_delta: 9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: todayDate,
|
|
||||||
paid_subscribed: 4,
|
|
||||||
paid_canceled: 3,
|
|
||||||
free_delta: 2,
|
|
||||||
comped_delta: 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: yesterdayDate,
|
|
||||||
paid_subscribed: 0,
|
|
||||||
paid_canceled: 0,
|
|
||||||
free_delta: 0,
|
|
||||||
comped_delta: 0
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update current faked counts
|
// Update current faked counts
|
||||||
currentCounts.paid = 1;
|
currentCounts.paid = 1;
|
||||||
|
@ -192,5 +108,197 @@ describe('MembersStatsService', function () {
|
||||||
fakeStatuses.calledOnce.should.eql(true);
|
fakeStatuses.calledOnce.should.eql(true);
|
||||||
fakeTotal.calledOnce.should.eql(true);
|
fakeTotal.calledOnce.should.eql(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Correctly resolves deltas', async function () {
|
||||||
|
// Update faked status events
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
date: yesterdayDate,
|
||||||
|
paid_subscribed: 2,
|
||||||
|
paid_canceled: 1,
|
||||||
|
free_delta: 0,
|
||||||
|
comped_delta: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: todayDate,
|
||||||
|
paid_subscribed: 4,
|
||||||
|
paid_canceled: 3,
|
||||||
|
free_delta: 2,
|
||||||
|
comped_delta: 3
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Update current faked counts
|
||||||
|
currentCounts.paid = 2;
|
||||||
|
currentCounts.free = 3;
|
||||||
|
currentCounts.comped = 4;
|
||||||
|
|
||||||
|
const {data: results, meta} = await membersStatsService.getCountHistory();
|
||||||
|
results.should.eql([
|
||||||
|
{
|
||||||
|
date: dayBeforeYesterday,
|
||||||
|
paid: 0,
|
||||||
|
free: 1,
|
||||||
|
comped: 1,
|
||||||
|
paid_subscribed: 0,
|
||||||
|
paid_canceled: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: yesterday,
|
||||||
|
paid: 1,
|
||||||
|
free: 1,
|
||||||
|
comped: 1,
|
||||||
|
paid_subscribed: 2,
|
||||||
|
paid_canceled: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: today,
|
||||||
|
paid: 2,
|
||||||
|
free: 3,
|
||||||
|
comped: 4,
|
||||||
|
paid_subscribed: 4,
|
||||||
|
paid_canceled: 3
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
meta.totals.should.eql(currentCounts);
|
||||||
|
fakeStatuses.calledOnce.should.eql(true);
|
||||||
|
fakeTotal.calledOnce.should.eql(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Correctly handles negative numbers', async function () {
|
||||||
|
// Update faked status events
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
date: dayBeforeYesterdayDate,
|
||||||
|
paid_subscribed: 2,
|
||||||
|
paid_canceled: 1,
|
||||||
|
free_delta: 2,
|
||||||
|
comped_delta: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: yesterdayDate,
|
||||||
|
paid_subscribed: 2,
|
||||||
|
paid_canceled: 1,
|
||||||
|
free_delta: -100,
|
||||||
|
comped_delta: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: todayDate,
|
||||||
|
paid_subscribed: 4,
|
||||||
|
paid_canceled: 3,
|
||||||
|
free_delta: 100,
|
||||||
|
comped_delta: 3
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Update current faked counts
|
||||||
|
currentCounts.paid = 2;
|
||||||
|
currentCounts.free = 3;
|
||||||
|
currentCounts.comped = 4;
|
||||||
|
|
||||||
|
const {data: results, meta} = await membersStatsService.getCountHistory();
|
||||||
|
results.should.eql([
|
||||||
|
{
|
||||||
|
date: twoDaysBeforeYesterday,
|
||||||
|
paid: 0,
|
||||||
|
free: 1,
|
||||||
|
comped: 0,
|
||||||
|
paid_subscribed: 0,
|
||||||
|
paid_canceled: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: dayBeforeYesterday,
|
||||||
|
paid: 0,
|
||||||
|
// note that this shouldn't be 100 (which is also what we test here):
|
||||||
|
free: 3,
|
||||||
|
comped: 1,
|
||||||
|
paid_subscribed: 2,
|
||||||
|
paid_canceled: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: yesterday,
|
||||||
|
paid: 1,
|
||||||
|
// never return negative numbers, this is in fact -997:
|
||||||
|
free: 0,
|
||||||
|
comped: 1,
|
||||||
|
paid_subscribed: 2,
|
||||||
|
paid_canceled: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: today,
|
||||||
|
paid: 2,
|
||||||
|
free: 3,
|
||||||
|
comped: 4,
|
||||||
|
paid_subscribed: 4,
|
||||||
|
paid_canceled: 3
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
meta.totals.should.eql(currentCounts);
|
||||||
|
fakeStatuses.calledOnce.should.eql(true);
|
||||||
|
fakeTotal.calledOnce.should.eql(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ignores events in the future', async function () {
|
||||||
|
// Update faked status events
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
date: yesterdayDate,
|
||||||
|
paid_subscribed: 1,
|
||||||
|
paid_canceled: 0,
|
||||||
|
free_delta: 1,
|
||||||
|
comped_delta: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: todayDate,
|
||||||
|
paid_subscribed: 4,
|
||||||
|
paid_canceled: 3,
|
||||||
|
free_delta: 2,
|
||||||
|
comped_delta: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: tomorrowDate,
|
||||||
|
paid_subscribed: 10,
|
||||||
|
paid_canceled: 5,
|
||||||
|
free_delta: 8,
|
||||||
|
comped_delta: 9
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Update current faked counts
|
||||||
|
currentCounts.paid = 1;
|
||||||
|
currentCounts.free = 2;
|
||||||
|
currentCounts.comped = 3;
|
||||||
|
|
||||||
|
const {data: results, meta} = await membersStatsService.getCountHistory();
|
||||||
|
results.should.eql([
|
||||||
|
{
|
||||||
|
date: dayBeforeYesterday,
|
||||||
|
paid: 0,
|
||||||
|
free: 0,
|
||||||
|
comped: 0,
|
||||||
|
paid_subscribed: 0,
|
||||||
|
paid_canceled: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: yesterday,
|
||||||
|
paid: 0,
|
||||||
|
free: 0,
|
||||||
|
comped: 0,
|
||||||
|
paid_subscribed: 1,
|
||||||
|
paid_canceled: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: today,
|
||||||
|
paid: 1,
|
||||||
|
free: 2,
|
||||||
|
comped: 3,
|
||||||
|
paid_subscribed: 4,
|
||||||
|
paid_canceled: 3
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
meta.totals.should.eql(currentCounts);
|
||||||
|
fakeStatuses.calledOnce.should.eql(true);
|
||||||
|
fakeTotal.calledOnce.should.eql(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue