@jytextiles/medusa-plugin-faire-store-sync 0.1.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/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +74 -0
- package/.medusa/server/src/__tests__/webhook.spec.js +77 -0
- package/.medusa/server/src/admin/index.js +960 -0
- package/.medusa/server/src/admin/index.mjs +959 -0
- package/.medusa/server/src/api/admin/faire/auth/authorize/route.js +12 -0
- package/.medusa/server/src/api/admin/faire/auth/callback/route.js +25 -0
- package/.medusa/server/src/api/admin/faire/auth/disconnect/route.js +12 -0
- package/.medusa/server/src/api/admin/faire/brand/route.js +14 -0
- package/.medusa/server/src/api/admin/faire/ingest/orders/[batchId]/route.js +27 -0
- package/.medusa/server/src/api/admin/faire/ingest/orders/route.js +20 -0
- package/.medusa/server/src/api/admin/faire/products/route.js +16 -0
- package/.medusa/server/src/api/admin/faire/settings/route.js +19 -0
- package/.medusa/server/src/api/admin/faire/status/product/[id]/route.js +30 -0
- package/.medusa/server/src/api/admin/faire/status/route.js +46 -0
- package/.medusa/server/src/api/admin/faire/sync/bulk/[batchId]/route.js +29 -0
- package/.medusa/server/src/api/admin/faire/sync/bulk/route.js +20 -0
- package/.medusa/server/src/api/admin/faire/sync/product/[id]/route.js +14 -0
- package/.medusa/server/src/api/admin/faire/syncs/[id]/route.js +32 -0
- package/.medusa/server/src/api/admin/faire/syncs/route.js +18 -0
- package/.medusa/server/src/api/webhooks/faire/route.js +125 -0
- package/.medusa/server/src/jobs/pull-faire-orders.js +32 -0
- package/.medusa/server/src/jobs/refresh-faire-token.js +25 -0
- package/.medusa/server/src/lib/faire-client.js +321 -0
- package/.medusa/server/src/lib/types.js +10 -0
- package/.medusa/server/src/lib/webhook.js +58 -0
- package/.medusa/server/src/links/product-faire-sync-link.js +57 -0
- package/.medusa/server/src/modules/faire-sync/index.js +15 -0
- package/.medusa/server/src/modules/faire-sync/migrations/Migration20260707211346.js +34 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-order.js +21 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +17 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-batch.js +18 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-record.js +22 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-settings.js +26 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-webhook-event.js +21 -0
- package/.medusa/server/src/modules/faire-sync/service.js +222 -0
- package/.medusa/server/src/subscribers/faire-inventory-sync.js +87 -0
- package/.medusa/server/src/subscribers/faire-listing-events.js +46 -0
- package/.medusa/server/src/subscribers/faire-order-ingest.js +54 -0
- package/.medusa/server/src/subscribers/faire-order-lifecycle.js +71 -0
- package/.medusa/server/src/subscribers/faire-product-status-sync.js +71 -0
- package/.medusa/server/src/workflows/index.js +21 -0
- package/.medusa/server/src/workflows/ingest-faire-order-support.js +76 -0
- package/.medusa/server/src/workflows/ingest-faire-order.js +148 -0
- package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +124 -0
- package/.medusa/server/src/workflows/refresh-faire-token.js +20 -0
- package/.medusa/server/src/workflows/sync-product-to-faire.js +262 -0
- package/.medusa/server/src/workflows/sync-products-to-faire.js +90 -0
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/package.json +84 -0
- package/scripts/postbuild.js +36 -0
- package/src/__tests__/ingest-faire-order-support.spec.ts +77 -0
- package/src/__tests__/webhook.spec.ts +81 -0
- package/src/admin/lib/api.ts +88 -0
- package/src/admin/routes/settings/faire/[id]/page.tsx +184 -0
- package/src/admin/routes/settings/faire/hooks/use-faire-sync-columns.tsx +80 -0
- package/src/admin/routes/settings/faire/page.tsx +445 -0
- package/src/admin/routes/settings/faire/settings/page.tsx +286 -0
- package/src/admin/routes/settings/oauth/faire/callback/page.tsx +67 -0
- package/src/admin/widgets/faire-product-widget.tsx +180 -0
- package/src/api/admin/faire/auth/authorize/route.ts +10 -0
- package/src/api/admin/faire/auth/callback/route.ts +29 -0
- package/src/api/admin/faire/auth/disconnect/route.ts +10 -0
- package/src/api/admin/faire/brand/route.ts +12 -0
- package/src/api/admin/faire/ingest/orders/[batchId]/route.ts +31 -0
- package/src/api/admin/faire/ingest/orders/route.ts +29 -0
- package/src/api/admin/faire/products/route.ts +14 -0
- package/src/api/admin/faire/settings/route.ts +17 -0
- package/src/api/admin/faire/status/product/[id]/route.ts +30 -0
- package/src/api/admin/faire/status/route.ts +47 -0
- package/src/api/admin/faire/sync/bulk/[batchId]/route.ts +33 -0
- package/src/api/admin/faire/sync/bulk/route.ts +27 -0
- package/src/api/admin/faire/sync/product/[id]/route.ts +13 -0
- package/src/api/admin/faire/syncs/[id]/route.ts +32 -0
- package/src/api/admin/faire/syncs/route.ts +17 -0
- package/src/api/webhooks/faire/route.ts +133 -0
- package/src/jobs/pull-faire-orders.ts +30 -0
- package/src/jobs/refresh-faire-token.ts +23 -0
- package/src/lib/faire-client.ts +436 -0
- package/src/lib/types.ts +117 -0
- package/src/lib/webhook.ts +85 -0
- package/src/links/product-faire-sync-link.ts +56 -0
- package/src/modules/faire-sync/index.ts +11 -0
- package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +1486 -0
- package/src/modules/faire-sync/migrations/Migration20260707211346.ts +43 -0
- package/src/modules/faire-sync/models/faire-order.ts +20 -0
- package/src/modules/faire-sync/models/faire-sync-account.ts +16 -0
- package/src/modules/faire-sync/models/faire-sync-batch.ts +17 -0
- package/src/modules/faire-sync/models/faire-sync-record.ts +21 -0
- package/src/modules/faire-sync/models/faire-sync-settings.ts +25 -0
- package/src/modules/faire-sync/models/faire-webhook-event.ts +20 -0
- package/src/modules/faire-sync/service.ts +252 -0
- package/src/subscribers/faire-inventory-sync.ts +97 -0
- package/src/subscribers/faire-listing-events.ts +48 -0
- package/src/subscribers/faire-order-ingest.ts +61 -0
- package/src/subscribers/faire-order-lifecycle.ts +81 -0
- package/src/subscribers/faire-product-status-sync.ts +76 -0
- package/src/workflows/index.ts +4 -0
- package/src/workflows/ingest-faire-order-support.ts +96 -0
- package/src/workflows/ingest-faire-order.ts +212 -0
- package/src/workflows/ingest-faire-orders-bulk.ts +168 -0
- package/src/workflows/refresh-faire-token.ts +28 -0
- package/src/workflows/sync-product-to-faire.ts +375 -0
- package/src/workflows/sync-products-to-faire.ts +133 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStep,
|
|
3
|
+
createWorkflow,
|
|
4
|
+
StepResponse,
|
|
5
|
+
WorkflowResponse,
|
|
6
|
+
WorkflowData,
|
|
7
|
+
transform,
|
|
8
|
+
} from "@medusajs/framework/workflows-sdk"
|
|
9
|
+
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
|
10
|
+
import type { Link } from "@medusajs/modules-sdk"
|
|
11
|
+
import type { RemoteQueryFunction } from "@medusajs/types"
|
|
12
|
+
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
13
|
+
import FaireSyncService from "../modules/faire-sync/service"
|
|
14
|
+
import {
|
|
15
|
+
CreateProductInput,
|
|
16
|
+
ProductResponse,
|
|
17
|
+
} from "../lib/types"
|
|
18
|
+
|
|
19
|
+
export const SYNC_PRODUCT_TO_FAIRE = "faire-sync-product"
|
|
20
|
+
|
|
21
|
+
export type SyncProductToFaireInput = {
|
|
22
|
+
product_id: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ResolvedConfig = {
|
|
26
|
+
account_id: string
|
|
27
|
+
brand_id: string
|
|
28
|
+
access_token: string
|
|
29
|
+
currency: string | null
|
|
30
|
+
settings: any
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type PreparedProduct = {
|
|
34
|
+
product_id: string
|
|
35
|
+
product_title: string
|
|
36
|
+
product_status: string
|
|
37
|
+
product_input: CreateProductInput
|
|
38
|
+
existing_product_token: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type SyncResult = {
|
|
42
|
+
product: ProductResponse
|
|
43
|
+
published: boolean
|
|
44
|
+
warnings: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Faire doesn't expose a public storefront URL for every product the way Etsy
|
|
49
|
+
* does; fall back to the brand portal link if no url is present.
|
|
50
|
+
*/
|
|
51
|
+
const productDisplayUrl = (product: ProductResponse): string | null => {
|
|
52
|
+
if (product.url) return product.url
|
|
53
|
+
if (!product.product_token) return null
|
|
54
|
+
return `https://www.faire.com/brand/products/${product.product_token}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Step 1: resolve account (fresh token) + settings ─────────────────────
|
|
58
|
+
|
|
59
|
+
const resolveFaireConfigStep = createStep(
|
|
60
|
+
"faire-resolve-config-step",
|
|
61
|
+
async (_, { container }): Promise<StepResponse<ResolvedConfig>> => {
|
|
62
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
63
|
+
const account = await service.ensureFreshToken()
|
|
64
|
+
const settings = await service.getSettings()
|
|
65
|
+
return new StepResponse({
|
|
66
|
+
account_id: account.id,
|
|
67
|
+
brand_id: account.brand_id,
|
|
68
|
+
access_token: account.access_token,
|
|
69
|
+
currency: account.currency ?? null,
|
|
70
|
+
settings,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// ── Step 2: fetch + map product ──────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const prepareProductStep = createStep(
|
|
78
|
+
"faire-prepare-product-step",
|
|
79
|
+
async (
|
|
80
|
+
input: { product_id: string; settings: any; brand_id: string },
|
|
81
|
+
{ container }
|
|
82
|
+
): Promise<StepResponse<PreparedProduct>> => {
|
|
83
|
+
const query = container.resolve(
|
|
84
|
+
ContainerRegistrationKeys.QUERY
|
|
85
|
+
) as Omit<RemoteQueryFunction, symbol>
|
|
86
|
+
const remoteLink = container.resolve(ContainerRegistrationKeys.LINK) as Link
|
|
87
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
88
|
+
|
|
89
|
+
const { data: products } = await query.graph({
|
|
90
|
+
entity: "product",
|
|
91
|
+
fields: [
|
|
92
|
+
"id",
|
|
93
|
+
"title",
|
|
94
|
+
"description",
|
|
95
|
+
"status",
|
|
96
|
+
"metadata",
|
|
97
|
+
"tags.*",
|
|
98
|
+
"variants.*",
|
|
99
|
+
"variants.prices.*",
|
|
100
|
+
"variants.inventory_items.*",
|
|
101
|
+
"images.*",
|
|
102
|
+
"variants.sku",
|
|
103
|
+
],
|
|
104
|
+
filters: { id: input.product_id },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const product: any = products?.[0]
|
|
108
|
+
if (!product) {
|
|
109
|
+
throw new Error(`Product ${input.product_id} not found`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Resolve existing Faire product (from link)
|
|
113
|
+
let existing_product_token: string | null = null
|
|
114
|
+
const account = await service.getActiveAccount()
|
|
115
|
+
if (account) {
|
|
116
|
+
try {
|
|
117
|
+
const linkRows = await remoteLink.list({
|
|
118
|
+
[Modules.PRODUCT]: { product_id: input.product_id },
|
|
119
|
+
[FAIRE_SYNC_MODULE]: { faire_sync_account_id: account.id },
|
|
120
|
+
})
|
|
121
|
+
const row: any = (linkRows as any[])?.[0]
|
|
122
|
+
existing_product_token = row?.faire_product_token ?? null
|
|
123
|
+
} catch {
|
|
124
|
+
// no link yet
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const settings = input.settings
|
|
129
|
+
const metadata = product.metadata || {}
|
|
130
|
+
const variants: any[] = product.variants || []
|
|
131
|
+
|
|
132
|
+
// Medusa v2 stores money as a whole decimal in the price's currency (NOT
|
|
133
|
+
// minor units). Faire wants integer cents, so round to cents.
|
|
134
|
+
const toCents = (amount: any): number => Math.round(Number(amount || 0) * 100)
|
|
135
|
+
|
|
136
|
+
const markupPercent =
|
|
137
|
+
Number(metadata.faire_wholesale_markup_percent ?? settings.default_wholesale_markup_percent) ||
|
|
138
|
+
0
|
|
139
|
+
|
|
140
|
+
const buildVariant = (v: any) => {
|
|
141
|
+
const prices: any[] = v.prices || []
|
|
142
|
+
const retail = prices[0] ?? prices.find(Boolean)
|
|
143
|
+
const retailCents = retail ? toCents(retail.amount) : 0
|
|
144
|
+
const wholesaleCents =
|
|
145
|
+
markupPercent > 0
|
|
146
|
+
? Math.round((retailCents * (100 - markupPercent)) / 100)
|
|
147
|
+
: retailCents
|
|
148
|
+
return {
|
|
149
|
+
sku: v.sku || v.id,
|
|
150
|
+
name: v.title || undefined,
|
|
151
|
+
retail_price_cents: retailCents || undefined,
|
|
152
|
+
wholesale_price_cents: wholesaleCents || undefined,
|
|
153
|
+
inventory_count: Number(v.inventory_quantity) || 0,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Aggregate retail/wholesale from min-variant for the product-level price.
|
|
158
|
+
const allPrices = variants.flatMap((v) => v.prices || [])
|
|
159
|
+
const minRetail =
|
|
160
|
+
allPrices
|
|
161
|
+
.map((p) => toCents(p.amount))
|
|
162
|
+
.filter((a) => a > 0)
|
|
163
|
+
.sort((a, b) => a - b)[0] ?? 0
|
|
164
|
+
const minWholesale =
|
|
165
|
+
markupPercent > 0
|
|
166
|
+
? Math.round((minRetail * (100 - markupPercent)) / 100)
|
|
167
|
+
: minRetail
|
|
168
|
+
|
|
169
|
+
const tags = (product.tags || [])
|
|
170
|
+
.map((t: any) => t.value)
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
|
|
173
|
+
const images = (product.images || [])
|
|
174
|
+
.map((img: any) => ({ url: img.url }))
|
|
175
|
+
.filter((i: any) => i.url)
|
|
176
|
+
|
|
177
|
+
const product_input: CreateProductInput = {
|
|
178
|
+
brand_id: input.brand_id,
|
|
179
|
+
name: (product.title || "Untitled Product").slice(0, 140),
|
|
180
|
+
description: product.description || "",
|
|
181
|
+
wholesale_price_cents: minWholesale || undefined,
|
|
182
|
+
retail_price_cents: minRetail || undefined,
|
|
183
|
+
images,
|
|
184
|
+
variants: variants.length ? variants.map(buildVariant) : undefined,
|
|
185
|
+
tags,
|
|
186
|
+
short_description:
|
|
187
|
+
typeof metadata.faire_short_description === "string"
|
|
188
|
+
? metadata.faire_short_description
|
|
189
|
+
: undefined,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return new StepResponse({
|
|
193
|
+
product_id: input.product_id,
|
|
194
|
+
product_title: product.title,
|
|
195
|
+
product_status: product.status,
|
|
196
|
+
product_input,
|
|
197
|
+
existing_product_token,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
// ── Step 3: create/update product + push inventory ───────────────────────
|
|
203
|
+
|
|
204
|
+
const syncProductStep = createStep(
|
|
205
|
+
"faire-sync-product-step",
|
|
206
|
+
async (
|
|
207
|
+
input: {
|
|
208
|
+
config: ResolvedConfig
|
|
209
|
+
prepared: PreparedProduct
|
|
210
|
+
},
|
|
211
|
+
{ container }
|
|
212
|
+
): Promise<StepResponse<SyncResult>> => {
|
|
213
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
214
|
+
const client = service.getClient()
|
|
215
|
+
const { access_token, settings } = input.config
|
|
216
|
+
const { product_input, existing_product_token, product_status } =
|
|
217
|
+
input.prepared
|
|
218
|
+
|
|
219
|
+
const warnings: string[] = []
|
|
220
|
+
let product: ProductResponse
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
if (existing_product_token) {
|
|
224
|
+
product = await client.updateProduct(access_token, existing_product_token, {
|
|
225
|
+
name: product_input.name,
|
|
226
|
+
description: product_input.description,
|
|
227
|
+
images: product_input.images,
|
|
228
|
+
variants: product_input.variants,
|
|
229
|
+
tags: product_input.tags,
|
|
230
|
+
})
|
|
231
|
+
} else {
|
|
232
|
+
product = await client.createProduct(access_token, product_input)
|
|
233
|
+
}
|
|
234
|
+
} catch (err: any) {
|
|
235
|
+
// Image fetch/upload failures shouldn't abort the whole sync.
|
|
236
|
+
warnings.push(`Product create/update failed: ${err?.message || err}`)
|
|
237
|
+
throw err
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Push inventory levels for each variant.
|
|
241
|
+
if (product_input.variants?.length) {
|
|
242
|
+
try {
|
|
243
|
+
const levels = product_input.variants.map((v) => ({
|
|
244
|
+
sku: v.sku,
|
|
245
|
+
current_count: v.inventory_count ?? 0,
|
|
246
|
+
}))
|
|
247
|
+
await client.updateInventory(access_token, levels)
|
|
248
|
+
} catch (err: any) {
|
|
249
|
+
warnings.push(`Inventory push failed: ${err?.message || err}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Publish decision: follow product status.
|
|
254
|
+
const shouldPublish =
|
|
255
|
+
settings.follow_product_status !== false && product_status === "published"
|
|
256
|
+
const published = shouldPublish && product.state === "active"
|
|
257
|
+
|
|
258
|
+
return new StepResponse({
|
|
259
|
+
product,
|
|
260
|
+
published,
|
|
261
|
+
warnings,
|
|
262
|
+
})
|
|
263
|
+
},
|
|
264
|
+
async () => {}
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
// ── Step 4: persist sync record + link ───────────────────────────────────
|
|
268
|
+
|
|
269
|
+
const persistSyncResultStep = createStep(
|
|
270
|
+
"faire-persist-sync-result-step",
|
|
271
|
+
async (
|
|
272
|
+
input: {
|
|
273
|
+
config: ResolvedConfig
|
|
274
|
+
prepared: PreparedProduct
|
|
275
|
+
result: SyncResult
|
|
276
|
+
},
|
|
277
|
+
{ container }
|
|
278
|
+
): Promise<StepResponse<void>> => {
|
|
279
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
280
|
+
const remoteLink = container.resolve(ContainerRegistrationKeys.LINK) as Link
|
|
281
|
+
|
|
282
|
+
const { config, prepared, result } = input
|
|
283
|
+
const status: "success" | "draft" = result.published
|
|
284
|
+
? "success"
|
|
285
|
+
: result.product.state === "draft"
|
|
286
|
+
? "draft"
|
|
287
|
+
: "success"
|
|
288
|
+
|
|
289
|
+
await service.createSyncRecord({
|
|
290
|
+
product_id: prepared.product_id,
|
|
291
|
+
account_id: config.account_id,
|
|
292
|
+
product_token: result.product.product_token,
|
|
293
|
+
product_url: productDisplayUrl(result.product),
|
|
294
|
+
product_state: result.product.state,
|
|
295
|
+
action: prepared.existing_product_token ? "update" : "create",
|
|
296
|
+
status,
|
|
297
|
+
published: result.published,
|
|
298
|
+
error_message: result.warnings.length ? result.warnings.join(" | ") : null,
|
|
299
|
+
warnings: result.warnings,
|
|
300
|
+
metadata: { title: prepared.product_title },
|
|
301
|
+
synced_at: new Date(),
|
|
302
|
+
} as any)
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
await remoteLink.dismiss([
|
|
306
|
+
{
|
|
307
|
+
[Modules.PRODUCT]: { product_id: prepared.product_id },
|
|
308
|
+
[FAIRE_SYNC_MODULE]: { faire_sync_account_id: config.account_id },
|
|
309
|
+
},
|
|
310
|
+
])
|
|
311
|
+
} catch {
|
|
312
|
+
// ignore dismiss errors
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await remoteLink.create([
|
|
316
|
+
{
|
|
317
|
+
[Modules.PRODUCT]: { product_id: prepared.product_id },
|
|
318
|
+
[FAIRE_SYNC_MODULE]: { faire_sync_account_id: config.account_id },
|
|
319
|
+
data: {
|
|
320
|
+
faire_product_token: result.product.product_token,
|
|
321
|
+
faire_url: productDisplayUrl(result.product),
|
|
322
|
+
sync_status: "synced",
|
|
323
|
+
last_synced_at: new Date(),
|
|
324
|
+
sync_error: result.warnings.length ? result.warnings.join(" | ") : null,
|
|
325
|
+
metadata: {
|
|
326
|
+
published: result.published,
|
|
327
|
+
state: result.product.state,
|
|
328
|
+
product_status: prepared.product_status,
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
])
|
|
333
|
+
|
|
334
|
+
return new StepResponse()
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
// ── Workflow ─────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
export const syncProductToFaireWorkflow = createWorkflow(
|
|
341
|
+
SYNC_PRODUCT_TO_FAIRE,
|
|
342
|
+
(input: WorkflowData<SyncProductToFaireInput>) => {
|
|
343
|
+
const config = resolveFaireConfigStep()
|
|
344
|
+
const prepared = prepareProductStep(
|
|
345
|
+
transform({ input, config }, (data) => ({
|
|
346
|
+
product_id: data.input.product_id,
|
|
347
|
+
settings: data.config.settings,
|
|
348
|
+
brand_id: data.config.brand_id,
|
|
349
|
+
}))
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
const result = syncProductStep(
|
|
353
|
+
transform({ config, prepared }, (data) => ({
|
|
354
|
+
config: data.config,
|
|
355
|
+
prepared: data.prepared,
|
|
356
|
+
}))
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
persistSyncResultStep(
|
|
360
|
+
transform({ config, prepared, result }, (data) => ({
|
|
361
|
+
config: data.config,
|
|
362
|
+
prepared: data.prepared,
|
|
363
|
+
result: data.result,
|
|
364
|
+
}))
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return new WorkflowResponse({
|
|
368
|
+
product_token: result.product.product_token,
|
|
369
|
+
product_url: productDisplayUrl(result.product),
|
|
370
|
+
published: result.published,
|
|
371
|
+
state: result.product.state,
|
|
372
|
+
warnings: result.warnings,
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStep,
|
|
3
|
+
createWorkflow,
|
|
4
|
+
StepResponse,
|
|
5
|
+
WorkflowResponse,
|
|
6
|
+
WorkflowData,
|
|
7
|
+
transform,
|
|
8
|
+
} from "@medusajs/framework/workflows-sdk"
|
|
9
|
+
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
10
|
+
import FaireSyncService from "../modules/faire-sync/service"
|
|
11
|
+
import { syncProductToFaireWorkflow } from "./sync-product-to-faire"
|
|
12
|
+
|
|
13
|
+
export const SYNC_PRODUCTS_TO_FAIRE = "faire-sync-products-bulk"
|
|
14
|
+
|
|
15
|
+
export type SyncProductsToFaireInput = {
|
|
16
|
+
product_ids: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Faire enforces a modest per-second request budget and a daily call ceiling.
|
|
20
|
+
// A single product sync fans out to several calls (create + inventory push),
|
|
21
|
+
// so we pace *products* conservatively and let the client's 429 retry-after
|
|
22
|
+
// backoff absorb bursts over the per-second limit.
|
|
23
|
+
const BASE_DELAY_MS = 600
|
|
24
|
+
const MAX_DELAY_MS = 10_000
|
|
25
|
+
const PROGRESS_EVERY = 5
|
|
26
|
+
|
|
27
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
28
|
+
|
|
29
|
+
// ── Step 1 (inline): open the batch so the HTTP caller gets an id to poll ──
|
|
30
|
+
|
|
31
|
+
const openBatchStep = createStep(
|
|
32
|
+
"faire-open-batch-step",
|
|
33
|
+
async (
|
|
34
|
+
input: { product_ids: string[] },
|
|
35
|
+
{ container, context }
|
|
36
|
+
): Promise<StepResponse<{ batch_id: string }>> => {
|
|
37
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
38
|
+
|
|
39
|
+
const account = await service.getActiveAccount()
|
|
40
|
+
if (!account) {
|
|
41
|
+
throw new Error("Faire account is not connected")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const batch = await service.createFaireSyncBatches({
|
|
45
|
+
transaction_id: context.transactionId,
|
|
46
|
+
status: "processing",
|
|
47
|
+
total_products: input.product_ids.length,
|
|
48
|
+
synced_count: 0,
|
|
49
|
+
failed_count: 0,
|
|
50
|
+
error_log: {},
|
|
51
|
+
started_at: new Date(),
|
|
52
|
+
} as any)
|
|
53
|
+
|
|
54
|
+
return new StepResponse({ batch_id: (batch as any).id })
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// ── Step 2 (async, background): process every product with backoff ─────────
|
|
59
|
+
|
|
60
|
+
const processBatchStep = createStep(
|
|
61
|
+
"faire-process-batch-step",
|
|
62
|
+
async (
|
|
63
|
+
input: { batch_id: string; product_ids: string[] },
|
|
64
|
+
{ container }
|
|
65
|
+
): Promise<StepResponse<{ synced: number; failed: number }>> => {
|
|
66
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
67
|
+
|
|
68
|
+
let synced = 0
|
|
69
|
+
let failed = 0
|
|
70
|
+
let delay = BASE_DELAY_MS
|
|
71
|
+
const errors: Record<string, string> = {}
|
|
72
|
+
|
|
73
|
+
const persistProgress = async () => {
|
|
74
|
+
await service
|
|
75
|
+
.updateFaireSyncBatches({
|
|
76
|
+
id: input.batch_id,
|
|
77
|
+
synced_count: synced,
|
|
78
|
+
failed_count: failed,
|
|
79
|
+
error_log: errors,
|
|
80
|
+
} as any)
|
|
81
|
+
.catch(() => {})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < input.product_ids.length; i++) {
|
|
85
|
+
const product_id = input.product_ids[i]
|
|
86
|
+
try {
|
|
87
|
+
await syncProductToFaireWorkflow(container).run({ input: { product_id } })
|
|
88
|
+
synced++
|
|
89
|
+
delay = Math.max(BASE_DELAY_MS, Math.floor(delay / 2))
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
failed++
|
|
92
|
+
errors[product_id] = err?.message || "Unknown error"
|
|
93
|
+
if (/429|rate limit/i.test(errors[product_id])) {
|
|
94
|
+
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if ((i + 1) % PROGRESS_EVERY === 0) {
|
|
99
|
+
await persistProgress()
|
|
100
|
+
}
|
|
101
|
+
if (i < input.product_ids.length - 1) {
|
|
102
|
+
await sleep(delay)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await service.updateFaireSyncBatches({
|
|
107
|
+
id: input.batch_id,
|
|
108
|
+
status: failed === input.product_ids.length ? "failed" : "completed",
|
|
109
|
+
synced_count: synced,
|
|
110
|
+
failed_count: failed,
|
|
111
|
+
error_log: errors,
|
|
112
|
+
completed_at: new Date(),
|
|
113
|
+
} as any)
|
|
114
|
+
|
|
115
|
+
return new StepResponse({ synced, failed })
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
export const syncProductsToFaireWorkflow = createWorkflow(
|
|
120
|
+
SYNC_PRODUCTS_TO_FAIRE,
|
|
121
|
+
(input: WorkflowData<SyncProductsToFaireInput>) => {
|
|
122
|
+
const opened = openBatchStep(input)
|
|
123
|
+
|
|
124
|
+
processBatchStep(
|
|
125
|
+
transform({ input, opened }, (data) => ({
|
|
126
|
+
batch_id: data.opened.batch_id,
|
|
127
|
+
product_ids: data.input.product_ids,
|
|
128
|
+
}))
|
|
129
|
+
).config({ async: true, backgroundExecution: true })
|
|
130
|
+
|
|
131
|
+
return new WorkflowResponse({ batch_id: opened.batch_id })
|
|
132
|
+
}
|
|
133
|
+
)
|