Ghost/ghost/admin/app/controllers/members.js
Kevin Ansfield f1bcb073a3 🐛 Fixed incorrect automatic CSV download when bulk-deleting members
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
2022-01-05 12:21:52 +00:00

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);
}
}