Skip to content

Commit 365ef2e

Browse files
committed
Add tool to add comp plans to members
1 parent de60399 commit 365ef2e

File tree

9 files changed

+320
-1
lines changed

9 files changed

+320
-1
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Available tools include:
4848
* [`change-visibility-pages`](#change-visibility-pages)
4949
* [`change-status`](#change-status)
5050
* [`change-role`](#change-role)
51+
* [`add-member-comp-subscription`](#add-member-comp-subscription)
5152
* [`add-member-newsletter-subscription`](#add-member-newsletter-subscription)
5253
* [`remove-member-newsletter-subscription`](#remove-member-newsletter-subscription)
5354
* [`change-tags`](#change-tags)
@@ -418,6 +419,15 @@ gctools change-role <apiURL> <adminAPIKey> --newRole 'Contributor'
418419
gctools change-role <apiURL> <adminAPIKey> --filterRole 'Editor' --newRole 'Author'
419420
```
420421

422+
### add-member-comp-subscription
423+
424+
Add complimentary subscriptions for members
425+
426+
```sh
427+
# Add a compliemty plan to tier ID abcdtierid1234 that expired on May 4th 2025, but only for members with the label slug 'my-member-label-slug'
428+
gctools add-member-comp-subscription <apiURL> <adminAPIKey> --tierId abcdtierid1234 --expireAt '2025-05-04T00:00:00.000Z' --onlyForLabelSlugs my-member-label-slug
429+
```
430+
421431
### add-member-newsletter-subscription
422432

423433
Remove subscriptions for a specific newsletter

bin/cli.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import prettyCLI from '@tryghost/pretty-cli';
33

44
prettyCLI.preface('Command line utilities for working with Ghost content');
55

6+
import addMemberCompSubscriptionCommands from '../commands/add-member-comp.js';
67
import addMemberNewsletterSubscriptionCommands from '../commands/add-member-newsletter-subscription.js';
78
import addPreviewCommands from '../commands/add-preview.js';
89
import addTagsCommands from '../commands/add-tags.js';
@@ -30,6 +31,7 @@ import changeTags from '../commands/change-tags.js';
3031
import revueStripe from '../commands/revue-stripe.js';
3132
import letterdropStripe from '../commands/letterdrop-stripe.js';
3233

34+
prettyCLI.command(addMemberCompSubscriptionCommands);
3335
prettyCLI.command(addMemberNewsletterSubscriptionCommands);
3436
prettyCLI.command(addPreviewCommands);
3537
prettyCLI.command(addTagsCommands);

commands/add-member-comp.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {ui} from '@tryghost/pretty-cli';
2+
import addMemberCompSubscription from '../tasks/add-member-comp-subscription.js';
3+
4+
// Internal ID in case we need one.
5+
const id = 'add-member-comp-subscription';
6+
7+
const group = 'Members:';
8+
9+
// The command to run and any params
10+
const flags = 'add-member-comp-subscription <apiURL> <adminAPIKey>';
11+
12+
// Description for the top level command
13+
const desc = 'Add member complimentary subscription';
14+
15+
// Descriptions for the individual params
16+
const paramsDesc = [
17+
'URL to your Ghost API',
18+
'Admin API key'
19+
];
20+
21+
// Configure all the options
22+
const setup = (sywac) => {
23+
sywac.boolean('-V --verbose', {
24+
defaultValue: false,
25+
desc: 'Show verbose output'
26+
});
27+
sywac.array('--onlyForLabelSlugs', {
28+
defaultValue: null,
29+
desc: 'Optional label to filter members'
30+
});
31+
sywac.string('--tierId', {
32+
defaultValue: null,
33+
desc: 'The ID for the tier to add the subscription to'
34+
});
35+
sywac.string('--expireAt', {
36+
defaultValue: null,
37+
desc: 'When the comp plan should expire in quotes, such as \'2024-05-12T00:00:00.000Z\''
38+
});
39+
sywac.number('--delayBetweenCalls', {
40+
defaultValue: 100,
41+
desc: 'The delay between API calls, in ms'
42+
});
43+
};
44+
45+
// What to do when this command is executed
46+
const run = async (argv) => {
47+
let timer = Date.now();
48+
let context = {errors: []};
49+
50+
try {
51+
// Fetch the tasks, configured correctly according to the options passed in
52+
let runner = addMemberCompSubscription.getTaskRunner(argv);
53+
54+
// Run the migration
55+
await runner.run(context);
56+
} catch (error) {
57+
ui.log.error('Done with errors', context.errors);
58+
}
59+
60+
// Report success
61+
ui.log.ok(`Successfully added ${context.updated.length} subscriptions in ${Date.now() - timer}ms.`);
62+
};
63+
64+
export default {
65+
id,
66+
group,
67+
flags,
68+
desc,
69+
paramsDesc,
70+
setup,
71+
run
72+
};

commands/interactive.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ const run = async () => {
109109
name: tasks.deleteUnusedTags.choice.name,
110110
value: tasks.deleteUnusedTags.choice.value
111111
},
112+
{
113+
name: tasks.addMemberCompSubscription.choice.name,
114+
value: tasks.addMemberCompSubscription.choice.value
115+
},
112116
{
113117
name: tasks.addMemberNewsletterSubscription.choice.name,
114118
value: tasks.addMemberNewsletterSubscription.choice.value

lib/admin-api-call.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,33 @@ const apiAuthTokenHeaders = (options) => {
1717
return headers;
1818
};
1919

20+
const getTiers = async (options) => {
21+
const headers = apiAuthTokenHeaders(options);
22+
23+
let urlString = `${options.apiURL}/ghost/api/admin/tiers/?filter=type%3Apaid%2Bactive%3Atrue&limit=50`;
24+
25+
let response = null;
26+
let results = [];
27+
let pageNumber = 1;
28+
29+
do {
30+
// Set the page number of the request
31+
let urlPageChange = new URL(urlString);
32+
urlPageChange.searchParams.set('page', pageNumber); // `set` adds or updates
33+
urlString = urlPageChange.href;
34+
35+
try {
36+
response = await axios.get(urlString, {headers});
37+
results = results.concat(response.data.tiers);
38+
pageNumber = response.data.meta.pagination.next;
39+
} catch (error) {
40+
return [];
41+
}
42+
} while (response.data.meta.pagination.next);
43+
44+
return results;
45+
};
46+
2047
const getMemberLabels = async (options) => {
2148
const headers = apiAuthTokenHeaders(options);
2249
let urlString = `${options.apiURL}/ghost/api/admin/labels/?limit=50`;
@@ -44,5 +71,6 @@ const getMemberLabels = async (options) => {
4471
};
4572

4673
export {
74+
getTiers,
4775
getMemberLabels
4876
};

lib/ghost-api-choices.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,28 @@
11
import {discover} from './batch-ghost-discover.js';
2-
import {getMemberLabels} from './admin-api-call.js';
2+
import {getMemberLabels, getTiers} from './admin-api-call.js';
33
import chalk from 'chalk';
44

5+
const getAPITiers = async ({returnKey = 'slug'}) => {
6+
let tiers = [];
7+
8+
const url = process.env.GC_TOOLS_apiURL;
9+
const key = process.env.GC_TOOLS_adminAPIKey;
10+
11+
let tiersResponse = await getTiers({
12+
adminAPIKey: key,
13+
apiURL: url
14+
});
15+
16+
tiersResponse.forEach(function (tier){
17+
tiers.push({
18+
name: `${tier.name} ${chalk.grey(`(${tier.slug} - ${tier.id})`)}`,
19+
value: (returnKey) ? tier[returnKey] : tier
20+
});
21+
});
22+
23+
return tiers;
24+
};
25+
526
const getAPIAuthorsObj = async () => {
627
const url = process.env.GC_TOOLS_apiURL;
728
const key = process.env.GC_TOOLS_adminAPIKey;
@@ -135,6 +156,7 @@ const getAPIMemberLabels = async ({returnKey = 'slug'}) => {
135156
};
136157

137158
export {
159+
getAPITiers,
138160
getAPIAuthorsObj,
139161
getAPITagsObj,
140162
getAPINewslettersObj,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import inquirer from 'inquirer';
2+
import inquirerDatepickerPrompt from 'inquirer-datepicker-prompt';
3+
inquirer.registerPrompt('datetime', inquirerDatepickerPrompt);
4+
import chalk from 'chalk';
5+
import addMemberComp from '../tasks/add-member-comp-subscription.js';
6+
import {getAPITiers, getAPIMemberLabels} from '../lib/ghost-api-choices.js';
7+
import ghostAPICreds from '../lib/ghost-api-creds.js';
8+
import {ui} from '@tryghost/pretty-cli';
9+
10+
const choice = {
11+
name: 'Add member complimentary subscription',
12+
value: 'addMemberCompSubscription'
13+
};
14+
15+
const onYearToday = new Date();
16+
onYearToday.setFullYear(onYearToday.getFullYear() + 1);
17+
18+
const options = [
19+
...ghostAPICreds,
20+
{
21+
type: 'list',
22+
name: 'onlyForLabelSlugs',
23+
message: `Select member label: ${chalk.yellow('[Type to search]')}`,
24+
pageSize: 20,
25+
choices: function () {
26+
return getAPIMemberLabels({returnKey: 'slug'});
27+
}
28+
},
29+
{
30+
type: 'list',
31+
name: 'tierId',
32+
message: `Select tier: ${chalk.yellow('[Type to search]')}`,
33+
pageSize: 20,
34+
choices: function () {
35+
return getAPITiers({returnKey: 'id'});
36+
}
37+
},
38+
{
39+
type: 'datetime',
40+
name: 'expireAt',
41+
message: 'End date (UTC):',
42+
format: ['dd', ' ', 'mmmm', ' ', 'yyyy'],
43+
initial: onYearToday
44+
}
45+
];
46+
47+
async function run() {
48+
await inquirer.prompt(options).then(async (answers) => {
49+
let timer = Date.now();
50+
let context = {errors: []};
51+
52+
answers.onlyForLabelSlugs = [answers.onlyForLabelSlugs];
53+
54+
try {
55+
let runner = addMemberComp.getTaskRunner(answers);
56+
await runner.run(context);
57+
ui.log.ok(`Successfully updated ${context.updated.length} posts in ${Date.now() - timer}ms.`);
58+
} catch (error) {
59+
ui.log.error('Done with errors', context.errors);
60+
}
61+
});
62+
}
63+
64+
export default {
65+
choice,
66+
options,
67+
run
68+
};

prompts/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import changeRole from './change-role.js';
1919
import contentStats from './content-stats.js';
2020
import dedupeMembersCsv from './dedupe-members-csv.js';
2121
import addMemberNewsletterSubscription from './add-member-newsletter-subscription.js';
22+
import addMemberCompSubscription from './add-member-comp-subscription.js';
2223

2324
export default {
2425
zipSplit,
@@ -40,6 +41,7 @@ export default {
4041
changeStatus,
4142
changeRole,
4243
addMemberNewsletterSubscription,
44+
addMemberCompSubscription,
4345
contentStats,
4446
dedupeMembersCsv
4547
};

tasks/add-member-comp-subscription.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import Promise from 'bluebird';
2+
import _ from 'lodash';
3+
import GhostAdminAPI from '@tryghost/admin-api';
4+
import {makeTaskRunner} from '@tryghost/listr-smart-renderer';
5+
import {discover} from '../lib/batch-ghost-discover.js';
6+
7+
const initialise = (options) => {
8+
return {
9+
title: 'Initialising API connection',
10+
task: (ctx, task) => {
11+
let defaults = {
12+
verbose: false,
13+
delayBetweenCalls: 50
14+
};
15+
16+
const url = options.apiURL;
17+
const key = options.adminAPIKey;
18+
const api = new GhostAdminAPI({
19+
url: url.replace('localhost', '127.0.0.1'),
20+
key,
21+
version: 'v5.0'
22+
});
23+
24+
ctx.args = _.mergeWith(defaults, options);
25+
ctx.api = api;
26+
ctx.updated = [];
27+
28+
task.output = `Initialised API connection for ${options.apiURL}`;
29+
}
30+
};
31+
};
32+
33+
const getFullTaskList = (options) => {
34+
return [
35+
initialise(options),
36+
{
37+
title: 'Fetch members from Ghost API',
38+
task: async (ctx, task) => {
39+
let discoveryFilter = [];
40+
41+
if (options.onlyForLabelSlugs.length) {
42+
discoveryFilter.push(`label:[${options.onlyForLabelSlugs.join(',')}]`);
43+
}
44+
45+
let discoveryOptions = {
46+
api: ctx.api,
47+
type: 'members',
48+
filter: discoveryFilter.join('+')
49+
};
50+
51+
try {
52+
ctx.members = await discover(discoveryOptions);
53+
task.output = `Found ${ctx.members.length} members`;
54+
} catch (error) {
55+
ctx.errors.push(error);
56+
throw error;
57+
}
58+
}
59+
},
60+
{
61+
title: 'Updating members',
62+
task: async (ctx) => {
63+
let tasks = [];
64+
65+
await Promise.mapSeries(ctx.members, async (member) => {
66+
tasks.push({
67+
title: `Updating ${member.email}`,
68+
task: async () => {
69+
let newMemberObject = {
70+
id: member.id,
71+
tiers: [
72+
{
73+
id: options.tierId,
74+
expiry_at: options.expireAt
75+
}
76+
]
77+
};
78+
79+
try {
80+
let result = await ctx.api.members.edit(newMemberObject);
81+
ctx.updated.push(result);
82+
return Promise.delay(options.delayBetweenCalls).return(result);
83+
} catch (error) {
84+
ctx.errors.push(error);
85+
throw error;
86+
}
87+
}
88+
});
89+
});
90+
91+
let taskOptions = options;
92+
taskOptions.concurrent = 1;
93+
return makeTaskRunner(tasks, taskOptions);
94+
}
95+
}
96+
];
97+
};
98+
99+
const getTaskRunner = (options) => {
100+
let tasks = [];
101+
102+
tasks = getFullTaskList(options);
103+
104+
return makeTaskRunner(tasks, Object.assign({topLevel: true}, options));
105+
};
106+
107+
export default {
108+
initialise,
109+
getFullTaskList,
110+
getTaskRunner
111+
};

0 commit comments

Comments
 (0)