mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-27 21:03:29 +03:00
24681398af
refs https://ghost.slack.com/archives/CTH5NDJMS/p1675194453354469 https://secure.helpscout.net/conversation/2127479135/94711 - replaced tiers filtering by `slug` with `id` as we hit an edge case where the NQL filtering is struggling to handle slugs with a single character, eg `a`, `b`. Now filters by the tier's `id` instead. - added new member, `tier_id` relation to make it possible to filter by id, eg `/?filter=tier_id:<id>`.
339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
import faker from 'faker';
|
|
import moment from 'moment-timezone';
|
|
import nql from '@tryghost/nql';
|
|
import {Response} from 'miragejs';
|
|
import {
|
|
extractFilterParam,
|
|
paginateModelCollection,
|
|
withPermissionsCheck
|
|
} from '../utils';
|
|
import {underscore} from '@ember/string';
|
|
|
|
const ALLOWED_ROLES = [
|
|
'Owner',
|
|
'Administrator'
|
|
];
|
|
|
|
export function mockMembersStats(server) {
|
|
server.get('/members/stats/count', withPermissionsCheck(ALLOWED_ROLES, function (db, {queryParams}) {
|
|
let {days} = queryParams;
|
|
|
|
let firstSubscriberDays = faker.datatype.number({min: 30, max: 600});
|
|
|
|
if (days === 'all-time') {
|
|
days = firstSubscriberDays;
|
|
} else {
|
|
days = Number(days);
|
|
}
|
|
|
|
let total = 0;
|
|
if (firstSubscriberDays > days) {
|
|
total += faker.datatype.number({max: 1000});
|
|
}
|
|
|
|
// simulate sql GROUP BY where days with 0 subscribers are missing
|
|
let dateCounts = {};
|
|
let i = 0;
|
|
while (i < days) {
|
|
let date = moment().subtract(i, 'days').format('YYYY-MM-DD');
|
|
let count = faker.datatype.number({min: 0, max: 30});
|
|
|
|
if (count !== 0) {
|
|
dateCounts[date] = count;
|
|
}
|
|
|
|
i += 1;
|
|
}
|
|
|
|
// similar to what we'll need to do on the server
|
|
let totalOnDate = {};
|
|
let j = days - 1;
|
|
while (j >= 0) {
|
|
let date = moment().subtract(j, 'days').format('YYYY-MM-DD');
|
|
totalOnDate[date] = total + (dateCounts[date] || 0);
|
|
total += (dateCounts[date] || 0);
|
|
j -= 1;
|
|
}
|
|
|
|
return {
|
|
total,
|
|
resource: 'members',
|
|
data: Object.keys(totalOnDate).map((key, idx, arr) => {
|
|
return {
|
|
date: key,
|
|
free: arr[key],
|
|
paid: 0,
|
|
comped: 0
|
|
};
|
|
})
|
|
};
|
|
}));
|
|
}
|
|
|
|
export default function mockMembers(server) {
|
|
server.post('/members/', withPermissionsCheck(ALLOWED_ROLES, function ({members}) {
|
|
const attrs = this.normalizedRequestAttrs();
|
|
return members.create(attrs);
|
|
}));
|
|
|
|
server.get('/members/', withPermissionsCheck(ALLOWED_ROLES, function ({members}, {queryParams}) {
|
|
let {filter, search, page, limit} = queryParams;
|
|
page = +page || 1;
|
|
limit = +limit || 15;
|
|
|
|
let collection = members.all();
|
|
if (filter) {
|
|
try {
|
|
const nqlFilter = nql(filter, {
|
|
expansions: [
|
|
{
|
|
key: 'label',
|
|
replacement: 'labels.slug'
|
|
},
|
|
{
|
|
key: 'tier',
|
|
replacement: 'tiers.slug'
|
|
},
|
|
{
|
|
key: 'tier_id',
|
|
replacement: 'tiers.id'
|
|
},
|
|
{
|
|
key: 'offer_redemptions',
|
|
replacement: 'subscriptions.offer_id'
|
|
}
|
|
]
|
|
});
|
|
|
|
collection = collection.filter((member) => {
|
|
const serializedMember = {};
|
|
|
|
// mirage model keys match our main model keys so we need to transform
|
|
// camelCase to underscore to match the filter format
|
|
Object.keys(member.attrs).forEach((key) => {
|
|
serializedMember[underscore(key)] = member.attrs[key];
|
|
});
|
|
|
|
// similar deal for associated models
|
|
['labels', 'tiers', 'subscriptions', 'newsletters'].forEach((association) => {
|
|
serializedMember[association] = [];
|
|
member[association].models.forEach((associatedModel) => {
|
|
const serializedAssociation = {};
|
|
Object.keys(associatedModel.attrs).forEach((key) => {
|
|
serializedAssociation[underscore(key)] = associatedModel.attrs[key];
|
|
});
|
|
serializedMember[association].push(serializedAssociation);
|
|
});
|
|
});
|
|
return nqlFilter.queryJSON(serializedMember);
|
|
});
|
|
} catch (err) {
|
|
console.error(err); // eslint-disable-line
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (search) {
|
|
const query = search.toLowerCase();
|
|
collection = collection.filter((member) => {
|
|
return member.name.toLowerCase().indexOf(query) !== -1
|
|
|| member.email.toLowerCase().indexOf(query) !== -1;
|
|
});
|
|
}
|
|
|
|
return paginateModelCollection('members', collection, page, limit);
|
|
}));
|
|
|
|
server.del('/members/', withPermissionsCheck(ALLOWED_ROLES, function ({members}, {queryParams}) {
|
|
if (!queryParams.filter && !queryParams.search && queryParams.all !== 'true') {
|
|
return new Response(422, {}, {errors: [{
|
|
type: 'IncorrectUsageError',
|
|
message: 'DELETE /members/ must be used with a filter, search, or all=true query parameter'
|
|
}]});
|
|
}
|
|
|
|
let membersToDelete = members.all();
|
|
|
|
if (queryParams.filter) {
|
|
let labelFilter = extractFilterParam('label', queryParams.filter);
|
|
|
|
membersToDelete = membersToDelete.filter((member) => {
|
|
let matches = false;
|
|
labelFilter.forEach((slug) => {
|
|
if (member.labels.models.find(l => l.slug === slug)) {
|
|
matches = true;
|
|
}
|
|
});
|
|
return matches;
|
|
});
|
|
}
|
|
|
|
let count = membersToDelete.length;
|
|
membersToDelete.destroy();
|
|
|
|
return {
|
|
meta: {
|
|
stats: {
|
|
successful: count
|
|
}
|
|
}
|
|
};
|
|
}));
|
|
|
|
server.get('/members/:id/', withPermissionsCheck(ALLOWED_ROLES, function ({members}, {params}) {
|
|
let {id} = params;
|
|
let member = members.find(id);
|
|
|
|
return member || new Response(404, {}, {
|
|
errors: [{
|
|
type: 'NotFoundError',
|
|
message: 'Member not found.'
|
|
}]
|
|
});
|
|
}));
|
|
|
|
server.put('/members/:id/', withPermissionsCheck(ALLOWED_ROLES, function ({members, tiers, subscriptions}, {params}) {
|
|
const attrs = this.normalizedRequestAttrs();
|
|
const member = members.find(params.id);
|
|
|
|
// API accepts `tiers: [{id: 'x'}]` which isn't handled natively by mirage
|
|
if (attrs.tiers.length > 0) {
|
|
attrs.tiers.forEach((p) => {
|
|
const tier = tiers.find(p.id);
|
|
|
|
if (!member.tiers.includes(tier)) {
|
|
// TODO: serialize tiers through _active_ subscriptions
|
|
member.tiers.add(tier);
|
|
|
|
subscriptions.create({
|
|
member,
|
|
tier,
|
|
comped: true,
|
|
plan: {
|
|
id: '',
|
|
nickname: 'Complimentary',
|
|
interval: 'year',
|
|
currency: 'USD',
|
|
amount: 0
|
|
},
|
|
status: 'active',
|
|
startDate: moment().toISOString(),
|
|
defaultPaymentCardLast4: '****',
|
|
cancelAtPeriodEnd: false,
|
|
cancellationReason: null,
|
|
currentPeriodEnd: moment().add(1, 'year').toISOString(),
|
|
price: {
|
|
id: '',
|
|
price_id: '',
|
|
nickname: 'Complimentary',
|
|
amount: 0,
|
|
interval: 'year',
|
|
type: 'recurring',
|
|
currency: 'USD',
|
|
tier: {
|
|
id: '',
|
|
tier_id: tier.id
|
|
}
|
|
},
|
|
offer: null
|
|
});
|
|
|
|
member.save();
|
|
}
|
|
});
|
|
}
|
|
|
|
const tierIds = (attrs.tiers || []).map(p => p.id);
|
|
|
|
member.tiers.models.forEach((tier) => {
|
|
if (!tierIds.includes(tier.id)) {
|
|
member.subscriptions.models.filter(sub => sub.tier.id === tier.id).forEach((sub) => {
|
|
member.subscriptions.remove(sub);
|
|
});
|
|
|
|
member.tiers.remove(tier);
|
|
}
|
|
});
|
|
|
|
// these are read-only properties so make sure we don't overwrite data
|
|
delete attrs.tiers;
|
|
delete attrs.subscriptions;
|
|
|
|
return member.update(attrs);
|
|
}));
|
|
|
|
server.del('/members/:id/', withPermissionsCheck(ALLOWED_ROLES, function ({members}, request) {
|
|
const id = request.params.id;
|
|
members.find(id).destroy();
|
|
}));
|
|
|
|
server.get('/members/upload/', withPermissionsCheck(ALLOWED_ROLES, function () {
|
|
return new Response(200, {
|
|
'Content-Disposition': 'attachment',
|
|
filename: `members.${moment().format('YYYY-MM-DD')}.csv`,
|
|
'Content-Type': 'text/csv'
|
|
}, '');
|
|
}));
|
|
|
|
server.post('/members/upload/', withPermissionsCheck(ALLOWED_ROLES, function ({labels}, request) {
|
|
const label = labels.create();
|
|
|
|
// TODO: parse CSV and create member records
|
|
for (const kvPair of request.requestBody.entries()) {
|
|
const [key, value] = kvPair;
|
|
console.log({key, value}); // eslint-disable-line
|
|
}
|
|
|
|
return new Response(201, {}, {
|
|
meta: {
|
|
import_label: label,
|
|
stats: {imported: 1, invalid: []}
|
|
}
|
|
});
|
|
}));
|
|
|
|
server.get('/members/events/', withPermissionsCheck(ALLOWED_ROLES, function ({memberActivityEvents}, {queryParams}) {
|
|
let {limit, filter, page} = queryParams;
|
|
|
|
limit = +limit || 15;
|
|
page = +page || 1;
|
|
|
|
let collection = memberActivityEvents.all();
|
|
collection = collection.sort((a, b) => {
|
|
return Number(b.id) - Number(a.id);
|
|
});
|
|
|
|
if (filter) {
|
|
try {
|
|
const nqlFilter = nql(filter, {
|
|
expansions: [
|
|
{
|
|
key: 'data.created_at',
|
|
replacement: 'created_at'
|
|
}
|
|
]
|
|
});
|
|
|
|
collection = collection.filter((event) => {
|
|
const serializedEvent = {};
|
|
|
|
// mirage model keys match our main model keys, so we need to transform
|
|
// camelCase to underscore to match the filter format
|
|
Object.keys(event.attrs).forEach((key) => {
|
|
serializedEvent[underscore(key)] = event.attrs[key];
|
|
});
|
|
|
|
return nqlFilter.queryJSON(serializedEvent);
|
|
});
|
|
} catch (err) {
|
|
console.error(err); // eslint-disable-line
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return paginateModelCollection('members', collection, page, limit);
|
|
}));
|
|
|
|
mockMembersStats(server);
|
|
}
|