From 7007ba12bccadcd0dc62c3b750437e66a3259c99 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 2 Dec 2025 08:58:51 +0000 Subject: [PATCH 1/2] Add shopify plugin with 2-5 high impact steps --- plugins/shopify/credentials.ts | 4 + plugins/shopify/icon.tsx | 14 ++ plugins/shopify/index.ts | 292 +++++++++++++++++++++++++++++++++ plugins/shopify/test.ts | 81 +++++++++ 4 files changed, 391 insertions(+) create mode 100644 plugins/shopify/credentials.ts create mode 100644 plugins/shopify/icon.tsx create mode 100644 plugins/shopify/index.ts create mode 100644 plugins/shopify/test.ts diff --git a/plugins/shopify/credentials.ts b/plugins/shopify/credentials.ts new file mode 100644 index 00000000..124c5afa --- /dev/null +++ b/plugins/shopify/credentials.ts @@ -0,0 +1,4 @@ +export type ShopifyCredentials = { + SHOPIFY_STORE_DOMAIN?: string; + SHOPIFY_ACCESS_TOKEN?: string; +}; diff --git a/plugins/shopify/icon.tsx b/plugins/shopify/icon.tsx new file mode 100644 index 00000000..f2acecd4 --- /dev/null +++ b/plugins/shopify/icon.tsx @@ -0,0 +1,14 @@ +export function ShopifyIcon({ className }: { className?: string }) { + return ( + + Shopify + + + ); +} diff --git a/plugins/shopify/index.ts b/plugins/shopify/index.ts new file mode 100644 index 00000000..235a6a76 --- /dev/null +++ b/plugins/shopify/index.ts @@ -0,0 +1,292 @@ +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { ShopifyIcon } from "./icon"; + +const shopifyPlugin: IntegrationPlugin = { + type: "shopify", + label: "Shopify", + description: "Manage orders, products, and inventory in your Shopify store", + + icon: ShopifyIcon, + + formFields: [ + { + id: "storeDomain", + label: "Store Domain", + type: "text", + placeholder: "your-store.myshopify.com", + configKey: "storeDomain", + envVar: "SHOPIFY_STORE_DOMAIN", + helpText: "Your Shopify store domain (e.g., your-store.myshopify.com)", + }, + { + id: "accessToken", + label: "Admin API Access Token", + type: "password", + placeholder: "shpat_...", + configKey: "accessToken", + envVar: "SHOPIFY_ACCESS_TOKEN", + helpText: "Create an access token from ", + helpLink: { + text: "Shopify Admin > Apps > Develop apps", + url: "https://help.shopify.com/en/manual/apps/app-types/custom-apps", + }, + }, + ], + + testConfig: { + getTestFunction: async () => { + const { testShopify } = await import("./test"); + return testShopify; + }, + }, + + actions: [ + { + slug: "get-order", + label: "Get Order", + description: "Retrieve details of a specific order by ID", + category: "Shopify", + stepFunction: "getOrderStep", + stepImportPath: "get-order", + outputFields: [ + { field: "id", description: "Unique ID of the order" }, + { field: "orderNumber", description: "Human-readable order number" }, + { field: "name", description: "Order name (e.g., #1001)" }, + { field: "email", description: "Customer email address" }, + { field: "totalPrice", description: "Total price of the order" }, + { field: "currency", description: "Currency code (e.g., USD)" }, + { + field: "financialStatus", + description: "Payment status (pending, paid, refunded, etc.)", + }, + { + field: "fulfillmentStatus", + description: "Fulfillment status (unfulfilled, fulfilled, partial)", + }, + { field: "createdAt", description: "ISO timestamp when order was created" }, + { field: "lineItems", description: "Array of line item objects" }, + { + field: "shippingAddress", + description: "Shipping address object (if available)", + }, + { field: "customer", description: "Customer information object" }, + ], + configFields: [ + { + key: "orderId", + label: "Order ID", + type: "template-input", + placeholder: "450789469 or {{NodeName.orderId}}", + example: "450789469", + required: true, + }, + ], + }, + { + slug: "list-orders", + label: "List Orders", + description: "Search and list orders with optional filters", + category: "Shopify", + stepFunction: "listOrdersStep", + stepImportPath: "list-orders", + outputFields: [ + { field: "orders", description: "Array of order objects" }, + { field: "count", description: "Number of orders returned" }, + ], + configFields: [ + { + key: "status", + label: "Order Status", + type: "select", + defaultValue: "any", + options: [ + { value: "any", label: "Any" }, + { value: "open", label: "Open" }, + { value: "closed", label: "Closed" }, + { value: "cancelled", label: "Cancelled" }, + ], + }, + { + key: "financialStatus", + label: "Financial Status", + type: "select", + defaultValue: "", + options: [ + { value: "", label: "Any" }, + { value: "pending", label: "Pending" }, + { value: "paid", label: "Paid" }, + { value: "refunded", label: "Refunded" }, + { value: "voided", label: "Voided" }, + { value: "partially_refunded", label: "Partially Refunded" }, + ], + }, + { + key: "fulfillmentStatus", + label: "Fulfillment Status", + type: "select", + defaultValue: "", + options: [ + { value: "", label: "Any" }, + { value: "unfulfilled", label: "Unfulfilled" }, + { value: "fulfilled", label: "Fulfilled" }, + { value: "partial", label: "Partial" }, + ], + }, + { + key: "createdAtMin", + label: "Created After (ISO date)", + type: "template-input", + placeholder: "2024-01-01 or {{NodeName.date}}", + }, + { + key: "createdAtMax", + label: "Created Before (ISO date)", + type: "template-input", + placeholder: "2024-12-31 or {{NodeName.date}}", + }, + { + key: "limit", + label: "Limit", + type: "number", + min: 1, + defaultValue: "50", + }, + ], + }, + { + slug: "create-product", + label: "Create Product", + description: "Create a new product in your Shopify store", + category: "Shopify", + stepFunction: "createProductStep", + stepImportPath: "create-product", + outputFields: [ + { field: "id", description: "Unique ID of the created product" }, + { field: "title", description: "Title of the product" }, + { field: "handle", description: "URL-friendly handle for the product" }, + { field: "status", description: "Product status (active, draft, archived)" }, + { field: "variants", description: "Array of product variants" }, + { field: "createdAt", description: "ISO timestamp when product was created" }, + ], + configFields: [ + { + key: "title", + label: "Product Title", + type: "template-input", + placeholder: "Awesome T-Shirt or {{NodeName.title}}", + example: "Awesome T-Shirt", + required: true, + }, + { + key: "bodyHtml", + label: "Description (HTML)", + type: "template-textarea", + placeholder: "

Product description...

", + rows: 4, + example: "

A comfortable cotton t-shirt

", + }, + { + key: "vendor", + label: "Vendor", + type: "template-input", + placeholder: "Your Brand or {{NodeName.vendor}}", + example: "Acme Inc", + }, + { + key: "productType", + label: "Product Type", + type: "template-input", + placeholder: "T-Shirts or {{NodeName.type}}", + example: "Clothing", + }, + { + key: "tags", + label: "Tags (comma-separated)", + type: "template-input", + placeholder: "summer, sale, new", + example: "summer, featured", + }, + { + key: "status", + label: "Status", + type: "select", + defaultValue: "draft", + options: [ + { value: "draft", label: "Draft" }, + { value: "active", label: "Active" }, + { value: "archived", label: "Archived" }, + ], + }, + { + key: "price", + label: "Price", + type: "template-input", + placeholder: "29.99 or {{NodeName.price}}", + example: "29.99", + }, + { + key: "sku", + label: "SKU", + type: "template-input", + placeholder: "TSHIRT-001 or {{NodeName.sku}}", + example: "TSHIRT-001", + }, + { + key: "inventoryQuantity", + label: "Inventory Quantity", + type: "number", + min: 0, + defaultValue: "0", + }, + ], + }, + { + slug: "update-inventory", + label: "Update Inventory", + description: "Update inventory levels for a product variant", + category: "Shopify", + stepFunction: "updateInventoryStep", + stepImportPath: "update-inventory", + outputFields: [ + { + field: "inventoryItemId", + description: "ID of the inventory item updated", + }, + { field: "locationId", description: "ID of the inventory location" }, + { field: "available", description: "New available inventory quantity" }, + { field: "previousQuantity", description: "Previous inventory quantity" }, + ], + configFields: [ + { + key: "inventoryItemId", + label: "Inventory Item ID", + type: "template-input", + placeholder: "808950810 or {{NodeName.inventoryItemId}}", + example: "808950810", + required: true, + }, + { + key: "locationId", + label: "Location ID", + type: "template-input", + placeholder: "655441491 or {{NodeName.locationId}}", + example: "655441491", + required: true, + }, + { + key: "adjustment", + label: "Quantity Adjustment", + type: "template-input", + placeholder: "10 or -5 or {{NodeName.adjustment}}", + example: "10", + required: true, + }, + ], + }, + ], +}; + +registerIntegration(shopifyPlugin); + +export default shopifyPlugin; diff --git a/plugins/shopify/test.ts b/plugins/shopify/test.ts new file mode 100644 index 00000000..84332181 --- /dev/null +++ b/plugins/shopify/test.ts @@ -0,0 +1,81 @@ +type ShopInfo = { + shop: { + name: string; + email: string; + }; +}; + +export async function testShopify(credentials: Record) { + try { + const storeDomain = credentials.SHOPIFY_STORE_DOMAIN; + const accessToken = credentials.SHOPIFY_ACCESS_TOKEN; + + if (!storeDomain) { + return { + success: false, + error: "SHOPIFY_STORE_DOMAIN is required", + }; + } + + if (!accessToken) { + return { + success: false, + error: "SHOPIFY_ACCESS_TOKEN is required", + }; + } + + // Normalize store domain (remove protocol and trailing slashes) + const normalizedDomain = storeDomain + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + + // Make a lightweight API call to verify credentials + const response = await fetch( + `https://${normalizedDomain}/admin/api/2024-01/shop.json`, + { + method: "GET", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + if (response.status === 401) { + return { + success: false, + error: + "Invalid access token. Please check your Shopify Admin API access token.", + }; + } + if (response.status === 404) { + return { + success: false, + error: + "Store not found. Please check your store domain (e.g., your-store.myshopify.com).", + }; + } + return { + success: false, + error: `API validation failed: HTTP ${response.status}`, + }; + } + + const data = (await response.json()) as ShopInfo; + + if (!data.shop?.name) { + return { + success: false, + error: "Failed to verify Shopify connection", + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} From 7b75434338c7843e13e9fb92a7613eac51ef28b6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 2 Dec 2025 09:06:59 +0000 Subject: [PATCH 2/2] Fix steps creation for cursor agent follow-up --- README.md | 1 + plugins/index.ts | 3 +- plugins/shopify/steps/create-product.ts | 197 ++++++++++++++++++++ plugins/shopify/steps/get-order.ts | 215 ++++++++++++++++++++++ plugins/shopify/steps/list-orders.ts | 183 ++++++++++++++++++ plugins/shopify/steps/update-inventory.ts | 169 +++++++++++++++++ 6 files changed, 767 insertions(+), 1 deletion(-) create mode 100644 plugins/shopify/steps/create-product.ts create mode 100644 plugins/shopify/steps/get-order.ts create mode 100644 plugins/shopify/steps/list-orders.ts create mode 100644 plugins/shopify/steps/update-inventory.ts diff --git a/README.md b/README.md index c15185d8..58e81b45 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started. - **GitHub**: Create Issue, List Issues, Get Issue, Update Issue - **Linear**: Create Ticket, Find Issues - **Resend**: Send Email +- **Shopify**: Get Order, List Orders, Create Product, Update Inventory - **Slack**: Send Slack Message - **Superagent**: Guard, Redact - **v0**: Create Chat, Send Message diff --git a/plugins/index.ts b/plugins/index.ts index af795cb5..0954a3f4 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -13,7 +13,7 @@ * 1. Delete the plugin directory * 2. Run: pnpm discover-plugins (or it runs automatically on build) * - * Discovered plugins: ai-gateway, blob, firecrawl, github, linear, resend, slack, superagent, v0 + * Discovered plugins: ai-gateway, blob, firecrawl, github, linear, resend, shopify, slack, superagent, v0 */ import "./ai-gateway"; @@ -22,6 +22,7 @@ import "./firecrawl"; import "./github"; import "./linear"; import "./resend"; +import "./shopify"; import "./slack"; import "./superagent"; import "./v0"; diff --git a/plugins/shopify/steps/create-product.ts b/plugins/shopify/steps/create-product.ts new file mode 100644 index 00000000..df473ad3 --- /dev/null +++ b/plugins/shopify/steps/create-product.ts @@ -0,0 +1,197 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { ShopifyCredentials } from "../credentials"; + +type ShopifyVariant = { + id: number; + product_id: number; + title: string; + price: string; + sku?: string; + inventory_quantity?: number; + inventory_item_id?: number; +}; + +type ShopifyProduct = { + id: number; + title: string; + handle: string; + status: string; + body_html?: string; + vendor?: string; + product_type?: string; + tags: string; + created_at: string; + updated_at: string; + variants: ShopifyVariant[]; +}; + +type CreateProductResult = + | { + success: true; + id: number; + title: string; + handle: string; + status: string; + variants: Array<{ + id: number; + title: string; + price: string; + sku?: string; + inventoryItemId?: number; + }>; + createdAt: string; + } + | { success: false; error: string }; + +export type CreateProductCoreInput = { + title: string; + bodyHtml?: string; + vendor?: string; + productType?: string; + tags?: string; + status?: string; + price?: string; + sku?: string; + inventoryQuantity?: number; +}; + +export type CreateProductInput = StepInput & + CreateProductCoreInput & { + integrationId?: string; + }; + +function normalizeStoreDomain(domain: string): string { + return domain.replace(/^https?:\/\//, "").replace(/\/$/, ""); +} + +async function stepHandler( + input: CreateProductCoreInput, + credentials: ShopifyCredentials +): Promise { + const storeDomain = credentials.SHOPIFY_STORE_DOMAIN; + const accessToken = credentials.SHOPIFY_ACCESS_TOKEN; + + if (!storeDomain) { + return { + success: false, + error: + "SHOPIFY_STORE_DOMAIN is not configured. Please add it in Project Integrations.", + }; + } + + if (!accessToken) { + return { + success: false, + error: + "SHOPIFY_ACCESS_TOKEN is not configured. Please add it in Project Integrations.", + }; + } + + try { + const normalizedDomain = normalizeStoreDomain(storeDomain); + const url = `https://${normalizedDomain}/admin/api/2024-01/products.json`; + + // Build the product payload + const productPayload: Record = { + title: input.title, + }; + + if (input.bodyHtml) { + productPayload.body_html = input.bodyHtml; + } + + if (input.vendor) { + productPayload.vendor = input.vendor; + } + + if (input.productType) { + productPayload.product_type = input.productType; + } + + if (input.tags) { + productPayload.tags = input.tags; + } + + if (input.status) { + productPayload.status = input.status; + } + + // Add variant with price/sku if provided + if (input.price || input.sku) { + const variant: Record = {}; + + if (input.price) { + variant.price = input.price; + } + + if (input.sku) { + variant.sku = input.sku; + } + + productPayload.variants = [variant]; + } + + const response = await fetch(url, { + method: "POST", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ product: productPayload }), + }); + + if (!response.ok) { + const errorData = (await response.json()) as { errors?: unknown }; + const errorMessage = + typeof errorData.errors === "string" + ? errorData.errors + : JSON.stringify(errorData.errors); + return { + success: false, + error: errorMessage || `HTTP ${response.status}`, + }; + } + + const data = (await response.json()) as { product: ShopifyProduct }; + const product = data.product; + + return { + success: true, + id: product.id, + title: product.title, + handle: product.handle, + status: product.status, + variants: product.variants.map((v) => ({ + id: v.id, + title: v.title, + price: v.price, + sku: v.sku, + inventoryItemId: v.inventory_item_id, + })), + createdAt: product.created_at, + }; + } catch (error) { + return { + success: false, + error: `Failed to create product: ${getErrorMessage(error)}`, + }; + } +} + +export async function createProductStep( + input: CreateProductInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "shopify"; diff --git a/plugins/shopify/steps/get-order.ts b/plugins/shopify/steps/get-order.ts new file mode 100644 index 00000000..5b35ea23 --- /dev/null +++ b/plugins/shopify/steps/get-order.ts @@ -0,0 +1,215 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { ShopifyCredentials } from "../credentials"; + +type ShopifyLineItem = { + id: number; + title: string; + quantity: number; + price: string; + sku?: string; + variant_id?: number; + product_id?: number; +}; + +type ShopifyAddress = { + first_name?: string; + last_name?: string; + address1?: string; + address2?: string; + city?: string; + province?: string; + country?: string; + zip?: string; + phone?: string; +}; + +type ShopifyCustomer = { + id: number; + email?: string; + first_name?: string; + last_name?: string; +}; + +type ShopifyOrder = { + id: number; + order_number: number; + name: string; + email?: string; + total_price: string; + currency: string; + financial_status: string; + fulfillment_status: string | null; + created_at: string; + updated_at: string; + line_items: ShopifyLineItem[]; + shipping_address?: ShopifyAddress; + customer?: ShopifyCustomer; +}; + +type GetOrderResult = + | { + success: true; + id: number; + orderNumber: number; + name: string; + email?: string; + totalPrice: string; + currency: string; + financialStatus: string; + fulfillmentStatus: string | null; + createdAt: string; + lineItems: Array<{ + id: number; + title: string; + quantity: number; + price: string; + sku?: string; + variantId?: number; + productId?: number; + }>; + shippingAddress?: { + firstName?: string; + lastName?: string; + address1?: string; + address2?: string; + city?: string; + province?: string; + country?: string; + zip?: string; + phone?: string; + }; + customer?: { + id: number; + email?: string; + firstName?: string; + lastName?: string; + }; + } + | { success: false; error: string }; + +export type GetOrderCoreInput = { + orderId: string; +}; + +export type GetOrderInput = StepInput & + GetOrderCoreInput & { + integrationId?: string; + }; + +function normalizeStoreDomain(domain: string): string { + return domain.replace(/^https?:\/\//, "").replace(/\/$/, ""); +} + +async function stepHandler( + input: GetOrderCoreInput, + credentials: ShopifyCredentials +): Promise { + const storeDomain = credentials.SHOPIFY_STORE_DOMAIN; + const accessToken = credentials.SHOPIFY_ACCESS_TOKEN; + + if (!storeDomain) { + return { + success: false, + error: + "SHOPIFY_STORE_DOMAIN is not configured. Please add it in Project Integrations.", + }; + } + + if (!accessToken) { + return { + success: false, + error: + "SHOPIFY_ACCESS_TOKEN is not configured. Please add it in Project Integrations.", + }; + } + + try { + const normalizedDomain = normalizeStoreDomain(storeDomain); + const url = `https://${normalizedDomain}/admin/api/2024-01/orders/${input.orderId}.json`; + + const response = await fetch(url, { + method: "GET", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { errors?: string }; + return { + success: false, + error: errorData.errors || `HTTP ${response.status}`, + }; + } + + const data = (await response.json()) as { order: ShopifyOrder }; + const order = data.order; + + return { + success: true, + id: order.id, + orderNumber: order.order_number, + name: order.name, + email: order.email, + totalPrice: order.total_price, + currency: order.currency, + financialStatus: order.financial_status, + fulfillmentStatus: order.fulfillment_status, + createdAt: order.created_at, + lineItems: order.line_items.map((item) => ({ + id: item.id, + title: item.title, + quantity: item.quantity, + price: item.price, + sku: item.sku, + variantId: item.variant_id, + productId: item.product_id, + })), + shippingAddress: order.shipping_address + ? { + firstName: order.shipping_address.first_name, + lastName: order.shipping_address.last_name, + address1: order.shipping_address.address1, + address2: order.shipping_address.address2, + city: order.shipping_address.city, + province: order.shipping_address.province, + country: order.shipping_address.country, + zip: order.shipping_address.zip, + phone: order.shipping_address.phone, + } + : undefined, + customer: order.customer + ? { + id: order.customer.id, + email: order.customer.email, + firstName: order.customer.first_name, + lastName: order.customer.last_name, + } + : undefined, + }; + } catch (error) { + return { + success: false, + error: `Failed to get order: ${getErrorMessage(error)}`, + }; + } +} + +export async function getOrderStep( + input: GetOrderInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "shopify"; diff --git a/plugins/shopify/steps/list-orders.ts b/plugins/shopify/steps/list-orders.ts new file mode 100644 index 00000000..01943ae9 --- /dev/null +++ b/plugins/shopify/steps/list-orders.ts @@ -0,0 +1,183 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { ShopifyCredentials } from "../credentials"; + +type ShopifyLineItem = { + id: number; + title: string; + quantity: number; + price: string; +}; + +type ShopifyOrder = { + id: number; + order_number: number; + name: string; + email?: string; + total_price: string; + currency: string; + financial_status: string; + fulfillment_status: string | null; + created_at: string; + updated_at: string; + line_items: ShopifyLineItem[]; +}; + +type OrderSummary = { + id: number; + orderNumber: number; + name: string; + email?: string; + totalPrice: string; + currency: string; + financialStatus: string; + fulfillmentStatus: string | null; + createdAt: string; + updatedAt: string; + itemCount: number; +}; + +type ListOrdersResult = + | { + success: true; + orders: OrderSummary[]; + count: number; + } + | { success: false; error: string }; + +export type ListOrdersCoreInput = { + status?: string; + financialStatus?: string; + fulfillmentStatus?: string; + createdAtMin?: string; + createdAtMax?: string; + limit?: number; +}; + +export type ListOrdersInput = StepInput & + ListOrdersCoreInput & { + integrationId?: string; + }; + +function normalizeStoreDomain(domain: string): string { + return domain.replace(/^https?:\/\//, "").replace(/\/$/, ""); +} + +async function stepHandler( + input: ListOrdersCoreInput, + credentials: ShopifyCredentials +): Promise { + const storeDomain = credentials.SHOPIFY_STORE_DOMAIN; + const accessToken = credentials.SHOPIFY_ACCESS_TOKEN; + + if (!storeDomain) { + return { + success: false, + error: + "SHOPIFY_STORE_DOMAIN is not configured. Please add it in Project Integrations.", + }; + } + + if (!accessToken) { + return { + success: false, + error: + "SHOPIFY_ACCESS_TOKEN is not configured. Please add it in Project Integrations.", + }; + } + + try { + const normalizedDomain = normalizeStoreDomain(storeDomain); + const params = new URLSearchParams(); + + if (input.status && input.status !== "any") { + params.set("status", input.status); + } + + if (input.financialStatus) { + params.set("financial_status", input.financialStatus); + } + + if (input.fulfillmentStatus) { + params.set("fulfillment_status", input.fulfillmentStatus); + } + + if (input.createdAtMin) { + params.set("created_at_min", input.createdAtMin); + } + + if (input.createdAtMax) { + params.set("created_at_max", input.createdAtMax); + } + + if (input.limit) { + params.set("limit", String(input.limit)); + } else { + params.set("limit", "50"); + } + + const url = `https://${normalizedDomain}/admin/api/2024-01/orders.json${ + params.toString() ? `?${params.toString()}` : "" + }`; + + const response = await fetch(url, { + method: "GET", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { errors?: string }; + return { + success: false, + error: errorData.errors || `HTTP ${response.status}`, + }; + } + + const data = (await response.json()) as { orders: ShopifyOrder[] }; + + const orders: OrderSummary[] = data.orders.map((order) => ({ + id: order.id, + orderNumber: order.order_number, + name: order.name, + email: order.email, + totalPrice: order.total_price, + currency: order.currency, + financialStatus: order.financial_status, + fulfillmentStatus: order.fulfillment_status, + createdAt: order.created_at, + updatedAt: order.updated_at, + itemCount: order.line_items.reduce((sum, item) => sum + item.quantity, 0), + })); + + return { + success: true, + orders, + count: orders.length, + }; + } catch (error) { + return { + success: false, + error: `Failed to list orders: ${getErrorMessage(error)}`, + }; + } +} + +export async function listOrdersStep( + input: ListOrdersInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "shopify"; diff --git a/plugins/shopify/steps/update-inventory.ts b/plugins/shopify/steps/update-inventory.ts new file mode 100644 index 00000000..1a90d683 --- /dev/null +++ b/plugins/shopify/steps/update-inventory.ts @@ -0,0 +1,169 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { ShopifyCredentials } from "../credentials"; + +type InventoryLevel = { + inventory_item_id: number; + location_id: number; + available: number; + updated_at: string; +}; + +type UpdateInventoryResult = + | { + success: true; + inventoryItemId: number; + locationId: number; + available: number; + previousQuantity: number; + } + | { success: false; error: string }; + +export type UpdateInventoryCoreInput = { + inventoryItemId: string; + locationId: string; + adjustment: string; +}; + +export type UpdateInventoryInput = StepInput & + UpdateInventoryCoreInput & { + integrationId?: string; + }; + +function normalizeStoreDomain(domain: string): string { + return domain.replace(/^https?:\/\//, "").replace(/\/$/, ""); +} + +async function stepHandler( + input: UpdateInventoryCoreInput, + credentials: ShopifyCredentials +): Promise { + const storeDomain = credentials.SHOPIFY_STORE_DOMAIN; + const accessToken = credentials.SHOPIFY_ACCESS_TOKEN; + + if (!storeDomain) { + return { + success: false, + error: + "SHOPIFY_STORE_DOMAIN is not configured. Please add it in Project Integrations.", + }; + } + + if (!accessToken) { + return { + success: false, + error: + "SHOPIFY_ACCESS_TOKEN is not configured. Please add it in Project Integrations.", + }; + } + + const adjustmentValue = Number.parseInt(input.adjustment, 10); + if (Number.isNaN(adjustmentValue)) { + return { + success: false, + error: "Adjustment must be a valid integer (e.g., 10 or -5)", + }; + } + + try { + const normalizedDomain = normalizeStoreDomain(storeDomain); + + // First, get the current inventory level + const getUrl = `https://${normalizedDomain}/admin/api/2024-01/inventory_levels.json?inventory_item_ids=${input.inventoryItemId}&location_ids=${input.locationId}`; + + const getResponse = await fetch(getUrl, { + method: "GET", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + }); + + if (!getResponse.ok) { + const errorData = (await getResponse.json()) as { errors?: string }; + return { + success: false, + error: + errorData.errors || + `Failed to get current inventory: HTTP ${getResponse.status}`, + }; + } + + const getCurrentData = (await getResponse.json()) as { + inventory_levels: InventoryLevel[]; + }; + + if (getCurrentData.inventory_levels.length === 0) { + return { + success: false, + error: + "Inventory level not found for the specified item and location. Make sure the inventory item is stocked at this location.", + }; + } + + const previousQuantity = getCurrentData.inventory_levels[0].available; + + // Now adjust the inventory + const adjustUrl = `https://${normalizedDomain}/admin/api/2024-01/inventory_levels/adjust.json`; + + const response = await fetch(adjustUrl, { + method: "POST", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + location_id: Number.parseInt(input.locationId, 10), + inventory_item_id: Number.parseInt(input.inventoryItemId, 10), + available_adjustment: adjustmentValue, + }), + }); + + if (!response.ok) { + const errorData = (await response.json()) as { errors?: unknown }; + const errorMessage = + typeof errorData.errors === "string" + ? errorData.errors + : JSON.stringify(errorData.errors); + return { + success: false, + error: errorMessage || `HTTP ${response.status}`, + }; + } + + const data = (await response.json()) as { + inventory_level: InventoryLevel; + }; + const level = data.inventory_level; + + return { + success: true, + inventoryItemId: level.inventory_item_id, + locationId: level.location_id, + available: level.available, + previousQuantity, + }; + } catch (error) { + return { + success: false, + error: `Failed to update inventory: ${getErrorMessage(error)}`, + }; + } +} + +export async function updateInventoryStep( + input: UpdateInventoryInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "shopify";