@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,43 @@
|
|
|
1
|
+
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
|
|
2
|
+
|
|
3
|
+
export class Migration20260707211346 extends Migration {
|
|
4
|
+
|
|
5
|
+
override async up(): Promise<void> {
|
|
6
|
+
this.addSql(`alter table if exists "faire_webhook_event" drop constraint if exists "faire_webhook_event_webhook_id_unique";`);
|
|
7
|
+
this.addSql(`alter table if exists "faire_order" drop constraint if exists "faire_order_order_token_unique";`);
|
|
8
|
+
this.addSql(`create table if not exists "faire_order" ("id" text not null, "order_token" text not null, "order_id" text null, "status" text not null default 'created', "currency" text null, "total" text null, "buyer_name" text null, "raw" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_order_pkey" primary key ("id"));`);
|
|
9
|
+
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_faire_order_order_token_unique" ON "faire_order" ("order_token") WHERE deleted_at IS NULL;`);
|
|
10
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_order_deleted_at" ON "faire_order" ("deleted_at") WHERE deleted_at IS NULL;`);
|
|
11
|
+
|
|
12
|
+
this.addSql(`create table if not exists "faire_sync_account" ("id" text not null, "brand_id" text not null, "brand_name" text not null, "currency" text null, "country" text null, "access_token" text not null, "refresh_token" text null, "token_expires_at" timestamptz null, "brand_info" jsonb null, "is_active" boolean not null default true, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_sync_account_pkey" primary key ("id"));`);
|
|
13
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_sync_account_deleted_at" ON "faire_sync_account" ("deleted_at") WHERE deleted_at IS NULL;`);
|
|
14
|
+
|
|
15
|
+
this.addSql(`create table if not exists "faire_sync_batch" ("id" text not null, "transaction_id" text null, "status" text check ("status" in ('pending', 'processing', 'completed', 'failed')) not null default 'pending', "total_products" integer not null default 0, "synced_count" integer not null default 0, "failed_count" integer not null default 0, "error_log" jsonb null, "started_at" timestamptz null, "completed_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_sync_batch_pkey" primary key ("id"));`);
|
|
16
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_sync_batch_deleted_at" ON "faire_sync_batch" ("deleted_at") WHERE deleted_at IS NULL;`);
|
|
17
|
+
|
|
18
|
+
this.addSql(`create table if not exists "faire_sync_record" ("id" text not null, "product_id" text not null, "account_id" text not null, "product_token" text null, "product_url" text null, "product_state" text null, "action" text check ("action" in ('create', 'update', 'delete')) not null default 'create', "status" text check ("status" in ('pending', 'syncing', 'success', 'failed', 'draft')) not null default 'pending', "published" boolean not null default false, "error_message" text null, "warnings" jsonb null, "metadata" jsonb null, "synced_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_sync_record_pkey" primary key ("id"));`);
|
|
19
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_sync_record_deleted_at" ON "faire_sync_record" ("deleted_at") WHERE deleted_at IS NULL;`);
|
|
20
|
+
|
|
21
|
+
this.addSql(`create table if not exists "faire_sync_settings" ("id" text not null, "account_id" text null, "default_brand_id" text null, "default_wholesale_markup_percent" integer null, "default_min_order_quantity" integer not null default 1, "default_lead_time_days" integer null, "default_shipping_policy_id" text null, "default_category" text null, "auto_publish" boolean not null default false, "follow_product_status" boolean not null default true, "pending_oauth" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_sync_settings_pkey" primary key ("id"));`);
|
|
22
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_sync_settings_deleted_at" ON "faire_sync_settings" ("deleted_at") WHERE deleted_at IS NULL;`);
|
|
23
|
+
|
|
24
|
+
this.addSql(`create table if not exists "faire_webhook_event" ("id" text not null, "webhook_id" text not null, "event_type" text not null, "brand_id" text null, "resource_url" text null, "payload" jsonb null, "resource" jsonb null, "processed" boolean not null default false, "error" text null, "received_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "faire_webhook_event_pkey" primary key ("id"));`);
|
|
25
|
+
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_faire_webhook_event_webhook_id_unique" ON "faire_webhook_event" ("webhook_id") WHERE deleted_at IS NULL;`);
|
|
26
|
+
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_faire_webhook_event_deleted_at" ON "faire_webhook_event" ("deleted_at") WHERE deleted_at IS NULL;`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override async down(): Promise<void> {
|
|
30
|
+
this.addSql(`drop table if exists "faire_order" cascade;`);
|
|
31
|
+
|
|
32
|
+
this.addSql(`drop table if exists "faire_sync_account" cascade;`);
|
|
33
|
+
|
|
34
|
+
this.addSql(`drop table if exists "faire_sync_batch" cascade;`);
|
|
35
|
+
|
|
36
|
+
this.addSql(`drop table if exists "faire_sync_record" cascade;`);
|
|
37
|
+
|
|
38
|
+
this.addSql(`drop table if exists "faire_sync_settings" cascade;`);
|
|
39
|
+
|
|
40
|
+
this.addSql(`drop table if exists "faire_webhook_event" cascade;`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { model } from "@medusajs/framework/utils"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maps a Faire order to the Medusa order created from it. `order_token` is
|
|
5
|
+
* unique — the idempotency key so a Faire webhook retry (or a later
|
|
6
|
+
* order.canceled/shipped event for the same order) never re-creates the
|
|
7
|
+
* Medusa order.
|
|
8
|
+
*/
|
|
9
|
+
const FaireOrder = model.define("faire_order", {
|
|
10
|
+
id: model.id().primaryKey(),
|
|
11
|
+
order_token: model.text().unique(),
|
|
12
|
+
order_id: model.text().nullable(), // Medusa order id
|
|
13
|
+
status: model.text().default("created"),
|
|
14
|
+
currency: model.text().nullable(),
|
|
15
|
+
total: model.text().nullable(), // decimal string (cents as integer string)
|
|
16
|
+
buyer_name: model.text().nullable(),
|
|
17
|
+
raw: model.json().nullable(),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export default FaireOrder
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { model } from "@medusajs/framework/utils"
|
|
2
|
+
|
|
3
|
+
const FaireSyncAccount = model.define("faire_sync_account", {
|
|
4
|
+
id: model.id().primaryKey(),
|
|
5
|
+
brand_id: model.text(),
|
|
6
|
+
brand_name: model.text(),
|
|
7
|
+
currency: model.text().nullable(),
|
|
8
|
+
country: model.text().nullable(),
|
|
9
|
+
access_token: model.text(),
|
|
10
|
+
refresh_token: model.text().nullable(),
|
|
11
|
+
token_expires_at: model.dateTime().nullable(),
|
|
12
|
+
brand_info: model.json().nullable(),
|
|
13
|
+
is_active: model.boolean().default(true),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export default FaireSyncAccount
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { model } from "@medusajs/framework/utils"
|
|
2
|
+
|
|
3
|
+
const FaireSyncBatch = model.define("faire_sync_batch", {
|
|
4
|
+
id: model.id().primaryKey(),
|
|
5
|
+
transaction_id: model.text().nullable(),
|
|
6
|
+
status: model
|
|
7
|
+
.enum(["pending", "processing", "completed", "failed"])
|
|
8
|
+
.default("pending"),
|
|
9
|
+
total_products: model.number().default(0),
|
|
10
|
+
synced_count: model.number().default(0),
|
|
11
|
+
failed_count: model.number().default(0),
|
|
12
|
+
error_log: model.json().nullable(),
|
|
13
|
+
started_at: model.dateTime().nullable(),
|
|
14
|
+
completed_at: model.dateTime().nullable(),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export default FaireSyncBatch
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { model } from "@medusajs/framework/utils"
|
|
2
|
+
|
|
3
|
+
const FaireSyncRecord = model.define("faire_sync_record", {
|
|
4
|
+
id: model.id().primaryKey(),
|
|
5
|
+
product_id: model.text(),
|
|
6
|
+
account_id: model.text(),
|
|
7
|
+
product_token: model.text().nullable(),
|
|
8
|
+
product_url: model.text().nullable(),
|
|
9
|
+
product_state: model.text().nullable(),
|
|
10
|
+
action: model.enum(["create", "update", "delete"]).default("create"),
|
|
11
|
+
status: model
|
|
12
|
+
.enum(["pending", "syncing", "success", "failed", "draft"])
|
|
13
|
+
.default("pending"),
|
|
14
|
+
published: model.boolean().default(false),
|
|
15
|
+
error_message: model.text().nullable(),
|
|
16
|
+
warnings: model.json().nullable(),
|
|
17
|
+
metadata: model.json().nullable(),
|
|
18
|
+
synced_at: model.dateTime().nullable(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export default FaireSyncRecord
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { model } from "@medusajs/framework/utils"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Singleton settings row. Holds publish-readiness defaults plus the in-flight
|
|
5
|
+
* OAuth `state` (keyed by it) so the callback can verify the round-trip.
|
|
6
|
+
*
|
|
7
|
+
* Faire-flavored readiness: connection, brand id, wholesale pricing strategy,
|
|
8
|
+
* and shipping policy. Missing fields sync products as drafts rather than
|
|
9
|
+
* publishing them.
|
|
10
|
+
*/
|
|
11
|
+
const FaireSyncSettings = model.define("faire_sync_settings", {
|
|
12
|
+
id: model.id().primaryKey(),
|
|
13
|
+
account_id: model.text().nullable(),
|
|
14
|
+
default_brand_id: model.text().nullable(),
|
|
15
|
+
default_wholesale_markup_percent: model.number().nullable(),
|
|
16
|
+
default_min_order_quantity: model.number().default(1),
|
|
17
|
+
default_lead_time_days: model.number().nullable(),
|
|
18
|
+
default_shipping_policy_id: model.text().nullable(),
|
|
19
|
+
default_category: model.text().nullable(),
|
|
20
|
+
auto_publish: model.boolean().default(false),
|
|
21
|
+
follow_product_status: model.boolean().default(true),
|
|
22
|
+
pending_oauth: model.json().nullable(),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export default FaireSyncSettings
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { model } from "@medusajs/framework/utils"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Durable record of an inbound Faire webhook delivery. `webhook_id` is the
|
|
5
|
+
* delivery id Faire sends (unique) so retries are idempotent.
|
|
6
|
+
*/
|
|
7
|
+
const FaireWebhookEvent = model.define("faire_webhook_event", {
|
|
8
|
+
id: model.id().primaryKey(),
|
|
9
|
+
webhook_id: model.text().unique(),
|
|
10
|
+
event_type: model.text(),
|
|
11
|
+
brand_id: model.text().nullable(),
|
|
12
|
+
resource_url: model.text().nullable(),
|
|
13
|
+
payload: model.json().nullable(),
|
|
14
|
+
resource: model.json().nullable(),
|
|
15
|
+
processed: model.boolean().default(false),
|
|
16
|
+
error: model.text().nullable(),
|
|
17
|
+
received_at: model.dateTime().nullable(),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export default FaireWebhookEvent
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { MedusaError, MedusaService } from "@medusajs/framework/utils"
|
|
2
|
+
import FaireSyncAccount from "./models/faire-sync-account"
|
|
3
|
+
import FaireSyncRecord from "./models/faire-sync-record"
|
|
4
|
+
import FaireSyncSettings from "./models/faire-sync-settings"
|
|
5
|
+
import FaireSyncBatch from "./models/faire-sync-batch"
|
|
6
|
+
import FaireWebhookEvent from "./models/faire-webhook-event"
|
|
7
|
+
import FaireOrder from "./models/faire-order"
|
|
8
|
+
import { FaireClient } from "../../lib/faire-client"
|
|
9
|
+
import { FairePluginOptions, BrandInfo, TokenData } from "../../lib/types"
|
|
10
|
+
|
|
11
|
+
type ModuleOptions = FairePluginOptions & Record<string, any>
|
|
12
|
+
|
|
13
|
+
function readOption(options: any, key: string): string | undefined {
|
|
14
|
+
try {
|
|
15
|
+
const value = options?.[key]
|
|
16
|
+
return typeof value === "string" ? value : undefined
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toMedusaError(err: any): MedusaError {
|
|
23
|
+
if (err instanceof MedusaError) return err
|
|
24
|
+
|
|
25
|
+
const status: number | undefined = err?.status
|
|
26
|
+
const body: any = err?.body
|
|
27
|
+
const message: string =
|
|
28
|
+
body?.error_description || body?.error || err?.message || "Faire request failed"
|
|
29
|
+
|
|
30
|
+
if (status === 400 && /invalid_grant|invalid code|already used/i.test(message)) {
|
|
31
|
+
return new MedusaError(
|
|
32
|
+
MedusaError.Types.INVALID_DATA,
|
|
33
|
+
"Faire authorization expired or was already used. Please reconnect."
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
if (/brand/i.test(message) && /(no|not|own)/i.test(message)) {
|
|
37
|
+
return new MedusaError(MedusaError.Types.NOT_FOUND, message)
|
|
38
|
+
}
|
|
39
|
+
if (status === 401 || status === 403) {
|
|
40
|
+
return new MedusaError(MedusaError.Types.NOT_ALLOWED, message)
|
|
41
|
+
}
|
|
42
|
+
if (status === 404) {
|
|
43
|
+
return new MedusaError(MedusaError.Types.NOT_FOUND, message)
|
|
44
|
+
}
|
|
45
|
+
if (status === 400 || status === 422) {
|
|
46
|
+
return new MedusaError(MedusaError.Types.INVALID_DATA, message)
|
|
47
|
+
}
|
|
48
|
+
return new MedusaError(MedusaError.Types.UNEXPECTED_STATE, message)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveFaireOptions(options?: ModuleOptions): ModuleOptions {
|
|
52
|
+
return {
|
|
53
|
+
clientId: process.env.FAIRE_CLIENT_ID ?? readOption(options, "clientId") ?? "",
|
|
54
|
+
clientSecret:
|
|
55
|
+
process.env.FAIRE_CLIENT_SECRET ?? readOption(options, "clientSecret") ?? "",
|
|
56
|
+
redirectUri:
|
|
57
|
+
process.env.FAIRE_REDIRECT_URI ??
|
|
58
|
+
readOption(options, "redirectUri") ??
|
|
59
|
+
"http://localhost:9000/app/settings/oauth/faire/callback",
|
|
60
|
+
apiBase: process.env.FAIRE_API_BASE ?? readOption(options, "apiBase"),
|
|
61
|
+
authUrl: process.env.FAIRE_AUTH_URL ?? readOption(options, "authUrl"),
|
|
62
|
+
tokenUrl: process.env.FAIRE_TOKEN_URL ?? readOption(options, "tokenUrl"),
|
|
63
|
+
scope: process.env.FAIRE_SCOPE ?? readOption(options, "scope") ?? "",
|
|
64
|
+
webhookSecret:
|
|
65
|
+
process.env.FAIRE_WEBHOOK_SECRET ?? readOption(options, "webhookSecret") ?? "",
|
|
66
|
+
} as ModuleOptions
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class FaireSyncService extends MedusaService({
|
|
70
|
+
FaireSyncAccount,
|
|
71
|
+
FaireSyncRecord,
|
|
72
|
+
FaireSyncSettings,
|
|
73
|
+
FaireSyncBatch,
|
|
74
|
+
FaireWebhookEvent,
|
|
75
|
+
FaireOrder,
|
|
76
|
+
}) {
|
|
77
|
+
protected options_: ModuleOptions
|
|
78
|
+
protected client_: FaireClient | null = null
|
|
79
|
+
|
|
80
|
+
constructor(container: any, options?: ModuleOptions) {
|
|
81
|
+
super(...arguments)
|
|
82
|
+
this.options_ = resolveFaireOptions(options)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getClient(): FaireClient {
|
|
86
|
+
if (!this.client_) {
|
|
87
|
+
this.client_ = new FaireClient(this.options_)
|
|
88
|
+
}
|
|
89
|
+
return this.client_
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getOptions(): ModuleOptions {
|
|
93
|
+
return this.options_
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getActiveAccount() {
|
|
97
|
+
const [account] = await this.listFaireSyncAccounts({
|
|
98
|
+
is_active: true,
|
|
99
|
+
} as any)
|
|
100
|
+
return account ?? null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async saveAccount(token: TokenData, brand: BrandInfo) {
|
|
104
|
+
const existing = await this.getActiveAccount()
|
|
105
|
+
const expiresAt =
|
|
106
|
+
token.expires_in != null
|
|
107
|
+
? new Date(token.retrieved_at + token.expires_in * 1000)
|
|
108
|
+
: null
|
|
109
|
+
const payload = {
|
|
110
|
+
brand_id: brand.brand_id,
|
|
111
|
+
brand_name: brand.brand_name,
|
|
112
|
+
currency: brand.currency ?? null,
|
|
113
|
+
country: brand.country ?? null,
|
|
114
|
+
access_token: token.access_token,
|
|
115
|
+
refresh_token: token.refresh_token ?? null,
|
|
116
|
+
token_expires_at: expiresAt,
|
|
117
|
+
brand_info: brand.raw,
|
|
118
|
+
is_active: true,
|
|
119
|
+
}
|
|
120
|
+
if (existing) {
|
|
121
|
+
return this.updateFaireSyncAccounts({
|
|
122
|
+
id: existing.id,
|
|
123
|
+
...payload,
|
|
124
|
+
} as any)
|
|
125
|
+
}
|
|
126
|
+
return this.createFaireSyncAccounts(payload as any)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async disconnect() {
|
|
130
|
+
const account = await this.getActiveAccount()
|
|
131
|
+
if (!account) return
|
|
132
|
+
await this.updateFaireSyncAccounts({
|
|
133
|
+
id: account.id,
|
|
134
|
+
is_active: false,
|
|
135
|
+
access_token: "",
|
|
136
|
+
refresh_token: null,
|
|
137
|
+
} as any)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async ensureFreshToken(account?: any) {
|
|
141
|
+
const acct = account ?? (await this.getActiveAccount())
|
|
142
|
+
if (!acct) {
|
|
143
|
+
throw new MedusaError(
|
|
144
|
+
MedusaError.Types.NOT_FOUND,
|
|
145
|
+
"Faire account is not connected"
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
if (!acct.token_expires_at) {
|
|
149
|
+
return acct
|
|
150
|
+
}
|
|
151
|
+
const now = Date.now()
|
|
152
|
+
const expiresAt = new Date(acct.token_expires_at).getTime()
|
|
153
|
+
const fiveMinutes = 5 * 60 * 1000
|
|
154
|
+
if (expiresAt - now > fiveMinutes) {
|
|
155
|
+
return acct
|
|
156
|
+
}
|
|
157
|
+
if (!acct.refresh_token) {
|
|
158
|
+
throw new MedusaError(
|
|
159
|
+
MedusaError.Types.NOT_ALLOWED,
|
|
160
|
+
"Faire access token has expired. Please reconnect your Faire account."
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const client = this.getClient()
|
|
165
|
+
const token = await client.refreshAccessToken(acct.refresh_token)
|
|
166
|
+
return this.updateFaireSyncAccounts({
|
|
167
|
+
id: acct.id,
|
|
168
|
+
access_token: token.access_token,
|
|
169
|
+
refresh_token: token.refresh_token ?? acct.refresh_token,
|
|
170
|
+
token_expires_at:
|
|
171
|
+
token.expires_in != null
|
|
172
|
+
? new Date(token.retrieved_at + token.expires_in * 1000)
|
|
173
|
+
: null,
|
|
174
|
+
} as any)
|
|
175
|
+
} catch (err) {
|
|
176
|
+
throw toMedusaError(err)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async getSettings() {
|
|
181
|
+
const [settings] = await this.listFaireSyncSettings({}, { take: 1 } as any)
|
|
182
|
+
if (settings) return settings
|
|
183
|
+
return this.createFaireSyncSettings({} as any)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async updateSettings(data: any) {
|
|
187
|
+
const settings = await this.getSettings()
|
|
188
|
+
return this.updateFaireSyncSettings({ id: settings.id, ...data } as any)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async startOAuth(): Promise<{ authorization_url: string; state: string }> {
|
|
192
|
+
try {
|
|
193
|
+
const state = crypto.randomUUID()
|
|
194
|
+
await this.updateSettings({
|
|
195
|
+
pending_oauth: {
|
|
196
|
+
state,
|
|
197
|
+
created_at: Date.now(),
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
const client = this.getClient()
|
|
201
|
+
return {
|
|
202
|
+
authorization_url: client.getAuthorizationUrl(state),
|
|
203
|
+
state,
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw toMedusaError(err)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async completeOAuth(code: string, state: string) {
|
|
211
|
+
const settings = await this.getSettings()
|
|
212
|
+
const pending: any = settings.pending_oauth
|
|
213
|
+
if (!pending || pending.state !== state) {
|
|
214
|
+
throw new MedusaError(
|
|
215
|
+
MedusaError.Types.INVALID_DATA,
|
|
216
|
+
"Invalid or expired OAuth state. Please start the Faire connection again."
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const client = this.getClient()
|
|
221
|
+
const token = await client.exchangeCodeForToken(code)
|
|
222
|
+
const brand = await client.getBrand(token.access_token)
|
|
223
|
+
const account = await this.saveAccount(token, brand)
|
|
224
|
+
await this.updateSettings({
|
|
225
|
+
account_id: account.id,
|
|
226
|
+
default_brand_id: brand.brand_id,
|
|
227
|
+
pending_oauth: null,
|
|
228
|
+
})
|
|
229
|
+
return { account, brand }
|
|
230
|
+
} catch (err) {
|
|
231
|
+
throw toMedusaError(err)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async createSyncRecord(data: any) {
|
|
236
|
+
return this.createFaireSyncRecords(data as any)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async updateSyncRecord(id: string, data: any) {
|
|
240
|
+
return this.updateFaireSyncRecords({ id, ...data } as any)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async listSyncRecords(filters: any = {}, take = 20, skip = 0) {
|
|
244
|
+
return this.listAndCountFaireSyncRecords(filters, {
|
|
245
|
+
take,
|
|
246
|
+
skip,
|
|
247
|
+
order: { created_at: "DESC" },
|
|
248
|
+
} as any)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default FaireSyncService
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
|
2
|
+
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
|
3
|
+
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
4
|
+
import FaireSyncService from "../modules/faire-sync/service"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* React to inventory updates Faire pushes (e.g. a retailer placed an order and
|
|
8
|
+
* Faire decremented stock). We mirror the remote count back onto the linked
|
|
9
|
+
* Medusa variant's inventory so the two systems stay consistent. Best-effort —
|
|
10
|
+
* never throws.
|
|
11
|
+
*/
|
|
12
|
+
export default async function faireInventorySyncHandler({
|
|
13
|
+
event: { data },
|
|
14
|
+
container,
|
|
15
|
+
}: SubscriberArgs<{ resource?: any; resource_url?: string }>) {
|
|
16
|
+
const logger: any = container.resolve(ContainerRegistrationKeys.LOGGER)
|
|
17
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
18
|
+
|
|
19
|
+
let payload: any = data?.resource
|
|
20
|
+
if (!payload && data?.resource_url) {
|
|
21
|
+
try {
|
|
22
|
+
const account = await service.ensureFreshToken()
|
|
23
|
+
payload = await service
|
|
24
|
+
.getClient()
|
|
25
|
+
.fetchResource((account as any).access_token, data.resource_url)
|
|
26
|
+
} catch {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!payload) return
|
|
31
|
+
|
|
32
|
+
const levels: any[] = Array.isArray(payload?.inventory)
|
|
33
|
+
? payload.inventory
|
|
34
|
+
: Array.isArray(payload)
|
|
35
|
+
? payload
|
|
36
|
+
: payload?.items
|
|
37
|
+
? payload.items
|
|
38
|
+
: []
|
|
39
|
+
if (!levels.length) return
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const query: any = container.resolve(ContainerRegistrationKeys.QUERY)
|
|
43
|
+
const inventory: any = container.resolve(Modules.INVENTORY)
|
|
44
|
+
|
|
45
|
+
for (const lvl of levels) {
|
|
46
|
+
const sku = String(lvl.sku ?? "")
|
|
47
|
+
const count = Number(lvl.current_count ?? lvl.inventory_count)
|
|
48
|
+
if (!sku || !Number.isFinite(count)) continue
|
|
49
|
+
|
|
50
|
+
// Find the linked Medusa product via sync record matching this SKU's
|
|
51
|
+
// product token, then resolve its inventory item and set the level.
|
|
52
|
+
const productToken = lvl.product_token ? String(lvl.product_token) : null
|
|
53
|
+
if (!productToken) continue
|
|
54
|
+
const [records] = await service.listSyncRecords({ product_token: productToken }, 1, 0)
|
|
55
|
+
const productId = (records as any[])?.[0]?.product_id
|
|
56
|
+
if (!productId) continue
|
|
57
|
+
|
|
58
|
+
const { data: products } = await query.graph({
|
|
59
|
+
entity: "product",
|
|
60
|
+
fields: [
|
|
61
|
+
"id",
|
|
62
|
+
"variants.sku",
|
|
63
|
+
"variants.inventory_items.inventory_item_id",
|
|
64
|
+
"variants.inventory_items.inventory.location_levels.location_id",
|
|
65
|
+
"variants.inventory_items.inventory.location_levels.stocked_quantity",
|
|
66
|
+
],
|
|
67
|
+
filters: { id: productId },
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const variant = (products?.[0]?.variants || []).find((v: any) => v.sku === sku)
|
|
71
|
+
const invItem = (variant?.inventory_items || [])[0]
|
|
72
|
+
const levels2 = invItem?.inventory?.location_levels || []
|
|
73
|
+
const level = [...levels2].sort(
|
|
74
|
+
(a: any, b: any) => Number(b.stocked_quantity) - Number(a.stocked_quantity)
|
|
75
|
+
)[0]
|
|
76
|
+
if (!invItem?.inventory_item_id || !level?.location_id) continue
|
|
77
|
+
|
|
78
|
+
const delta = count - Number(level.stocked_quantity || 0)
|
|
79
|
+
if (delta !== 0) {
|
|
80
|
+
await inventory.adjustInventory(
|
|
81
|
+
invItem.inventory_item_id,
|
|
82
|
+
level.location_id,
|
|
83
|
+
delta
|
|
84
|
+
)
|
|
85
|
+
logger?.info?.(
|
|
86
|
+
`[faire-inventory] ${sku} adjusted by ${delta} → ${count}`
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
logger?.warn?.(`[faire-inventory] sync failed: ${err?.message}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const config: SubscriberConfig = {
|
|
96
|
+
event: "faire.inventory_updated",
|
|
97
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
|
2
|
+
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Surface notable Faire product/listing changes to the admin feed. Faire emits
|
|
6
|
+
* outbound webhooks for product updates; the webhook receiver re-emits internal
|
|
7
|
+
* `faire.product.*` / `faire.listing.*` events that this subscriber turns into
|
|
8
|
+
* admin feed notifications. Extend here to auto-restock or re-list.
|
|
9
|
+
*/
|
|
10
|
+
const LABELS: Record<string, string> = {
|
|
11
|
+
"faire.product_updated": "was updated on Faire",
|
|
12
|
+
"faire.product_sold_out": "sold out on Faire",
|
|
13
|
+
"faire.product_deactivated": "was deactivated on Faire",
|
|
14
|
+
"faire.listing.deleted": "was removed on Faire",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function faireListingEventsHandler({
|
|
18
|
+
event,
|
|
19
|
+
container,
|
|
20
|
+
}: SubscriberArgs<{ product_token?: string; resource?: any }>) {
|
|
21
|
+
const logger: any = container.resolve(ContainerRegistrationKeys.LOGGER)
|
|
22
|
+
const label = LABELS[event.name] || "changed on Faire"
|
|
23
|
+
const token = event.data?.product_token
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const notification: any = container.resolve(Modules.NOTIFICATION)
|
|
27
|
+
await notification.createNotifications({
|
|
28
|
+
to: "",
|
|
29
|
+
channel: "feed",
|
|
30
|
+
template: "admin-ui",
|
|
31
|
+
data: {
|
|
32
|
+
title: "Faire product update",
|
|
33
|
+
description: `Faire product ${token ?? ""} ${label}.`,
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
logger?.warn?.(`[faire-listing] notify failed for ${event.name}: ${err?.message}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const config: SubscriberConfig = {
|
|
42
|
+
event: [
|
|
43
|
+
"faire.product_updated",
|
|
44
|
+
"faire.product_sold_out",
|
|
45
|
+
"faire.product_deactivated",
|
|
46
|
+
"faire.listing.deleted",
|
|
47
|
+
],
|
|
48
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
|
|
2
|
+
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
|
3
|
+
import { FAIRE_SYNC_MODULE } from "../modules/faire-sync"
|
|
4
|
+
import FaireSyncService from "../modules/faire-sync/service"
|
|
5
|
+
import { ingestFaireOrderWorkflow } from "../workflows/ingest-faire-order"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a Medusa order from a placed Faire order.
|
|
9
|
+
*
|
|
10
|
+
* Fired by the `faire.order_placed` (or `faire.order_created`) event the webhook
|
|
11
|
+
* receiver emits, kept off the webhook request path so the 200 stays fast. The
|
|
12
|
+
* Faire order is carried on the event; if it wasn't fetched at webhook time we
|
|
13
|
+
* refetch from resource_url.
|
|
14
|
+
*
|
|
15
|
+
* Kill switch: set FAIRE_INGEST_ORDERS=false to disable order creation while
|
|
16
|
+
* still recording webhook deliveries.
|
|
17
|
+
*/
|
|
18
|
+
export default async function faireOrderIngestHandler({
|
|
19
|
+
event: { data },
|
|
20
|
+
container,
|
|
21
|
+
}: SubscriberArgs<{
|
|
22
|
+
resource?: any
|
|
23
|
+
resource_url?: string
|
|
24
|
+
webhook_id?: string
|
|
25
|
+
}>) {
|
|
26
|
+
if (process.env.FAIRE_INGEST_ORDERS === "false") return
|
|
27
|
+
|
|
28
|
+
const logger: any = container.resolve(ContainerRegistrationKeys.LOGGER)
|
|
29
|
+
const service: FaireSyncService = container.resolve(FAIRE_SYNC_MODULE)
|
|
30
|
+
|
|
31
|
+
let order: any = data?.resource
|
|
32
|
+
if (!order && data?.resource_url) {
|
|
33
|
+
try {
|
|
34
|
+
const account = await service.ensureFreshToken()
|
|
35
|
+
order = await service
|
|
36
|
+
.getClient()
|
|
37
|
+
.fetchResource((account as any).access_token, data.resource_url)
|
|
38
|
+
} catch (err: any) {
|
|
39
|
+
logger?.warn?.(`[faire-order] could not fetch order: ${err?.message}`)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!order) return
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await ingestFaireOrderWorkflow(container).run({
|
|
47
|
+
input: {
|
|
48
|
+
order,
|
|
49
|
+
order_token: order?.order_token != null ? String(order.order_token) : null,
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
logger?.warn?.(
|
|
54
|
+
`[faire-order] ingest failed for order ${order?.order_token}: ${err?.message}`
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const config: SubscriberConfig = {
|
|
60
|
+
event: ["faire.order_placed", "faire.order_created"],
|
|
61
|
+
}
|