@foxtware/mineral 0.1.18 → 0.1.19

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/AGENTS.md CHANGED
@@ -20,6 +20,7 @@ Notes for AI assistants working in this directory. **Read this file at the start
20
20
  - docs.md in each platform should not describe the details of our implementation, handlers or anything relating to Mineral. It is for describing the platform's API itself and does not change based on what we do with it. It should also be brief.
21
21
  - Usually when making a function that gets multiples of a resource, it should be implemented as a Getter, with a paginator and digester. If done in this way, export platformThingGet and platformThingGetter, using the .bind() syntax seen in other Get functions.
22
22
  - When making functions that act on single resources, consider using actionSingleOrMultiple to allow a queue of actions.
23
+ - When altering .yml files, preserve whitespace formatting.
23
24
 
24
25
  ## API clients
25
26
  - Base URLs for platform clients should live in `{platform}.constants.js`, not creds, if they are static for all users of the API.
@@ -366,11 +366,16 @@ const selectDirInteractive = async (dirs) => {
366
366
  }
367
367
 
368
368
  const dirIndex = await askQuestion(`Where does your new function live? \n${
369
- dirs.map((dir, index) => {
370
- return `[${ index + 1 }] ${ dir }`;
371
- }).join('\n')
369
+ [
370
+ '[0] api/ (root)',
371
+ ...dirs.map((dir, index) => `[${ index + 1 }] ${ dir }`),
372
+ ].join('\n')
372
373
  }\n`);
373
374
 
375
+ if (String(dirIndex) === '0') {
376
+ return '';
377
+ }
378
+
374
379
  const dir = dirs[dirIndex - 1];
375
380
 
376
381
  if (!dir) {
@@ -510,8 +515,8 @@ const createNewFunction = async () => {
510
515
  name: cliArgs.name,
511
516
  });
512
517
 
