@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.
Files changed (104) hide show
  1. package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +74 -0
  2. package/.medusa/server/src/__tests__/webhook.spec.js +77 -0
  3. package/.medusa/server/src/admin/index.js +960 -0
  4. package/.medusa/server/src/admin/index.mjs +959 -0
  5. package/.medusa/server/src/api/admin/faire/auth/authorize/route.js +12 -0
  6. package/.medusa/server/src/api/admin/faire/auth/callback/route.js +25 -0
  7. package/.medusa/server/src/api/admin/faire/auth/disconnect/route.js +12 -0
  8. package/.medusa/server/src/api/admin/faire/brand/route.js +14 -0
  9. package/.medusa/server/src/api/admin/faire/ingest/orders/[batchId]/route.js +27 -0
  10. package/.medusa/server/src/api/admin/faire/ingest/orders/route.js +20 -0
  11. package/.medusa/server/src/api/admin/faire/products/route.js +16 -0
  12. package/.medusa/server/src/api/admin/faire/settings/route.js +19 -0
  13. package/.medusa/server/src/api/admin/faire/status/product/[id]/route.js +30 -0
  14. package/.medusa/server/src/api/admin/faire/status/route.js +46 -0
  15. package/.medusa/server/src/api/admin/faire/sync/bulk/[batchId]/route.js +29 -0
  16. package/.medusa/server/src/api/admin/faire/sync/bulk/route.js +20 -0
  17. package/.medusa/server/src/api/admin/faire/sync/product/[id]/route.js +14 -0
  18. package/.medusa/server/src/api/admin/faire/syncs/[id]/route.js +32 -0
  19. package/.medusa/server/src/api/admin/faire/syncs/route.js +18 -0
  20. package/.medusa/server/src/api/webhooks/faire/route.js +125 -0
  21. package/.medusa/server/src/jobs/pull-faire-orders.js +32 -0
  22. package/.medusa/server/src/jobs/refresh-faire-token.js +25 -0
  23. package/.medusa/server/src/lib/faire-client.js +321 -0
  24. package/.medusa/server/src/lib/types.js +10 -0
  25. package/.medusa/server/src/lib/webhook.js +58 -0
  26. package/.medusa/server/src/links/product-faire-sync-link.js +57 -0
  27. package/.medusa/server/src/modules/faire-sync/index.js +15 -0
  28. package/.medusa/server/src/modules/faire-sync/migrations/Migration20260707211346.js +34 -0
  29. package/.medusa/server/src/modules/faire-sync/models/faire-order.js +21 -0
  30. package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +17 -0
  31. package/.medusa/server/src/modules/faire-sync/models/faire-sync-batch.js +18 -0
  32. package/.medusa/server/src/modules/faire-sync/models/faire-sync-record.js +22 -0
  33. package/.medusa/server/src/modules/faire-sync/models/faire-sync-settings.js +26 -0
  34. package/.medusa/server/src/modules/faire-sync/models/faire-webhook-event.js +21 -0
  35. package/.medusa/server/src/modules/faire-sync/service.js +222 -0
  36. package/.medusa/server/src/subscribers/faire-inventory-sync.js +87 -0
  37. package/.medusa/server/src/subscribers/faire-listing-events.js +46 -0
  38. package/.medusa/server/src/subscribers/faire-order-ingest.js +54 -0
  39. package/.medusa/server/src/subscribers/faire-order-lifecycle.js +71 -0
  40. package/.medusa/server/src/subscribers/faire-product-status-sync.js +71 -0
  41. package/.medusa/server/src/workflows/index.js +21 -0
  42. package/.medusa/server/src/workflows/ingest-faire-order-support.js +76 -0
  43. package/.medusa/server/src/workflows/ingest-faire-order.js +148 -0
  44. package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +124 -0
  45. package/.medusa/server/src/workflows/refresh-faire-token.js +20 -0
  46. package/.medusa/server/src/workflows/sync-product-to-faire.js +262 -0
  47. package/.medusa/server/src/workflows/sync-products-to-faire.js +90 -0
  48. package/LICENSE +21 -0
  49. package/README.md +180 -0
  50. package/package.json +84 -0
  51. package/scripts/postbuild.js +36 -0
  52. package/src/__tests__/ingest-faire-order-support.spec.ts +77 -0
  53. package/src/__tests__/webhook.spec.ts +81 -0
  54. package/src/admin/lib/api.ts +88 -0
  55. package/src/admin/routes/settings/faire/[id]/page.tsx +184 -0
  56. package/src/admin/routes/settings/faire/hooks/use-faire-sync-columns.tsx +80 -0
  57. package/src/admin/routes/settings/faire/page.tsx +445 -0
  58. package/src/admin/routes/settings/faire/settings/page.tsx +286 -0
  59. package/src/admin/routes/settings/oauth/faire/callback/page.tsx +67 -0
  60. package/src/admin/widgets/faire-product-widget.tsx +180 -0
  61. package/src/api/admin/faire/auth/authorize/route.ts +10 -0
  62. package/src/api/admin/faire/auth/callback/route.ts +29 -0
  63. package/src/api/admin/faire/auth/disconnect/route.ts +10 -0
  64. package/src/api/admin/faire/brand/route.ts +12 -0
  65. package/src/api/admin/faire/ingest/orders/[batchId]/route.ts +31 -0
  66. package/src/api/admin/faire/ingest/orders/route.ts +29 -0
  67. package/src/api/admin/faire/products/route.ts +14 -0
  68. package/src/api/admin/faire/settings/route.ts +17 -0
  69. package/src/api/admin/faire/status/product/[id]/route.ts +30 -0
  70. package/src/api/admin/faire/status/route.ts +47 -0
  71. package/src/api/admin/faire/sync/bulk/[batchId]/route.ts +33 -0
  72. package/src/api/admin/faire/sync/bulk/route.ts +27 -0
  73. package/src/api/admin/faire/sync/product/[id]/route.ts +13 -0
  74. package/src/api/admin/faire/syncs/[id]/route.ts +32 -0
  75. package/src/api/admin/faire/syncs/route.ts +17 -0
  76. package/src/api/webhooks/faire/route.ts +133 -0
  77. package/src/jobs/pull-faire-orders.ts +30 -0
  78. package/src/jobs/refresh-faire-token.ts +23 -0
  79. package/src/lib/faire-client.ts +436 -0
  80. package/src/lib/types.ts +117 -0
  81. package/src/lib/webhook.ts +85 -0
  82. package/src/links/product-faire-sync-link.ts +56 -0
  83. package/src/modules/faire-sync/index.ts +11 -0
  84. package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +1486 -0
  85. package/src/modules/faire-sync/migrations/Migration20260707211346.ts +43 -0
  86. package/src/modules/faire-sync/models/faire-order.ts +20 -0
  87. package/src/modules/faire-sync/models/faire-sync-account.ts +16 -0
  88. package/src/modules/faire-sync/models/faire-sync-batch.ts +17 -0
  89. package/src/modules/faire-sync/models/faire-sync-record.ts +21 -0
  90. package/src/modules/faire-sync/models/faire-sync-settings.ts +25 -0
  91. package/src/modules/faire-sync/models/faire-webhook-event.ts +20 -0
  92. package/src/modules/faire-sync/service.ts +252 -0
  93. package/src/subscribers/faire-inventory-sync.ts +97 -0
  94. package/src/subscribers/faire-listing-events.ts +48 -0
  95. package/src/subscribers/faire-order-ingest.ts +61 -0
  96. package/src/subscribers/faire-order-lifecycle.ts +81 -0
  97. package/src/subscribers/faire-product-status-sync.ts +76 -0
  98. package/src/workflows/index.ts +4 -0
  99. package/src/workflows/ingest-faire-order-support.ts +96 -0
  100. package/src/workflows/ingest-faire-order.ts +212 -0
  101. package/src/workflows/ingest-faire-orders-bulk.ts +168 -0
  102. package/src/workflows/refresh-faire-token.ts +28 -0
  103. package/src/workflows/sync-product-to-faire.ts +375 -0
  104. 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
+ )