@trycourier/cli 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -12
- package/dist/commands/TenantsGetMembership.d.ts +3 -0
- package/dist/commands/TenantsGetMembership.js +121 -0
- package/dist/commands/UsersTokensBulk.d.ts +3 -0
- package/dist/commands/UsersTokensBulk.js +215 -0
- package/dist/mappings.js +23 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,6 +55,7 @@ For more details, run `courier` to see a list of commands and their arguments &
|
|
|
55
55
|
|
|
56
56
|
```
|
|
57
57
|
courier --help
|
|
58
|
+
courier --version
|
|
58
59
|
courier upgrade
|
|
59
60
|
|
|
60
61
|
courier send --tel 555-867-5309 --body "Hey Jenny\!"
|
|
@@ -66,10 +67,7 @@ courier send --user="1" --tenant-context=kewl --title=hello --body="hello world"
|
|
|
66
67
|
courier users:get user123
|
|
67
68
|
courier users:set user123 --email user@example.com
|
|
68
69
|
courier users:bulk examples/users.csv --replace
|
|
69
|
-
courier users:bulk
|
|
70
|
-
courier users:bulk "examples/*.json" --remove-nulls
|
|
71
|
-
courier users:bulk examples/users.parquet --list new-list-id
|
|
72
|
-
courier users:bulk examples/users.xlsx --tenant new-tenant-id
|
|
70
|
+
courier users:bulk examples/users.parquet --list new-list-id --tenant new-tenant-id
|
|
73
71
|
|
|
74
72
|
courier track EXAMPLE_EVENT user123 --name "Pip the Pigeon"
|
|
75
73
|
|
|
@@ -85,16 +83,15 @@ courier config --apikey MY_API_KEY --draft
|
|
|
85
83
|
|
|
86
84
|
There are a number flags you can use for any command
|
|
87
85
|
|
|
88
|
-
| Flags
|
|
89
|
-
|
|
|
90
|
-
| -M --mock
|
|
91
|
-
| -P --production
|
|
92
|
-
| -D --draft
|
|
93
|
-
| -S --submitted
|
|
94
|
-
| --apikey <Courier API Key> | Use the provided Courier API key, otherwise use the approprate environment variable
|
|
86
|
+
| Flags | Description |
|
|
87
|
+
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
88
|
+
| -M --mock | Use the API key that simulates sending using the simulating routing |
|
|
89
|
+
| -P --production | Use the production environment API key |
|
|
90
|
+
| -D --draft | Use the draft document scope API key. Use draft or submitted, will default to published key if neither are provided |
|
|
91
|
+
| -S --submitted | Use the submitted document scope API key |
|
|
92
|
+
| --apikey <Courier API Key> | Use the provided Courier API key, otherwise use the approprate environment variable |
|
|
95
93
|
| --apiurl <Courier API URL> | Use the provided Courier API URL, otherwise use COURIER_API_URL environment variable. Default is https://api.courier.com |
|
|
96
94
|
|
|
97
|
-
|
|
98
95
|
## Misc
|
|
99
96
|
|
|
100
97
|
- If you need to change the Courier API URL, you can set COURIER_API_URL in .courier or other methods to set the environment variables.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Alert } from '@inkjs/ui';
|
|
2
|
+
import { stringify } from 'csv-stringify/sync';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import _ from 'lodash';
|
|
5
|
+
import { DateTime } from 'luxon';
|
|
6
|
+
import React, { useEffect, useState } from 'react';
|
|
7
|
+
import { useBoolean, useCounter } from 'usehooks-ts';
|
|
8
|
+
import { useCliContext } from '../components/Context.js';
|
|
9
|
+
import Spinner from '../components/Spinner.js';
|
|
10
|
+
import UhOh from '../components/UhOh.js';
|
|
11
|
+
const FILENAME = 'tenent_members';
|
|
12
|
+
const TenantsGetMembership = () => {
|
|
13
|
+
const { parsedParams, courier } = useCliContext();
|
|
14
|
+
const processing = useBoolean(true);
|
|
15
|
+
const running = useBoolean(true);
|
|
16
|
+
const counter = useCounter(0);
|
|
17
|
+
const [members, setMembers] = useState([]);
|
|
18
|
+
const [error, setError] = useState();
|
|
19
|
+
const { maxPages, json, csv, webhook, filename, _: [tenant_id], } = parsedParams;
|
|
20
|
+
const out_file = (filename?.length
|
|
21
|
+
? filename.substring(0, filename.includes('.') ? filename.lastIndexOf('.') : filename.length)
|
|
22
|
+
: FILENAME) + (csv ? '.csv' : '.json');
|
|
23
|
+
const MAX_PAGES = Number(maxPages) || 100;
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!processing.value) {
|
|
26
|
+
if (json || csv || webhook?.length) {
|
|
27
|
+
runExport();
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
running.setFalse();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}, [processing.value]);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
getTenantMemberships();
|
|
36
|
+
}, []);
|
|
37
|
+
const getTenantMemberships = async (cursor, count = 0) => {
|
|
38
|
+
if (!tenant_id) {
|
|
39
|
+
setError('No Tenant Specified');
|
|
40
|
+
processing.setFalse();
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
counter.increment();
|
|
44
|
+
const r = await courier.tenants.getUsersByTenant(tenant_id, {
|
|
45
|
+
cursor,
|
|
46
|
+
limit: 100,
|
|
47
|
+
});
|
|
48
|
+
let items = r.items;
|
|
49
|
+
if (items?.length) {
|
|
50
|
+
setMembers(previous => {
|
|
51
|
+
return [
|
|
52
|
+
...previous,
|
|
53
|
+
...items.map(({ tenant_id, type, ...rest }) => rest),
|
|
54
|
+
];
|
|
55
|
+
});
|
|
56
|
+
if (r.has_more && count < MAX_PAGES) {
|
|
57
|
+
await getTenantMemberships(r.cursor, count + 1);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
processing.setFalse();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const runExport = async () => {
|
|
66
|
+
const flat = csv ? flattenData(members) : members;
|
|
67
|
+
if (csv) {
|
|
68
|
+
await fs.writeFile(out_file, stringify(flat, { header: true }));
|
|
69
|
+
}
|
|
70
|
+
else if (json) {
|
|
71
|
+
await fs.writeFile(out_file, JSON.stringify(flat, null, 2), {
|
|
72
|
+
encoding: 'utf-8',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (webhook?.length) {
|
|
76
|
+
try {
|
|
77
|
+
await fetch(webhook, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify(flat),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
running.setFalse();
|
|
90
|
+
};
|
|
91
|
+
if (error?.length) {
|
|
92
|
+
return React.createElement(UhOh, { text: error });
|
|
93
|
+
}
|
|
94
|
+
else if (running.value) {
|
|
95
|
+
return React.createElement(Spinner, { text: `Fetching tenent_members - page ${counter.count}` });
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
return (React.createElement(React.Fragment, null,
|
|
99
|
+
React.createElement(Alert, { variant: "success", title: `Finished ${counter.count} pages` }, csv || json
|
|
100
|
+
? `Output ${members.length} tenent_members to ${out_file}`
|
|
101
|
+
: JSON.stringify(members, null, 2))));
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const flattenData = (data) => {
|
|
105
|
+
return data.map(row => {
|
|
106
|
+
return Object.keys(row).reduce((p, key) => {
|
|
107
|
+
const v = _.get(row, [key]);
|
|
108
|
+
if (typeof v === 'number') {
|
|
109
|
+
p[key] = DateTime.fromMillis(v, { zone: 'utc' }).toISO();
|
|
110
|
+
}
|
|
111
|
+
else if (typeof v === 'object') {
|
|
112
|
+
p[key] = JSON.stringify(v);
|
|
113
|
+
}
|
|
114
|
+
else if (v) {
|
|
115
|
+
p[key] = v;
|
|
116
|
+
}
|
|
117
|
+
return p;
|
|
118
|
+
}, {});
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
export default TenantsGetMembership;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { ProgressBar } from '@inkjs/ui';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import _ from 'lodash';
|
|
4
|
+
import fs from 'fs/promises';
|
|
5
|
+
import React, { useEffect, useState } from 'react';
|
|
6
|
+
import { useBoolean, useCounter } from 'usehooks-ts';
|
|
7
|
+
import getDb, { getChunk } from '../bulk.js';
|
|
8
|
+
import { useCliContext } from '../components/Context.js';
|
|
9
|
+
import Spinner from '../components/Spinner.js';
|
|
10
|
+
import UhOh from '../components/UhOh.js';
|
|
11
|
+
import delay from '../lib/delay.js';
|
|
12
|
+
const DEFAULT_DELAY = 5000;
|
|
13
|
+
const DEFAULT_CHUNK_SIZE = 500;
|
|
14
|
+
const DEFAULT_TIMEOUT = 10;
|
|
15
|
+
const DEFAULT_ERROR_FILENAME = 'errors.json';
|
|
16
|
+
export default () => {
|
|
17
|
+
const { parsedParams, courier } = useCliContext();
|
|
18
|
+
const [error, setError] = useState();
|
|
19
|
+
const processing = useBoolean(true);
|
|
20
|
+
const running = useBoolean(true);
|
|
21
|
+
const [data, setData] = useState();
|
|
22
|
+
const [data_errors, setDataErrors] = useState([]);
|
|
23
|
+
const counter = useCounter(0);
|
|
24
|
+
const [row_errors, setRowErrors] = useState([]);
|
|
25
|
+
const filename = String(_.get(parsedParams, ['_', 0], ''));
|
|
26
|
+
const { db, filetype, sql } = getDb(filename);
|
|
27
|
+
const delay_between_chunks = Number(parsedParams['delay']) ?? DEFAULT_DELAY;
|
|
28
|
+
const chunk_size = parsedParams['chunk_size']
|
|
29
|
+
? Number(parsedParams['chunk_size'])
|
|
30
|
+
: DEFAULT_CHUNK_SIZE;
|
|
31
|
+
const log_errors = true;
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (filetype) {
|
|
34
|
+
getData();
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
setError('File type not supported.');
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (data) {
|
|
42
|
+
processData();
|
|
43
|
+
}
|
|
44
|
+
}, [data]);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!processing.value) {
|
|
47
|
+
handleErrors();
|
|
48
|
+
}
|
|
49
|
+
}, [processing.value]);
|
|
50
|
+
const getData = () => {
|
|
51
|
+
db.all(sql, (err, result) => {
|
|
52
|
+
if (err) {
|
|
53
|
+
setError(err.message);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
setData(result);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
const processChunkRows = (data, start_index) => {
|
|
61
|
+
return data.map((row, i) => {
|
|
62
|
+
const curr_index = start_index + i;
|
|
63
|
+
let { user_id, token, provider_key, device, tracking, expiry_date, ...properties } = row || {};
|
|
64
|
+
if (!device)
|
|
65
|
+
device = {};
|
|
66
|
+
if (!tracking)
|
|
67
|
+
tracking = {};
|
|
68
|
+
if (!properties)
|
|
69
|
+
properties = {};
|
|
70
|
+
if (!user_id) {
|
|
71
|
+
return Promise.resolve({
|
|
72
|
+
success: false,
|
|
73
|
+
userId: '__unknown__',
|
|
74
|
+
error: `user_id not found in index ${curr_index}`,
|
|
75
|
+
index: curr_index,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
else if (!provider_key) {
|
|
79
|
+
return Promise.resolve({
|
|
80
|
+
success: false,
|
|
81
|
+
userId: user_id,
|
|
82
|
+
error: `provider_key not found in index ${curr_index}`,
|
|
83
|
+
index: curr_index,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else if (!token) {
|
|
87
|
+
return Promise.resolve({
|
|
88
|
+
success: false,
|
|
89
|
+
userId: user_id,
|
|
90
|
+
error: `token not found in index ${curr_index}`,
|
|
91
|
+
index: curr_index,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
Object.entries(properties).forEach(([key, value]) => {
|
|
96
|
+
if (key.startsWith('device.')) {
|
|
97
|
+
_.unset(properties, key);
|
|
98
|
+
_.set(device, key.replace('device.', ''), value);
|
|
99
|
+
}
|
|
100
|
+
else if (key.startsWith('tracking.')) {
|
|
101
|
+
_.unset(properties, key);
|
|
102
|
+
_.set(tracking, key.replace('tracking.', ''), value);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
_.unset(properties, key);
|
|
106
|
+
_.set(properties, key.replace('properties.', ''), value);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
return processRow({
|
|
110
|
+
user_id: String(user_id),
|
|
111
|
+
token: token,
|
|
112
|
+
provider_key,
|
|
113
|
+
device,
|
|
114
|
+
tracking,
|
|
115
|
+
expiry_date,
|
|
116
|
+
properties,
|
|
117
|
+
index: curr_index,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
const processRow = async ({ user_id, token, index, ...body }) => {
|
|
123
|
+
return new Promise(async (resolve) => {
|
|
124
|
+
let promises = [];
|
|
125
|
+
try {
|
|
126
|
+
promises.push(courier.users.tokens.add(user_id, token, body, {
|
|
127
|
+
maxRetries: 5,
|
|
128
|
+
timeoutInSeconds: DEFAULT_TIMEOUT,
|
|
129
|
+
}));
|
|
130
|
+
await Promise.all(promises);
|
|
131
|
+
counter.increment();
|
|
132
|
+
return resolve({ userId: user_id, success: true, index });
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
counter.increment();
|
|
136
|
+
return resolve({
|
|
137
|
+
userId: user_id,
|
|
138
|
+
success: false,
|
|
139
|
+
index,
|
|
140
|
+
error: (String(error) ??
|
|
141
|
+
error?.message ??
|
|
142
|
+
error.message ??
|
|
143
|
+
'Unknown Error') + `+ ${user_id}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
const processData = async () => {
|
|
149
|
+
if (data?.length) {
|
|
150
|
+
let data_copy = [...data];
|
|
151
|
+
let counter = 0;
|
|
152
|
+
let { rows, data: rest } = getChunk(data_copy, chunk_size);
|
|
153
|
+
while (rows?.length) {
|
|
154
|
+
const chunk = processChunkRows(rows, counter);
|
|
155
|
+
const processed_chunks = await Promise.all(chunk);
|
|
156
|
+
const errors = processed_chunks.filter(r => !r.success);
|
|
157
|
+
if (errors.length) {
|
|
158
|
+
setDataErrors(p => [
|
|
159
|
+
...p,
|
|
160
|
+
...errors.map(r => {
|
|
161
|
+
return `user_id (${r.userId}) failed to update in index ${r.index}: ${String(r.error)}`;
|
|
162
|
+
}),
|
|
163
|
+
]);
|
|
164
|
+
setRowErrors(r => [
|
|
165
|
+
...r,
|
|
166
|
+
...errors.map(e => data[e.index]),
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
if (rest.length > 0) {
|
|
170
|
+
await delay(delay_between_chunks);
|
|
171
|
+
counter += rows.length;
|
|
172
|
+
const next = getChunk(rest, chunk_size);
|
|
173
|
+
rows = next.rows;
|
|
174
|
+
rest = next.data;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
processing.setFalse();
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const handleErrors = async () => {
|
|
184
|
+
if (row_errors.length && log_errors) {
|
|
185
|
+
await fs.writeFile(DEFAULT_ERROR_FILENAME, JSON.stringify(row_errors, null, 2), {
|
|
186
|
+
encoding: 'utf-8',
|
|
187
|
+
});
|
|
188
|
+
running.setFalse();
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
running.setFalse();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
if (!filename?.length) {
|
|
195
|
+
return React.createElement(UhOh, { text: "You must specify a filename." });
|
|
196
|
+
}
|
|
197
|
+
else if (error?.length) {
|
|
198
|
+
return React.createElement(UhOh, { text: error });
|
|
199
|
+
}
|
|
200
|
+
else if (data && running.value) {
|
|
201
|
+
return (React.createElement(React.Fragment, null,
|
|
202
|
+
React.createElement(ProgressBar, { value: Math.floor((counter.count / data.length) * 100) }),
|
|
203
|
+
React.createElement(Spinner, { text: `Completed Rows: ${counter.count} / ${data.length}` })));
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
return (React.createElement(Box, { flexDirection: "column", marginY: 1 },
|
|
207
|
+
React.createElement(Text, { color: 'green' }, `Completed Rows: ${counter.count} / ${data?.length || 0}`),
|
|
208
|
+
data_errors.map((err, i) => {
|
|
209
|
+
return React.createElement(UhOh, { key: i, text: err });
|
|
210
|
+
}),
|
|
211
|
+
log_errors && data_errors.length ? (React.createElement(Text, null,
|
|
212
|
+
"Errors output to ",
|
|
213
|
+
DEFAULT_ERROR_FILENAME)) : (React.createElement(React.Fragment, null))));
|
|
214
|
+
}
|
|
215
|
+
};
|
package/dist/mappings.js
CHANGED
|
@@ -21,6 +21,8 @@ import AutomationInvokeBulk from './commands/AutomationInvokeBulk.js';
|
|
|
21
21
|
import MessagesSearch from './commands/MessagesSearch.js';
|
|
22
22
|
import TenantsMembershipBulk from './commands/TenantsMembershipBulk.js';
|
|
23
23
|
import AudienceSearch from './commands/AudienceSearch.js';
|
|
24
|
+
import UsersTokensBulk from './commands/UsersTokensBulk.js';
|
|
25
|
+
import TenantsGetMembership from './commands/TenantsGetMembership.js';
|
|
24
26
|
const mappings = new Map();
|
|
25
27
|
export const COMMON_OPTIONS = [
|
|
26
28
|
{
|
|
@@ -281,6 +283,18 @@ mappings.set('users:bulk', {
|
|
|
281
283
|
],
|
|
282
284
|
component: () => React.createElement(UsersBulk, null),
|
|
283
285
|
});
|
|
286
|
+
mappings.set('users:tokens:bulk', {
|
|
287
|
+
params: '<filename>',
|
|
288
|
+
instructions: 'Bulk import user tokens from a file (csv, json, jsonl, xls, xlsx, .parquet)." For CSVs, we will unpack nested objects into based on the header. E.g., "address.city" becomes {"address": {"city": "value"}}. Lodash path syntax is used for created the nested object. Supports wildcard syntax for multiple files, must surround with quotes (see examples).',
|
|
289
|
+
options: [
|
|
290
|
+
{
|
|
291
|
+
option: '--replace',
|
|
292
|
+
value: 'Replace existing users with the same ID, if not set, will do a merge based on key',
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
example: ['courier users:tokens:bulk examples/users-tokens.csv'],
|
|
296
|
+
component: () => React.createElement(UsersTokensBulk, null),
|
|
297
|
+
});
|
|
284
298
|
mappings.set('users:preferences', {
|
|
285
299
|
params: '<user>',
|
|
286
300
|
instructions: 'Fetch the preferences for a given user ID',
|
|
@@ -356,6 +370,15 @@ mappings.set('tenants:bulk', {
|
|
|
356
370
|
},
|
|
357
371
|
],
|
|
358
372
|
});
|
|
373
|
+
mappings.set('tenants:get:membership', {
|
|
374
|
+
params: '<tenant_id>',
|
|
375
|
+
instructions: 'Retrieve the users in the tenant',
|
|
376
|
+
component: () => React.createElement(TenantsGetMembership, null),
|
|
377
|
+
options: [...OUTPUT_OPTIONS],
|
|
378
|
+
example: [
|
|
379
|
+
'courier tenants:membership tenant123 --json --filename=tenant123.json',
|
|
380
|
+
],
|
|
381
|
+
});
|
|
359
382
|
mappings.set('tenants:membership:bulk', {
|
|
360
383
|
params: '<filename>',
|
|
361
384
|
instructions: 'Bulk add or remove tenant memberships from file(s). Requires at a minumum a user_id column. You can provide the tenant as a column (tenant_id) or via, other columns are merged into the users tenant specific profile. Supports csv, json, jsonl, xls, xlsx, .parquet. Supports wildcard syntax for multiple files, must surround with quotes (see examples)',
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
let VERSION = '2.
|
|
1
|
+
let VERSION = '2.6.0';
|
|
2
2
|
export default VERSION;
|