mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 14:03:48 +03:00
f1bcb073a3
closes https://github.com/TryGhost/Team/issues/1265 - we were generating the correct query params for the export endpoint but those query params weren't applied to the export URL before fetching it meaning the default GET behaviour of listing first 15 members was always being used when generating the backup CSV
557 lines
16 KiB
JavaScript
557 lines
16 KiB
JavaScript
import Controller from '@ember/controller';
|
|
import config from 'ghost-admin/config/environment';
|
|
import fetch from 'fetch';
|
|
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
|
import moment from 'moment';
|
|
import {A} from '@ember/array';
|
|
import {action} from '@ember/object';
|
|
import {capitalize} from '@ember/string';
|
|
import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize';
|
|
import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params';
|
|
import {inject as service} from '@ember/service';
|
|
import {task} from 'ember-concurrency-decorators';
|
|
import {timeout} from 'ember-concurrency';
|
|
import {tracked} from '@glimmer/tracking';
|
|
|
|
const PAID_PARAMS = [{
|
|
name: 'All members',
|
|
value: null
|
|
}, {
|
|
name: 'Free members',
|
|
value: 'false'
|
|
}, {
|
|
name: 'Paid members',
|
|
value: 'true'
|
|
}];
|
|
|
|
export default class MembersController extends Controller {
|
|
@service ajax;
|
|
@service config;
|
|
@service ellaSparse;
|
|
@service feature;
|
|
@service ghostPaths;
|
|
@service membersStats;
|
|
@service router;
|
|
@service store;
|
|
@service utils;
|
|
|
|
queryParams = [
|
|
'label',
|
|
{paidParam: 'paid'},
|
|
{searchParam: 'search'},
|
|
{orderParam: 'order'},
|
|
{filterParam: 'filter'}
|
|
];
|
|
|
|
@tracked members = A([]);
|
|
@tracked searchText = '';
|
|
@tracked searchParam = '';
|
|
@tracked filterParam = null;
|
|
@tracked softFilterParam = null;
|
|
@tracked paidParam = null;
|
|
@tracked label = null;
|
|
@tracked orderParam = null;
|
|
@tracked modalLabel = null;
|
|
@tracked showLabelModal = false;
|
|
@tracked showDeleteMembersModal = false;
|
|
@tracked showUnsubscribeMembersModal = false;
|
|
@tracked showAddMembersLabelModal = false;
|
|
@tracked showRemoveMembersLabelModal = false;
|
|
@tracked filters = A([]);
|
|
@tracked softFilters = A([]);
|
|
|
|
@tracked _availableLabels = A([]);
|
|
|
|
paidParams = PAID_PARAMS;
|
|
|
|
constructor() {
|
|
super(...arguments);
|
|
this._availableLabels = this.store.peekAll('label');
|
|
|
|
if (this.isTesting === undefined) {
|
|
this.isTesting = config.environment === 'test';
|
|
}
|
|
}
|
|
|
|
// Computed properties -----------------------------------------------------
|
|
|
|
get listHeader() {
|
|
let {searchText, selectedLabel, members} = this;
|
|
|
|
if (members.loading) {
|
|
return 'Loading...';
|
|
}
|
|
|
|
if (searchText) {
|
|
return 'Search result';
|
|
}
|
|
|
|
let count = ghPluralize(members.length, 'member');
|
|
|
|
if (selectedLabel && selectedLabel.slug) {
|
|
if (members.length > 1) {
|
|
return `${count} match current filter`;
|
|
} else {
|
|
return `${count} matches current filter`;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
get showingAll() {
|
|
return !this.searchParam && !this.paidParam && !this.label && !this.filterParam && !this.softFilterParam;
|
|
}
|
|
|
|
get availableOrders() {
|
|
// don't return anything if email analytics is disabled because
|
|
// we don't want to show an order dropdown with only a single option
|
|
|
|
if (this.feature.get('emailAnalytics')) {
|
|
return [{
|
|
name: 'Newest',
|
|
value: null
|
|
}, {
|
|
name: 'Open rate',
|
|
value: 'email_open_rate'
|
|
}];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
get selectedOrder() {
|
|
return this.availableOrders.find(order => order.value === this.orderParam);
|
|
}
|
|
|
|
get availableLabels() {
|
|
let labels = this._availableLabels
|
|
.filter(label => !label.isNew)
|
|
.filter(label => label.id !== null)
|
|
.sort((labelA, labelB) => labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true}));
|
|
let options = labels.toArray();
|
|
|
|
options.unshiftObject({name: 'All labels', slug: null});
|
|
|
|
return options;
|
|
}
|
|
|
|
get selectedLabel() {
|
|
let {label, availableLabels} = this;
|
|
return availableLabels.findBy('slug', label);
|
|
}
|
|
|
|
get labelModalData() {
|
|
let label = this.modalLabel;
|
|
let labels = this.availableLabels;
|
|
|
|
return {
|
|
label,
|
|
labels
|
|
};
|
|
}
|
|
|
|
get selectedPaidParam() {
|
|
return this.paidParams.findBy('value', this.paidParam) || {value: '!unknown'};
|
|
}
|
|
|
|
get isFiltered() {
|
|
return !!(this.label || this.paidParam || this.searchParam || this.filterParam);
|
|
}
|
|
|
|
get filterColumns() {
|
|
const defaultColumns = ['name', 'email'];
|
|
const availableFilters = this.filters.length ? this.filters : this.softFilters;
|
|
return availableFilters.map((filter) => {
|
|
return filter.type;
|
|
}).filter((f, idx, arr) => {
|
|
return arr.indexOf(f) === idx;
|
|
}).filter(d => !defaultColumns.includes(d));
|
|
}
|
|
|
|
get filterColumnLabels() {
|
|
const filterColumnLabelMap = {
|
|
'subscriptions.plan_interval': 'Billing period',
|
|
subscribed: 'Subscribed to email',
|
|
'subscriptions.status': 'Subscription Status'
|
|
};
|
|
return this.filterColumns.map((d) => {
|
|
return filterColumnLabelMap[d] ? filterColumnLabelMap[d] : capitalize(d.replace(/_/g, ' '));
|
|
});
|
|
}
|
|
|
|
getApiQueryObject({params, extraFilters = []} = {}) {
|
|
let {label, paidParam, searchParam, filterParam} = params ? params : this;
|
|
|
|
let filters = [];
|
|
|
|
filters = filters.concat(extraFilters);
|
|
|
|
if (label) {
|
|
filters.push(`label:'${label}'`);
|
|
}
|
|
|
|
if (paidParam !== null) {
|
|
if (paidParam === 'true') {
|
|
filters.push('status:-free');
|
|
} else {
|
|
filters.push('status:free');
|
|
}
|
|
}
|
|
if (filterParam) {
|
|
filters.push(filterParam);
|
|
}
|
|
|
|
let searchQuery = searchParam ? {search: searchParam} : {};
|
|
|
|
return Object.assign({}, {filter: filters.join('+')}, searchQuery);
|
|
}
|
|
|
|
// Actions -----------------------------------------------------------------
|
|
|
|
@action
|
|
refreshData() {
|
|
this.fetchMembersTask.perform();
|
|
this.fetchLabelsTask.perform();
|
|
this.membersStats.invalidate();
|
|
this.membersStats.fetchCounts();
|
|
}
|
|
|
|
@action
|
|
changeOrder(order) {
|
|
this.orderParam = order.value;
|
|
}
|
|
|
|
@action
|
|
applyFilter(filterStr, filters) {
|
|
this.softFilters = A([]);
|
|
this.filterParam = filterStr || null;
|
|
this.filters = filters;
|
|
}
|
|
|
|
@action
|
|
applySoftFilter(filterStr, filters) {
|
|
this.softFilters = filters;
|
|
this.softFilterParam = filterStr || null;
|
|
let {label, paidParam, searchParam, orderParam} = this;
|
|
this.fetchMembersTask.perform({label, paidParam, searchParam, orderParam, filterParam: filterStr});
|
|
}
|
|
|
|
@action
|
|
resetSoftFilter() {
|
|
if (this.softFilters.length > 0 || !!this.softFilterParam) {
|
|
this.softFilters = A([]);
|
|
this.softFilterParam = null;
|
|
this.fetchMembersTask.perform();
|
|
}
|
|
}
|
|
|
|
@action
|
|
resetFilter() {
|
|
this.softFilters = A([]);
|
|
this.softFilterParam = null;
|
|
this.filters = A([]);
|
|
this.filterParam = null;
|
|
this.fetchMembersTask.perform();
|
|
}
|
|
|
|
@action
|
|
search(e) {
|
|
this.searchTask.perform(e.target.value);
|
|
}
|
|
|
|
@action
|
|
exportData() {
|
|
let exportUrl = ghostPaths().url.api('members/upload');
|
|
let downloadParams = new URLSearchParams(this.getApiQueryObject());
|
|
downloadParams.set('limit', 'all');
|
|
|
|
this.utils.downloadFile(`${exportUrl}?${downloadParams.toString()}`);
|
|
}
|
|
|
|
@action
|
|
changeLabel(label, e) {
|
|
if (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
this.label = label.slug;
|
|
}
|
|
|
|
@action
|
|
addLabel(e) {
|
|
if (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
const newLabel = this.store.createRecord('label');
|
|
this.modalLabel = newLabel;
|
|
this.showLabelModal = !this.showLabelModal;
|
|
}
|
|
|
|
@action
|
|
editLabel(label, e) {
|
|
if (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
let modalLabel = this.availableLabels.findBy('slug', label);
|
|
this.modalLabel = modalLabel;
|
|
this.showLabelModal = !this.showLabelModal;
|
|
}
|
|
|
|
@action
|
|
toggleLabelModal() {
|
|
this.showLabelModal = !this.showLabelModal;
|
|
}
|
|
|
|
@action
|
|
changePaidParam(paid) {
|
|
this.paidParam = paid.value;
|
|
}
|
|
|
|
@action
|
|
toggleDeleteMembersModal() {
|
|
this.showDeleteMembersModal = !this.showDeleteMembersModal;
|
|
}
|
|
|
|
@action
|
|
toggleUnsubscribeMembersModal() {
|
|
this.showUnsubscribeMembersModal = !this.showUnsubscribeMembersModal;
|
|
}
|
|
|
|
@action
|
|
toggleAddMembersLabelModal() {
|
|
this.showAddMembersLabelModal = !this.showAddMembersLabelModal;
|
|
}
|
|
|
|
@action
|
|
toggleRemoveMembersLabelModal() {
|
|
this.showRemoveMembersLabelModal = !this.showRemoveMembersLabelModal;
|
|
}
|
|
|
|
@action
|
|
deleteMembers() {
|
|
return this.deleteMembersTask.perform();
|
|
}
|
|
|
|
@action
|
|
unsubscribeMembers() {
|
|
return this.unsubscribeMembersTask.perform();
|
|
}
|
|
|
|
@action
|
|
addLabelToMembers(selectedLabel) {
|
|
return this.addLabelToMembersTask.perform(selectedLabel);
|
|
}
|
|
|
|
@action
|
|
removeLabelFromMembers(selectedLabel) {
|
|
return this.removeLabelFromMembersTask.perform(selectedLabel);
|
|
}
|
|
|
|
// Tasks -------------------------------------------------------------------
|
|
|
|
@task({restartable: true})
|
|
*searchTask(query) {
|
|
yield timeout(250); // debounce
|
|
this.searchParam = query;
|
|
}
|
|
|
|
@task
|
|
*fetchLabelsTask() {
|
|
yield this.store.query('label', {limit: 'all'});
|
|
}
|
|
|
|
@task({restartable: true})
|
|
*fetchMembersTask(params) {
|
|
// params is undefined when called as a "refresh" of the model
|
|
let {label, paidParam, searchParam, orderParam, filterParam} = typeof params === 'undefined' ? this : params;
|
|
|
|
if (!searchParam) {
|
|
this.resetSearch();
|
|
}
|
|
|
|
// use a fixed created_at date so that subsequent pages have a consistent index
|
|
let startDate = new Date();
|
|
|
|
// bypass the stale data shortcut if params change
|
|
let forceReload = !params
|
|
|| label !== this._lastLabel
|
|
|| paidParam !== this._lastPaidParam
|
|
|| searchParam !== this._lastSearchParam
|
|
|| orderParam !== this._lastOrderParam
|
|
|| filterParam !== this._lastFilterParam;
|
|
this._lastLabel = label;
|
|
this._lastPaidParam = paidParam;
|
|
this._lastSearchParam = searchParam;
|
|
this._lastOrderParam = orderParam;
|
|
this._lastFilterParam = filterParam;
|
|
|
|
// unless we have a forced reload, do not re-fetch the members list unless it's more than a minute old
|
|
// keeps navigation between list->details->list snappy
|
|
if (!forceReload && this._startDate && !(this._startDate - startDate > 1 * 60 * 1000)) {
|
|
return this.members;
|
|
}
|
|
|
|
this._startDate = startDate;
|
|
|
|
this.members = yield this.ellaSparse.array((range = {}, query = {}) => {
|
|
const searchQuery = this.getApiQueryObject({
|
|
params,
|
|
extraFilters: [`created_at:<='${moment.utc(this._startDate).format('YYYY-MM-DD HH:mm:ss')}'`]
|
|
});
|
|
const order = orderParam ? `${orderParam} desc` : `created_at desc`;
|
|
|
|
query = Object.assign({
|
|
order,
|
|
limit: range.length,
|
|
page: range.page
|
|
}, searchQuery, query);
|
|
|
|
return this.store.query('member', query).then((result) => {
|
|
return {
|
|
data: result,
|
|
total: result.meta.pagination.total
|
|
};
|
|
});
|
|
}, {
|
|
limit: 50
|
|
});
|
|
}
|
|
|
|
@task({drop: true})
|
|
*deleteMembersTask() {
|
|
const query = new URLSearchParams(this.getApiQueryObject());
|
|
|
|
// Trigger download before deleting. Uses the CSV export endpoint but
|
|
// needs to fetch the file and trigger a download directly rather than
|
|
// via an iframe. The iframe approach can't tell us when a download has
|
|
// started/finished meaning we could end up deleting the data before exporting it
|
|
const exportParams = new URLSearchParams(this.getApiQueryObject());
|
|
exportParams.set('limit', 'all');
|
|
const exportUrl = `${ghostPaths().url.api('members/upload')}?${exportParams.toString()}`;
|
|
|
|
yield fetch(exportUrl, {method: 'GET'})
|
|
.then(res => res.blob())
|
|
.then((blob) => {
|
|
const blobUrl = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = `members.${moment().format('YYYY-MM-DD')}.csv`;
|
|
document.body.appendChild(a);
|
|
if (!this.isTesting) {
|
|
a.click();
|
|
}
|
|
a.remove();
|
|
URL.revokeObjectURL(blobUrl);
|
|
});
|
|
|
|
// backup downloaded, continue with deletion
|
|
|
|
const deleteUrl = `${this.ghostPaths.url.api('members')}?${query}`;
|
|
|
|
// response contains details of which members failed to be deleted
|
|
const response = yield this.ajax.del(deleteUrl);
|
|
|
|
// reset and reload
|
|
this.store.unloadAll('member');
|
|
this.router.transitionTo('members.index', {queryParams: Object.assign(resetQueryParams('members.index'))});
|
|
this.membersStats.invalidate();
|
|
this.membersStats.fetchCounts();
|
|
|
|
return response.meta;
|
|
}
|
|
|
|
@task({drop: true})
|
|
*unsubscribeMembersTask() {
|
|
const query = new URLSearchParams(this.getApiQueryObject());
|
|
const unsubscribeUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`;
|
|
// response contains details of which members failed to be unsubscribe
|
|
const response = yield this.ajax.put(unsubscribeUrl, {
|
|
data: {
|
|
bulk: {
|
|
action: 'unsubscribe',
|
|
meta: {}
|
|
}
|
|
}
|
|
});
|
|
|
|
// reset and reload
|
|
this.store.unloadAll('member');
|
|
this.reload();
|
|
|
|
this.membersStats.invalidate();
|
|
this.membersStats.fetchCounts();
|
|
|
|
return response?.bulk?.meta;
|
|
}
|
|
|
|
@task({drop: true})
|
|
*addLabelToMembersTask(selectedLabel) {
|
|
const query = new URLSearchParams(this.getApiQueryObject());
|
|
const addLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`;
|
|
const response = yield this.ajax.put(addLabelUrl, {
|
|
data: {
|
|
bulk: {
|
|
action: 'addLabel',
|
|
meta: {
|
|
label: {
|
|
id: selectedLabel
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// reset and reload
|
|
this.store.unloadAll('member');
|
|
this.reload();
|
|
|
|
return response?.bulk?.meta;
|
|
}
|
|
|
|
@task({drop: true})
|
|
*removeLabelFromMembersTask(selectedLabel) {
|
|
const query = new URLSearchParams(this.getApiQueryObject());
|
|
const removeLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`;
|
|
const response = yield this.ajax.put(removeLabelUrl, {
|
|
data: {
|
|
bulk: {
|
|
action: 'removeLabel',
|
|
meta: {
|
|
label: {
|
|
id: selectedLabel
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// reset and reload
|
|
this.store.unloadAll('member');
|
|
this.reload();
|
|
|
|
return response?.bulk?.meta;
|
|
}
|
|
// Internal ----------------------------------------------------------------
|
|
|
|
resetSearch() {
|
|
this.searchText = '';
|
|
}
|
|
|
|
resetFilters(params) {
|
|
if (!params?.filterParam) {
|
|
this.filters = A([]);
|
|
this.softFilterParam = null;
|
|
this.softFilters = A([]);
|
|
}
|
|
}
|
|
|
|
reload(params) {
|
|
this.membersStats.invalidate();
|
|
this.membersStats.fetchCounts();
|
|
this.fetchMembersTask.perform(params);
|
|
}
|
|
}
|