513
- if (dir === null || (!dirs.includes(dir) && !(dir === '' && dirs.length === 0 && !context.inMineral))) {
514
- const availableDirs = dirs.length ? dirs.join(', ') : 'api/ (flat)';
518
+ if (dir === null || (dir !== '' && !dirs.includes(dir))) {
519
+ const availableDirs = dirs.length ? `api/ (root), ${ dirs.join(', ') }` : 'api/ (flat)';
515
520
  console.error(`Invalid --dir "${ cliArgs.dir }". Available: ${ availableDirs }`);
516
521
  process.exitCode = 1;
517
522
  return;
@@ -100,6 +100,30 @@ const stripEnvelope = (state) => {
100
100
  };
101
101
  };
102
102
 
103
+ // Peoplevox returns HTTP 200 with ResponseId -1 on failure; Detail holds the message.
104
+ const rejectNegativeResponseId = (state) => {
105
+ const { response } = state;
106
+ const { data } = response || {};
107
+
108
+ if (!response?.ok || data == null || typeof data !== 'object') {
109
+ return {};
110
+ }
111
+
112
+ if (Number(data.ResponseId) !== -1) {
113
+ return {};
114
+ }
115
+
116
+ return {
117
+ response: {
118
+ ok: false,
119
+ data,
120
+ error: {
121
+ detail: data.Detail,
122
+ },
123
+ },
124
+ };
125
+ };
126
+
103
127
  const tryToParseDetailAsCsv = async (state) => {
104
128
  const { response } = state;
105
129
 
@@ -179,6 +203,7 @@ const peoplevoxClient = new FetchClient({
179
203
  useSoapEnvelope,
180
204
  'fetch',
181
205
  stripEnvelope,
206
+ rejectNegativeResponseId,
182
207
  tryToParseDetailAsCsv,
183
208
  unwrapSingleDetail,
184
209
  hoistDetail,
@@ -0,0 +1,180 @@
1
+ // https://shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentCreate
2
+
3
+ const { credsValidator } = require('../validators');
4
+ const { actionSingleOrMultiple, everyIfArray, ArgsWarden, valueProvided } = require('../utils');
5
+ const { shopifyMutationDo } = require('../shopify/shopifyMutationDo');
6
+
7
+ const fulfillmentInputValidator = (fulfillmentInput) => {
8
+ const { lineItemsByFulfillmentOrder } = fulfillmentInput || {};
9
+ if (!Array.isArray(lineItemsByFulfillmentOrder) || lineItemsByFulfillmentOrder.length === 0) {
10
+ return false;
11
+ }
12
+
13
+ return lineItemsByFulfillmentOrder.every((item) => {
14
+ if (!item?.fulfillmentOrderId) {
15
+ return false;
16
+ }
17
+
18
+ if (item.fulfillmentOrderLineItems) {
19
+ return item.fulfillmentOrderLineItems.every((lineItem) => {
20
+ return lineItem?.id && valueProvided(lineItem?.quantity);
21
+ });
22
+ }
23
+
24
+ return true;
25
+ });
26
+ };
27
+
28
+ const argsWarden = new ArgsWarden([
29
+ ['credsPayload', credsValidator],
30
+ ['fulfillmentInput', (i) => everyIfArray(fulfillmentInputValidator, i)],
31
+ ]);
32
+
33
+ const defaultReturnFulfillmentAttrs = `
34
+ id
35
+ status
36
+ trackingInfo {
37
+ number
38
+ company
39
+ url
40
+ }
41
+ `.trim();
42
+
43
+ const shopifyFulfillmentCreateSingle = async (
44
+ credsPayload,
45
+ fulfillmentInput,
46
+ {
47
+ apiVersion,
48
+ message,
49
+ returnFulfillmentAttrs = defaultReturnFulfillmentAttrs,
50
+ } = {},
51
+ ) => {
52
+
53
+ return shopifyMutationDo(
54
+ credsPayload,
55
+ 'fulfillmentCreate',
56
+ {
57
+ mutationVariables: {
58
+ fulfillment: {
59
+ type: 'FulfillmentInput!',
60
+ value: fulfillmentInput,
61
+ },
62
+ ...(valueProvided(message) && {
63
+ message: {
64
+ type: 'String',
65
+ value: message,
66
+ },
67
+ }),
68
+ },
69
+ returnSchema: `
70
+ fulfillment { ${ returnFulfillmentAttrs } }
71
+ `.trim(),
72
+ apiVersion,
73
+ },
74
+ );
75
+ };
76
+
77
+ const shopifyFulfillmentCreate = async (
78
+ credsPayload,
79
+ fulfillmentInput,
80
+ {
81
+ queueRunOptions,
82
+ apiVersion,
83
+ message,
84
+ returnFulfillmentAttrs = defaultReturnFulfillmentAttrs,
85
+ } = {},
86
+ ) => {
87
+
88
+ const rejectResponse = await argsWarden.responseIfRejectingArgs({
89
+ credsPayload,
90
+ fulfillmentInput,
91
+ });
92
+ if (rejectResponse) {
93
+ return rejectResponse;
94
+ }
95
+
96
+ return actionSingleOrMultiple(
97
+ fulfillmentInput,
98
+ shopifyFulfillmentCreateSingle,
99
+ (fulfillmentInputItem) => ({
100
+ args: [
101
+ credsPayload,
102
+ fulfillmentInputItem,
103
+ { apiVersion, message, returnFulfillmentAttrs },
104
+ ],
105
+ }),
106
+ {
107
+ ...(queueRunOptions ? { queueRunOptions } : {}),
108
+ },
109
+ );
110
+ };
111
+
112
+ const funcApiConfig = {
113
+ argsWarden,
114
+ };
115
+
116
+ module.exports = {
117
+ shopifyFulfillmentCreate,
118
+ funcApiConfig,
119
+ };
120
+
121
+ /*
122
+ curl -X POST "http://localhost:8000/shopifyFulfillmentCreate" \
123
+ -H "Content-Type: application/json" \
124
+ -d '{
125
+ "credsPayload": { "credsPath": "shopify.au" },
126
+ "fulfillmentInput": {
127
+ "lineItemsByFulfillmentOrder": [
128
+ {
129
+ "fulfillmentOrderId": "gid://shopify/FulfillmentOrder/1234567890"
130
+ }
131
+ ],
132
+ "notifyCustomer": true,
133
+ "trackingInfo": {
134
+ "number": "1234567890",
135
+ "company": "Australia Post",
136
+ "url": "https://auspost.com.au/mypost/track/details/1234567890"
137
+ }
138
+ }
139
+ }'
140
+
141
+ curl -X POST "http://localhost:8000/shopifyFulfillmentCreate" \
142
+ -H "Content-Type: application/json" \
143
+ -d '{
144
+ "credsPayload": { "credsPath": "shopify.au" },
145
+ "fulfillmentInput": [
146
+ {
147
+ "lineItemsByFulfillmentOrder": [
148
+ {
149
+ "fulfillmentOrderId": "gid://shopify/FulfillmentOrder/1234567890"
150
+ }
151
+ ],
152
+ "notifyCustomer": true,
153
+ "trackingInfo": {
154
+ "number": "1234567890",
155
+ "company": "Australia Post"
156
+ }
157
+ },
158
+ {
159
+ "lineItemsByFulfillmentOrder": [
160
+ {
161
+ "fulfillmentOrderId": "gid://shopify/FulfillmentOrder/0987654321",
162
+ "fulfillmentOrderLineItems": [
163
+ {
164
+ "id": "gid://shopify/FulfillmentOrderLineItem/9876543210",
165
+ "quantity": 1
166
+ }
167
+ ]
168
+ }
169
+ ],
170
+ "notifyCustomer": false,
171
+ "originAddress": {
172
+ "countryCode": "AU"
173
+ }
174
+ }
175
+ ],
176
+ "options": {
177
+ "message": "Partial fulfillment"
178
+ }
179
+ }'
180
+ */
@@ -0,0 +1,92 @@
1
+ // https://shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentTrackingInfoUpdate
2
+
3
+ const { credsValidator } = require('../validators');
4
+ const { ArgsWarden } = require('../utils');
5
+ const { shopifyMutationDo } = require('../shopify/shopifyMutationDo');
6
+
7
+ const defaultReturnSchema = `
8
+ fulfillment {
9
+ id
10
+ status
11
+ trackingInfo {
12
+ company
13
+ number
14
+ url
15
+ }
16
+ }
17
+ `;
18
+
19
+ const argsWarden = new ArgsWarden([
20
+ ['credsPayload', credsValidator],
21
+ ['fulfillmentId'],
22
+ ['trackingInfoPayload'],
23
+ ]);
24
+
25
+ const shopifyFulfillmentTrackingInfoUpdate = async (
26
+ credsPayload,
27
+ fulfillmentId,
28
+ trackingInfoPayload,
29
+ {
30
+ apiVersion,
31
+ returnSchema = defaultReturnSchema,
32
+ notifyCustomer = false,
33
+ } = {},
34
+ ) => {
35
+
36
+ const rejectResponse = await argsWarden.responseIfRejectingArgs({
37
+ credsPayload,
38
+ fulfillmentId,
39
+ trackingInfoPayload,
40
+ });
41
+ if (rejectResponse) {
42
+ return rejectResponse;
43
+ }
44
+
45
+ return shopifyMutationDo(
46
+ credsPayload,
47
+ 'fulfillmentTrackingInfoUpdate',
48
+ {
49
+ mutationVariables: {
50
+ fulfillmentId: {
51
+ type: 'ID!',
52
+ value: `gid://shopify/Fulfillment/${ fulfillmentId }`,
53
+ },
54
+ trackingInfoInput: {
55
+ type: 'FulfillmentTrackingInput!',
56
+ value: trackingInfoPayload,
57
+ },
58
+ ...notifyCustomer && {
59
+ notifyCustomer: {
60
+ type: 'Boolean',
61
+ value: notifyCustomer,
62
+ },
63
+ },
64
+ },
65
+ returnSchema,
66
+ apiVersion,
67
+ },
68
+ );
69
+ };
70
+
71
+ const funcApiConfig = {
72
+ argsWarden,
73
+ };
74
+
75
+ module.exports = {
76
+ shopifyFulfillmentTrackingInfoUpdate,
77
+ funcApiConfig,
78
+ };
79
+
80
+ /*
81
+ curl -X POST "http://localhost:8000/shopifyFulfillmentTrackingInfoUpdate" \
82
+ -H "Content-Type: application/json" \
83
+ -d '{
84
+ "credsPayload": { "credsPath": "shopify.au" },
85
+ "fulfillmentId": "104188477512",
86
+ "trackingInfoPayload": {
87
+ "company": "FastEx",
88
+ "number": "123456789",
89
+ "url": "https://track.example.com/123456789"
90
+ }
91
+ }'
92
+ */
@@ -2,7 +2,7 @@
2
2
  // https://shopify.dev/docs/api/admin-graphql/latest/queries/metafieldDefinition
3
3
 
4
4
  const { credsValidator } = require('../validators');
5
- const { ArgsWarden, objHasAny } = require('../utils');
5
+ const { ArgsWarden, actionSingleOrMultiple, everyIfArray, objHasAny } = require('../utils');
6
6
  const { shopifyMetafieldDefinitionGet } = require('./shopifyMetafieldDefinitionGet');
7
7
  const { shopifyMetafieldDefinitionCreate } = require('./shopifyMetafieldDefinitionCreate');
8
8
 
@@ -18,8 +18,8 @@ const metafieldDefinitionIdentifierValidator = (metafieldDefinitionIdentifier) =
18
18
 
19
19
  const argsWarden = new ArgsWarden([
20
20
  ['fromStoreCredsPayload', credsValidator],
21
- ['toStoreCredsPayload', credsValidator],
22
- ['metafieldDefinitionIdentifier', metafieldDefinitionIdentifierValidator],
21
+ ['toStoreCredsPayload', (i) => everyIfArray(credsValidator, i)],
22
+ ['metafieldDefinitionIdentifier', (i) => everyIfArray(metafieldDefinitionIdentifierValidator, i)],
23
23
  ]);
24
24
 
25
25
  const metafieldDefinitionInputFromDefinition = (sourceDefinition) => {
@@ -69,7 +69,7 @@ const metafieldDefinitionInputFromDefinition = (sourceDefinition) => {
69
69
  return definitionInput;
70
70
  };
71
71
 
72
- const shopifyMetafieldDefinitionPropagate = async (
72
+ const shopifyMetafieldDefinitionPropagateSingle = async (
73
73
  fromStoreCredsPayload,
74
74
  toStoreCredsPayload,
75
75
  metafieldDefinitionIdentifier,
@@ -79,15 +79,6 @@ const shopifyMetafieldDefinitionPropagate = async (
79
79
  } = {},
80
80
  ) => {
81
81
 
82
- const rejectResponse = await argsWarden.responseIfRejectingArgs({
83
- fromStoreCredsPayload,
84
- toStoreCredsPayload,
85
- metafieldDefinitionIdentifier,
86
- });
87
- if (rejectResponse) {
88
- return rejectResponse;
89
- }
90
-
91
82
  const sourceResponse = await shopifyMetafieldDefinitionGet(
92
83
  fromStoreCredsPayload,
93
84
  metafieldDefinitionIdentifier,
@@ -109,6 +100,29 @@ const shopifyMetafieldDefinitionPropagate = async (
109
100
  };
110
101
  }
111
102
 
103
+ const existingResponse = await shopifyMetafieldDefinitionGet(
104
+ toStoreCredsPayload,
105
+ metafieldDefinitionIdentifier,
106
+ {
107
+ apiVersion,
108
+ attrs: returnCreatedDefinitionAttrs,
109
+ },
110
+ );
111
+
112
+ if (!existingResponse.ok) {
113
+ return existingResponse;
114
+ }
115
+
116
+ if (existingResponse.data) {
117
+ return {
118
+ ok: true,
119
+ data: existingResponse.data,
120
+ meta: {
121
+ alreadyExisted: true,
122
+ },
123
+ };
124
+ }
125
+
112
126
  const definitionInput = metafieldDefinitionInputFromDefinition(sourceResponse.data);
113
127
 
114
128
  const {
@@ -143,6 +157,43 @@ const shopifyMetafieldDefinitionPropagate = async (
143
157
  );
144
158
  };
145
159
 
160
+ const shopifyMetafieldDefinitionPropagate = async (
161
+ fromStoreCredsPayload,
162
+ toStoreCredsPayload,
163
+ metafieldDefinitionIdentifier,
164
+ {
165
+ queueRunOptions,
166
+ apiVersion,
167
+ returnCreatedDefinitionAttrs = defaultReturnCreatedDefinitionAttrs,
168
+ } = {},
169
+ ) => {
170
+
171
+ const rejectResponse = await argsWarden.responseIfRejectingArgs({
172
+ fromStoreCredsPayload,
173
+ toStoreCredsPayload,
174
+ metafieldDefinitionIdentifier,
175
+ });
176
+ if (rejectResponse) {
177
+ return rejectResponse;
178
+ }
179
+
180
+ return actionSingleOrMultiple(
181
+ [toStoreCredsPayload, metafieldDefinitionIdentifier],
182
+ shopifyMetafieldDefinitionPropagateSingle,
183
+ (toStoreCredsPayloadItem, metafieldDefinitionIdentifierItem) => ({
184
+ args: [
185
+ fromStoreCredsPayload,
186
+ toStoreCredsPayloadItem,
187
+ metafieldDefinitionIdentifierItem,
188
+ { apiVersion, returnCreatedDefinitionAttrs },
189
+ ],
190
+ }),
191
+ {
192
+ ...(queueRunOptions ? { queueRunOptions } : {}),
193
+ },
194
+ );
195
+ };
196
+
146
197
  const funcApiConfig = {
147
198
  argsWarden,
148
199
  };
@@ -157,11 +208,11 @@ curl -X POST "http://localhost:8000/shopifyMetafieldDefinitionPropagate" \
157
208
  -H "Content-Type: application/json" \
158
209
  -d '{
159
210
  "fromStoreCredsPayload": { "credsPath": "shopify.au" },
160
- "toStoreCredsPayload": { "credsPath": "shopify.us" },
211
+ "toStoreCredsPayload": [{ "credsPath": "shopify.us" }, { "credsPath": "shopify.uk" }],
161
212
  "metafieldDefinitionIdentifier": {
162
213
  "ownerType": "PRODUCT",
163
- "namespace": "custom",
164
- "key": "outfit_builder_image"
214
+ "namespace": "merch",
215
+ "key": "associations"
165
216
  }
166
217
  }'
167
218
  */
@@ -0,0 +1,322 @@
1
+ const { credsValidator } = require('../validators');
2
+ const { ArgsWarden, operationQueueRunner, logDeep, askQuestion } = require('../utils');
3
+ const { objHasAny } = require('../utils');
4
+ const { shopifyOrderGet } = require('../shopify/shopifyOrderGet');
5
+ const { shopifyFulfillmentCreate } = require('../shopify/shopifyFulfillmentCreate');
6
+
7
+ const orderIdentifierValidator = (orderIdentifier) => {
8
+ return objHasAny(orderIdentifier, [
9
+ 'orderId',
10
+ 'orderName',
11
+ ]);
12
+ };
13
+
14
+ const fulfillmentPayloadValidator = (fulfillmentPayload) => {
15
+
16
+ const {
17
+ fulfillAll,
18
+ itemsBySku,
19
+ // TODO: Support fulfilling items by line item id
20
+
21
+ } = fulfillmentPayload;
22
+
23
+ if (fulfillAll === true) {
24
+ return true;
25
+ }
26
+
27
+ if (itemsBySku) {
28
+ const quantities = Object.values(itemsBySku);
29
+ return quantities.length > 0 && quantities.every(quantity => typeof quantity === 'number');
30
+ }
31
+
32
+ return false;
33
+ };
34
+
35
+ const argsWarden = new ArgsWarden([
36
+ ['credsPayload', credsValidator],
37
+ ['orderIdentifier', orderIdentifierValidator],
38
+ ['fulfillmentPayload', fulfillmentPayloadValidator],
39
+ ]);
40
+
41
+ const shopifyOrderFulfill = async (
42
+ credsPayload,
43
+ orderIdentifier,
44
+ fulfillmentPayload,
45
+ {
46
+ apiVersion,
47
+ // TODO: Support return schema
48
+ // returnSchema = 'deletedThingId',
49
+ } = {},
50
+ ) => {
51
+
52
+ const rejectResponse = await argsWarden.responseIfRejectingArgs({
53
+ credsPayload,
54
+ orderIdentifier,
55
+ fulfillmentPayload,
56
+ });
57
+ if (rejectResponse) {
58
+ return rejectResponse;
59
+ }
60
+
61
+ const {
62
+ fulfillAll = false,
63
+ itemsBySku,
64
+ notifyCustomer = false,
65
+ originAddress,
66
+ trackingInfo,
67
+ } = fulfillmentPayload;
68
+
69
+ const fetchFulfillmentOrdersLimit = 10;
70
+ const fetchFulfillmentsLimit = 10;
71
+ const fetchTrackingInfoLimit = 10;
72
+ const fetchLineItemsLimit = 250;
73
+
74
+ const openFulfillmentOrdersQuery = `displayable:true AND (request_status:UNSUBMITTED OR request_status:ACCEPTED)`;
75
+
76
+ const ORDER_ATTRS = `
77
+ fulfillable
78
+ fulfillments(first: ${ fetchFulfillmentsLimit }) {
79
+ trackingInfo(first: ${ fetchTrackingInfoLimit }) {
80
+ number
81
+ }
82
+ }
83
+ fulfillmentOrders(first: ${ fetchFulfillmentOrdersLimit }, query: "${ openFulfillmentOrdersQuery }") {
84
+ edges {
85
+ node {
86
+ id
87
+ requestStatus
88
+ ${ itemsBySku ? `
89
+ lineItems (first: ${ fetchLineItemsLimit }) {
90
+ edges {
91
+ node {
92
+ id
93
+ sku
94
+ remainingQuantity
95
+ requiresShipping
96
+ }
97
+ }
98
+ }
99
+ ` : '' }
100
+ }
101
+ }
102
+ }
103
+ `;
104
+
105
+ // Get open fulfillment orders
106
+ const orderResponse = await shopifyOrderGet(
107
+ credsPayload,
108
+ orderIdentifier,
109
+ {
110
+ apiVersion,
111
+ attrs: ORDER_ATTRS,
112
+ },
113
+ );
114
+
115
+ const { ok: orderOk, data: order } = orderResponse;
116
+ if (!orderOk || !order) {
117
+ return orderResponse;
118
+ }
119
+
120
+ const {
121
+ fulfillable,
122
+ fulfillments,
123
+ fulfillmentOrders,
124
+ } = order;
125
+
126
+ if (fulfillments.length >= fetchFulfillmentsLimit) {
127
+ return {
128
+ ok: false,
129
+ error: {
130
+ code: 'LIMIT_REACHED',
131
+ message: `We retrieved ${ fetchFulfillmentsLimit } fulfillments, so there may be more. Please adjust the function.`,
132
+ },
133
+ data: order,
134
+ };
135
+ }
136
+
137
+ if (fulfillments.some(f => f?.trackingInfo?.length > fetchTrackingInfoLimit)) {
138
+ return {
139
+ ok: false,
140
+ error: {
141
+ code: 'LIMIT_REACHED',
142
+ message: `We retrieved ${ fetchTrackingInfoLimit } tracking info, so there may be more. Please adjust the function.`,
143
+ },
144
+ data: order,
145
+ };
146
+ }
147
+
148
+ if (fulfillmentOrders.length >= fetchFulfillmentOrdersLimit) {
149
+ return {
150
+ ok: false,
151
+ error: {
152
+ code: 'LIMIT_REACHED',
153
+ message: `We retrieved ${ fetchFulfillmentOrdersLimit } open fulfillment orders, so there may be more. Please adjust the function.`,
154
+ },
155
+ data: order,
156
+ };
157
+ }
158
+
159
+ if (!fulfillable) {
160
+ return {
161
+ ok: false,
162
+ error: 'Order is not fulfillable',
163
+ data: order,
164
+ };
165
+ }
166
+
167
+ const seenTrackingNumbers = fulfillments.map(f => f?.trackingInfo?.map(t => t.number)).flat().filter(Boolean);
168
+
169
+ if (trackingInfo?.number && seenTrackingNumbers.includes(trackingInfo.number)) {
170
+ return {
171
+ ok: true,
172
+ meta: {
173
+ alreadyFulfilled: true,
174
+ trackingNumber: trackingInfo.number,
175
+ },
176
+ };
177
+ }
178
+
179
+ // if using fulfillAll, fulfill them
180
+ if (fulfillAll === true) {
181
+ return shopifyFulfillmentCreate(
182
+ credsPayload,
183
+ fulfillmentOrders.map(fulfillmentOrder => {
184
+ const {
185
+ id: fulfillmentOrderGid,
186
+ } = fulfillmentOrder;
187
+
188
+ return {
189
+ lineItemsByFulfillmentOrder: [{
190
+ fulfillmentOrderId: fulfillmentOrderGid,
191
+ }],
192
+ notifyCustomer,
193
+ originAddress,
194
+ trackingInfo,
195
+ };
196
+ }),
197
+ {
198
+ apiVersion,
199
+ },
200
+ );
201
+ }
202
+
203
+ // if using itemsBySku, iterate over unfulfilled line items and decrement until complete, making a queue of fulfillments to action
204
+ if (itemsBySku) {
205
+ const depletableItemsBySku = { ...itemsBySku };
206
+ const shopifyFulfillmentCreatePayloads = [];
207
+
208
+ for (const fulfillmentOrder of fulfillmentOrders) {
209
+ const {
210
+ id: fulfillmentOrderGid,
211
+ lineItems = [],
212
+ } = fulfillmentOrder;
213
+
214
+ if (lineItems.length >= fetchLineItemsLimit) {
215
+ return {
216
+ ok: false,
217
+ error: {
218
+ code: 'LIMIT_REACHED',
219
+ message: `We retrieved ${ fetchLineItemsLimit } line items on a fulfillment order, so there may be more. Please adjust the function.`,
220
+ },
221
+ data: order,
222
+ };
223
+ }
224
+
225
+ const fulfillmentOrderLineItems = [];
226
+
227
+ for (const lineItem of lineItems) {
228
+ const {
229
+ id: lineItemGid,
230
+ sku,
231
+ remainingQuantity,
232
+ requiresShipping,
233
+ } = lineItem;
234
+
235
+ if (!requiresShipping || remainingQuantity <= 0) {
236
+ continue;
237
+ }
238
+
239
+ const depletableQuantity = depletableItemsBySku[sku];
240
+ if (!depletableQuantity) {
241
+ continue;
242
+ }
243
+
244
+ const quantity = Math.min(remainingQuantity, depletableQuantity);
245
+ fulfillmentOrderLineItems.push({
246
+ id: lineItemGid,
247
+ quantity,
248
+ });
249
+ depletableItemsBySku[sku] -= quantity;
250
+ }
251
+
252
+ if (fulfillmentOrderLineItems.length > 0) {
253
+ shopifyFulfillmentCreatePayloads.push([
254
+ credsPayload,
255
+ {
256
+ lineItemsByFulfillmentOrder: [{
257
+ fulfillmentOrderId: fulfillmentOrderGid,
258
+ fulfillmentOrderLineItems,
259
+ }],
260
+ notifyCustomer,
261
+ originAddress,
262
+ trackingInfo,
263
+ },
264
+ {
265
+ apiVersion,
266
+ },
267
+ ]);
268
+ }
269
+ }
270
+
271
+ if (shopifyFulfillmentCreatePayloads.length === 0) {
272
+ return {
273
+ ok: false,
274
+ error: {
275
+ message: 'No fulfillable line items found',
276
+ },
277
+ data: order,
278
+ };
279
+ }
280
+
281
+ logDeep({ depletableItemsBySku, shopifyFulfillmentCreatePayloads });
282
+ await askQuestion('Continue?');
283
+
284
+ return operationQueueRunner(
285
+ shopifyFulfillmentCreate,
286
+ shopifyFulfillmentCreatePayloads,
287
+ );
288
+ }
289
+
290
+ return {
291
+ ok: false,
292
+ error: `I guess we don't do that yet`,
293
+ };
294
+ };
295
+
296
+ const funcApiConfig = {
297
+ argsWarden,
298
+ };
299
+
300
+ module.exports = {
301
+ shopifyOrderFulfill,
302
+ funcApiConfig,
303
+ };
304
+
305
+ /*
306
+ curl -X POST "http://localhost:8000/shopifyOrderFulfill" \
307
+ -H "Content-Type: application/json" \
308
+ -d `{
309
+ "credsPayload": { "credsPath": "shopify.au" },
310
+ "orderIdentifier": { "orderId": "104188477512" },
311
+ "fulfillmentPayload": {
312
+ "fulfillAll": true,
313
+ "notifyCustomer": true,
314
+ "originAddress": { "countryCode": "AU" },
315
+ "trackingInfo": {
316
+ "number": "1234567890",
317
+ "company": "Kiki's Delivery Service",
318
+ "url": "https://www.studioghibli.com.au/kikisdeliveryservice"
319
+ }
320
+ }
321
+ }`
322
+ */
@@ -8,9 +8,19 @@ const {
8
8
  const interpretStarshipitResponse = async (state) => {
9
9
  const { response } = state;
10
10
 
11
- const { success, results, errors } = response.data;
11
+ const responseData = response.data || {};
12
+ const {
13
+ success,
14
+ succeeded,
15
+ results,
16
+ errors,
17
+ data: nestedData,
18
+ ...rest
19
+ } = responseData;
20
+
21
+ const ok = success === true || succeeded === true;
12
22
 
13
- if (!success) {
23
+ if (!ok) {
14
24
  const firstError = errors?.[0];
15
25
 
16
26
  // TODO: Consider removing data if errors
@@ -27,10 +37,25 @@ const interpretStarshipitResponse = async (state) => {
27
37
  };
28
38
  }
29
39
 
40
+ let data;
41
+ if (results !== undefined) {
42
+ data = results;
43
+ } else if (nestedData && typeof nestedData === 'object' && !Array.isArray(nestedData)) {
44
+ // List endpoints return { page_*, data: { orders|products }, succeeded }
45
+ data = {
46
+ ...rest,
47
+ ...nestedData,
48
+ ...success !== undefined && { success },
49
+ ...succeeded !== undefined && { succeeded },
50
+ };
51
+ } else {
52
+ data = responseData;
53
+ }
54
+
30
55
  return {
31
56
  response: {
32
57
  ...response,
33
- data: results ?? response.data,
58
+ data,
34
59
  },
35
60
  };
36
61
  };
@@ -0,0 +1,96 @@
1
+ // https://api-docs.starshipit.com/#0aef707f-e2f5-493a-a382-8235c00c9c18
2
+
3
+ const { ArgsWarden } = require('../utils');
4
+ const { credsValidator } = require('../validators');
5
+ const { starshipitGet, starshipitGetter } = require('../starshipit/starshipitGet');
6
+
7
+ const ORDERS_MAX_PER_PAGE = 500;
8
+
9
+ const argsWarden = new ArgsWarden([
10
+ ['credsPayload', credsValidator],
11
+ ]);
12
+
13
+ const starshipitOrdersGet = async (
14
+ returnGetter,
15
+
16
+ credsPayload,
17
+ {
18
+ orderId,
19
+ orderNumber,
20
+ status,
21
+ filter,
22
+ include,
23
+ sortColumn,
24
+ sortDirection,
25
+ perPage = ORDERS_MAX_PER_PAGE,
26
+ ...getterOptions
27
+ } = {},
28
+ ) => {
29
+
30
+ const rejectResponse = await argsWarden.responseIfRejectingArgs({
31
+ credsPayload,
32
+ });
33
+ if (rejectResponse) {
34
+ return rejectResponse;
35
+ }
36
+
37
+ const getterArgs = [
38
+ credsPayload,
39
+ '/orders',
40
+ {
41
+ nodeName: 'orders',
42
+ perPage,
43
+ params: {
44
+ ...orderId && { order_id: orderId },
45
+ ...orderNumber && { order_number: orderNumber },
46
+ ...status && { status },
47
+ ...filter && { filter },
48
+ ...include && { include },
49
+ ...sortColumn && { sort_column: sortColumn },
50
+ ...sortDirection && { sort_direction: sortDirection },
51
+ },
52
+ digester: (response) => {
53
+ if (!response?.ok) {
54
+ return [];
55
+ }
56
+ const { orders, order } = response.data || {};
57
+ if (Array.isArray(orders)) {
58
+ return orders;
59
+ }
60
+ if (order) {
61
+ return [order];
62
+ }
63
+ return [];
64
+ },
65
+ ...getterOptions,
66
+ },
67
+ ];
68
+
69
+ return returnGetter
70
+ ? starshipitGetter(...getterArgs)
71
+ : starshipitGet(...getterArgs);
72
+ };
73
+
74
+ const funcApiConfig = {
75
+ argsWarden,
76
+ };
77
+
78
+ module.exports = {
79
+ starshipitOrdersGet: (...args) => starshipitOrdersGet(false, ...args),
80
+ starshipitOrdersGetter: (...args) => starshipitOrdersGet(true, ...args),
81
+ funcApiConfig,
82
+ };
83
+
84
+ /*
85
+ curl -X POST "http://localhost:8000/starshipitOrdersGet" \
86
+ -H "Content-Type: application/json" \
87
+ -d '{
88
+ "credsPayload": { "credsPath": "starshipit.wf" },
89
+ "options": {
90
+ "status": "Shipped",
91
+ "include": ["Items", "Packages"],
92
+ "perPage": 2,
93
+ "limit": 2
94
+ }
95
+ }'
96
+ */
package/api/utils.js CHANGED
@@ -17,6 +17,14 @@ const objHasAny = (obj, keys) => {
17
17
  return keys.some((key) => obj[key] !== undefined);
18
18
  };
19
19
 
20
+ const objHasAll = (obj, keys) => {
21
+ if (obj == null || typeof obj !== 'object') {
22
+ return false;
23
+ }
24
+
25
+ return keys.every((key) => obj[key] !== undefined);
26
+ };
27
+
20
28
  const capitaliseString = (string) => `${ string[0].toUpperCase() }${ string.slice(1) }`;
21
29
  const sentenceCaseString = (string) => `${ string[0].toLowerCase() }${ string.slice(1) }`;
22
30
 
@@ -199,7 +207,19 @@ const customFetch = async (url, {
199
207
  }
200
208
 
201
209
  if (params) {
202
- const search = new URLSearchParams(params);
210
+ const search = new URLSearchParams();
211
+ for (const [key, value] of Object.entries(params)) {
212
+ if (value === undefined || value === null) {
213
+ continue;
214
+ }
215
+ if (Array.isArray(value)) {
216
+ for (const entry of value) {
217
+ search.append(key, entry);
218
+ }
219
+ } else {
220
+ search.append(key, value);
221
+ }
222
+ }
203
223
  url += (url.includes('?') ? '&' : '?') + search.toString();
204
224
  }
205
225
 
@@ -790,6 +810,7 @@ class OperationQueue {
790
810
  async run({
791
811
  interval = false,
792
812
  verbose = true,
813
+ inspect = false,
793
814
  } = {}) {
794
815
 
795
816
  // Run at interval
@@ -825,7 +846,18 @@ class OperationQueue {
825
846
  // Run in sequence
826
847
  const results = [];
827
848
  for (const op of this.queue) {
849
+
828
850
  const result = await op.run();
851
+
852
+ if (inspect !== false && (
853
+ inspect === true
854
+ || (typeof inspect === 'function' ? inspect(result) : objectMatchesPartial(result, inspect))
855
+ )) {
856
+ inspect && logDeep({ op });
857
+ inspect && logDeep({ result });
858
+ inspect && await askQuestion('Continue?');
859
+ }
860
+
829
861
  results.push(result);
830
862
  if (verbose) {
831
863
  console.log(`${ results.length } / ${ this.queue.length }`);
@@ -837,6 +869,16 @@ class OperationQueue {
837
869
  }
838
870
  }
839
871
 
872
+ const operationQueueRunner = async (func, payloads, { queueRunOptions = {} } = {}) => {
873
+ const queue = new OperationQueue(payloads.map(payload => new Operation(
874
+ func,
875
+ { args: payload },
876
+ )));
877
+
878
+ const queueResponses = await queue.run(queueRunOptions);
879
+ return responseArrayToResponse(queueResponses);
880
+ };
881
+
840
882
  const simpleSort = (arr, prop, { reverse } = {}) => {
841
883
  return [...arr].sort((a, b) =>
842
884
  reverse ? b[prop] - a[prop] : a[prop] - b[prop],
@@ -1236,10 +1278,48 @@ const gidToId = (gid) => {
1236
1278
  return gid.split('/').pop();
1237
1279
  };
1238
1280
 
1281
+ const objectToArray = (object, { keyProp } = {}) => {
1282
+ if (!keyProp) {
1283
+ return Object.values(object);
1284
+ }
1285
+
1286
+ return Object.entries(object).map(([key, value]) => ({
1287
+ [keyProp]: key,
1288
+ ...value,
1289
+ }));
1290
+ };
1291
+
1292
+ const objectMatchesPartial = (object, partial) => {
1293
+ if (object === partial) {
1294
+ return true;
1295
+ }
1296
+
1297
+ if (
1298
+ partial === null
1299
+ || typeof partial !== 'object'
1300
+ || object === null
1301
+ || typeof object !== 'object'
1302
+ ) {
1303
+ return false;
1304
+ }
1305
+
1306
+ if (Array.isArray(partial)) {
1307
+ if (!Array.isArray(object)) {
1308
+ return false;
1309
+ }
1310
+ return partial.every((item, i) => objectMatchesPartial(object[i], item));
1311
+ }
1312
+
1313
+ return Object.entries(partial).every(([key, value]) => (
1314
+ objectMatchesPartial(object[key], value)
1315
+ ));
1316
+ };
1317
+
1239
1318
  module.exports = {
1240
1319
  wait,
1241
1320
  timeMs,
1242
1321
  objHasAny,
1322
+ objHasAll,
1243
1323
  capitaliseString,
1244
1324
  normalise,
1245
1325
  credsFromPayload,
@@ -1264,6 +1344,7 @@ module.exports = {
1264
1344
  responseResultsByOutcome,
1265
1345
  Operation,
1266
1346
  OperationQueue,
1347
+ operationQueueRunner,
1267
1348
  actionSingleOrMultiple,
1268
1349
  Processor,
1269
1350
  Getter,
@@ -1271,4 +1352,6 @@ module.exports = {
1271
1352
  ArgsWarden,
1272
1353
  gidToId,
1273
1354
  valueProvided,
1355
+ objectToArray,
1356
+ objectMatchesPartial,
1274
1357
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxtware/mineral",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "bin": {
5
5
  "mineral": "bin/mineral.js"
6
6
  },