@hanzo/shopify 1.0.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.
@@ -0,0 +1,345 @@
1
+ // The Hanzo AI for Shopify service. This is the ONLY place the Shopify API secret
2
+ // and the Hanzo API key exist — both read from the environment (never bundled,
3
+ // never sent to the browser). It does OAuth, proxies the Admin GraphQL API + the
4
+ // @hanzo/ai model gateway, and verifies every Shopify HMAC. The embedded Polaris
5
+ // frontend calls the /v1/* proxy endpoints with its shop domain; the offline
6
+ // access token stays here.
7
+ //
8
+ // GET /oauth/install?shop=… → redirect the merchant to Shopify consent
9
+ // GET /oauth/callback?… → verify HMAC + state, exchange code, store
10
+ // the offline token per shop
11
+ // GET /v1/product?shop=&id= → read a product (Admin GraphQL)
12
+ // GET /v1/order?shop=&id= → read an order (Admin GraphQL)
13
+ // GET /v1/models?shop= → list model ids the shop may route to
14
+ // POST /v1/product/action → run an AI product action, return content
15
+ // POST /v1/order/action → run an AI order action, return content
16
+ // POST /v1/product/update → write AI content back (productUpdate)
17
+ // POST /webhooks → verify the Shopify webhook HMAC, route
18
+ // GET /healthz → readiness
19
+ //
20
+ // Dependency-free Node http handler over the pure modules (config, oauth,
21
+ // shopify-api, webhooks, hanzo, actions) so it is deployable behind
22
+ // hanzoai/ingress as a small service at shopify.hanzo.ai.
23
+ //
24
+ // node dist/server.js (after build.js bundles it)
25
+
26
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
27
+ import { randomUUID } from 'node:crypto';
28
+ import { isShopDomain, readServerConfig, type ServerConfig } from '../config.js';
29
+ import {
30
+ authorizeUrl,
31
+ tokenExchange,
32
+ parseTokenResponse,
33
+ verifyCallbackHmac,
34
+ parseCallback,
35
+ } from './oauth.js';
36
+ import {
37
+ getProduct,
38
+ getOrder,
39
+ updateProduct,
40
+ parseProduct,
41
+ parseOrder,
42
+ parseProductUpdate,
43
+ type PreparedGraphql,
44
+ type ProductWriteback,
45
+ } from './shopify-api.js';
46
+ import { verifyWebhook, routeWebhook, type WebhookAction } from './webhooks.js';
47
+ import { runProductAction, runOrderAction, isProductActionId, isOrderActionId } from './actions.js';
48
+ import { listModels, type AskOptions } from './hanzo.js';
49
+
50
+ // A stored per-shop authorization: the offline access token to call the Admin
51
+ // API. In-memory here keyed by shop domain; a production deployment persists
52
+ // this to KMS/Valkey (keyed by shop) so the webhook and the panel can act after
53
+ // a restart. The install flow populates it; every other endpoint reads it.
54
+ const shops = new Map<string, { accessToken: string; scope: string }>();
55
+
56
+ // Fresh OAuth `state` nonces awaiting their callback. A production deployment
57
+ // persists these (Valkey, TTL) so state survives a restart and cannot be
58
+ // replayed; in-process is enough for a single instance and keeps the trust gate
59
+ // real (an unknown state is rejected).
60
+ const pendingStates = new Set<string>();
61
+
62
+ async function readRawBody(req: IncomingMessage): Promise<string> {
63
+ const chunks: Buffer[] = [];
64
+ for await (const c of req) chunks.push(c as Buffer);
65
+ return Buffer.concat(chunks).toString('utf8');
66
+ }
67
+
68
+ function json(res: ServerResponse, status: number, body: unknown): void {
69
+ res.writeHead(status, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify(body));
71
+ }
72
+
73
+ function log(fields: Record<string, unknown>): void {
74
+ console.log(JSON.stringify(fields));
75
+ }
76
+
77
+ // send performs a PreparedGraphql request and returns the parsed JSON. The token
78
+ // header + body are already shaped by shopify-api; this is the one fetch.
79
+ async function send(req: PreparedGraphql): Promise<any> {
80
+ const resp = await fetch(req.url, { method: 'POST', headers: req.headers, body: req.body });
81
+ const text = await resp.text();
82
+ let data: any;
83
+ try {
84
+ data = JSON.parse(text);
85
+ } catch {
86
+ throw new Error(`Shopify Admin API ${resp.status}: ${text.slice(0, 200)}`);
87
+ }
88
+ return data;
89
+ }
90
+
91
+ // requireShopAuth resolves the shop from a query/body value and its stored token,
92
+ // or throws a boundary error (turned into a 400/401 by the caller). Every proxy
93
+ // endpoint runs input through this before touching Shopify — a request for an
94
+ // unknown or un-installed shop never reaches the Admin API.
95
+ function requireShopAuth(shop: string | undefined): { shop: string; accessToken: string } {
96
+ if (!isShopDomain(shop)) throw new Error('invalid or missing shop');
97
+ const auth = shops.get(shop);
98
+ if (!auth) throw new Error('shop not installed');
99
+ return { shop, accessToken: auth.accessToken };
100
+ }
101
+
102
+ // GET /oauth/install?shop=… → 302 to Shopify's consent screen. The shop is
103
+ // validated (never build the authorize URL against an unverified host); `state`
104
+ // is a fresh CSRF nonce we remember for the callback.
105
+ function handleInstall(cfg: ServerConfig, url: URL, res: ServerResponse): void {
106
+ const shop = url.searchParams.get('shop') ?? undefined;
107
+ if (!isShopDomain(shop)) return json(res, 400, { error: 'invalid or missing shop' });
108
+ const state = randomUUID();
109
+ pendingStates.add(state);
110
+ const dest = authorizeUrl({
111
+ shop,
112
+ apiKey: cfg.shopifyApiKey,
113
+ scopes: cfg.scopes,
114
+ redirectUri: cfg.shopifyRedirectUri,
115
+ state,
116
+ });
117
+ res.writeHead(302, { Location: dest });
118
+ res.end();
119
+ }
120
+
121
+ // GET /oauth/callback → the trust gate + token exchange. Order matters:
122
+ // 1. verify the HMAC signature (authenticity) BEFORE trusting any param
123
+ // 2. check state (CSRF) against what we issued
124
+ // 3. exchange the code for the offline token (secret stays in the body)
125
+ async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
126
+ const params: Record<string, string> = {};
127
+ url.searchParams.forEach((v, k) => (params[k] = v));
128
+
129
+ if (!verifyCallbackHmac(cfg.shopifyApiSecret, params)) {
130
+ log({ msg: 'oauth callback: HMAC verification failed' });
131
+ return json(res, 401, { error: 'invalid hmac' });
132
+ }
133
+
134
+ let cb;
135
+ try {
136
+ cb = parseCallback(params);
137
+ } catch (e: any) {
138
+ return json(res, 400, { error: e?.message || 'invalid callback' });
139
+ }
140
+
141
+ if (!pendingStates.delete(cb.state)) {
142
+ log({ msg: 'oauth callback: unknown state (possible CSRF)', shop: cb.shop });
143
+ return json(res, 401, { error: 'invalid state' });
144
+ }
145
+
146
+ const ex = tokenExchange({
147
+ shop: cb.shop,
148
+ apiKey: cfg.shopifyApiKey,
149
+ apiSecret: cfg.shopifyApiSecret,
150
+ code: cb.code,
151
+ });
152
+ try {
153
+ const resp = await fetch(ex.url, { method: 'POST', headers: ex.headers, body: ex.body });
154
+ const tokenSet = parseTokenResponse(await resp.json().catch(() => ({})));
155
+ shops.set(cb.shop, { accessToken: tokenSet.access_token, scope: tokenSet.scope });
156
+ log({ msg: 'oauth: shop installed', shop: cb.shop, scope: tokenSet.scope });
157
+ return json(res, 200, { ok: true, installed: true, shop: cb.shop });
158
+ } catch (e: any) {
159
+ return json(res, 400, { error: e?.message || 'token exchange failed' });
160
+ }
161
+ }
162
+
163
+ // GET /v1/product?shop=&id= → read a product for the panel.
164
+ async function handleGetProduct(url: URL, res: ServerResponse): Promise<void> {
165
+ const { shop, accessToken } = requireShopAuth(url.searchParams.get('shop') ?? undefined);
166
+ const id = url.searchParams.get('id');
167
+ if (!id) return json(res, 400, { error: 'missing product id' });
168
+ const data = await send(getProduct(shop, accessToken, id));
169
+ return json(res, 200, parseProduct(data));
170
+ }
171
+
172
+ // GET /v1/order?shop=&id= → read an order for the panel.
173
+ async function handleGetOrder(url: URL, res: ServerResponse): Promise<void> {
174
+ const { shop, accessToken } = requireShopAuth(url.searchParams.get('shop') ?? undefined);
175
+ const id = url.searchParams.get('id');
176
+ if (!id) return json(res, 400, { error: 'missing order id' });
177
+ const data = await send(getOrder(shop, accessToken, id));
178
+ return json(res, 200, parseOrder(data));
179
+ }
180
+
181
+ // GET /v1/models?shop= → the model ids this shop may route to. Uses the server's
182
+ // Hanzo key (the shop's org context) so the picker matches what a run will use.
183
+ async function handleModels(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
184
+ requireShopAuth(url.searchParams.get('shop') ?? undefined);
185
+ const models = await listModels({ token: cfg.hanzoApiKey });
186
+ return json(res, 200, { models });
187
+ }
188
+
189
+ // askOpts builds the @hanzo/ai options for a proxied run: the server's Hanzo key
190
+ // is the bearer (the merchant authenticates to Shopify via App Bridge, not to
191
+ // Hanzo directly), and the request may name a model.
192
+ function askOpts(cfg: ServerConfig, model: unknown): AskOptions {
193
+ return { token: cfg.hanzoApiKey, model: typeof model === 'string' && model ? model : undefined };
194
+ }
195
+
196
+ // POST /v1/product/action { shop, id, action, model? } → read the product, run
197
+ // the AI action, return the generated content. The panel then previews it and,
198
+ // on confirm, calls /v1/product/update.
199
+ async function handleProductAction(cfg: ServerConfig, body: any, res: ServerResponse): Promise<void> {
200
+ const { shop, accessToken } = requireShopAuth(body?.shop);
201
+ if (!isProductActionId(String(body?.action))) return json(res, 400, { error: 'unknown product action' });
202
+ if (!body?.id) return json(res, 400, { error: 'missing product id' });
203
+ const product = parseProduct(await send(getProduct(shop, accessToken, String(body.id))));
204
+ const content = await runProductAction(String(body.action), product, askOpts(cfg, body?.model));
205
+ return json(res, 200, { content });
206
+ }
207
+
208
+ // POST /v1/order/action { shop, id, action, model? } → read the order, run the
209
+ // AI order/support action, return the content.
210
+ async function handleOrderAction(cfg: ServerConfig, body: any, res: ServerResponse): Promise<void> {
211
+ const { shop, accessToken } = requireShopAuth(body?.shop);
212
+ if (!isOrderActionId(String(body?.action))) return json(res, 400, { error: 'unknown order action' });
213
+ if (!body?.id) return json(res, 400, { error: 'missing order id' });
214
+ const order = parseOrder(await send(getOrder(shop, accessToken, String(body.id))));
215
+ const content = await runOrderAction(String(body.action), order, askOpts(cfg, body?.model));
216
+ return json(res, 200, { content });
217
+ }
218
+
219
+ // POST /v1/product/update { shop, id, descriptionHtml?, seoTitle?, seoDescription? }
220
+ // → write AI content back via productUpdate. This is the write-back flow: the
221
+ // panel generated + previewed content, the merchant confirmed, and only the
222
+ // fields present in the body are written (a description rewrite never clobbers
223
+ // the SEO title). userErrors surface as a thrown 400.
224
+ async function handleProductUpdate(body: any, res: ServerResponse): Promise<void> {
225
+ const { shop, accessToken } = requireShopAuth(body?.shop);
226
+ if (!body?.id) return json(res, 400, { error: 'missing product id' });
227
+ const w: ProductWriteback = {
228
+ id: String(body.id),
229
+ descriptionHtml: typeof body.descriptionHtml === 'string' ? body.descriptionHtml : undefined,
230
+ seoTitle: typeof body.seoTitle === 'string' ? body.seoTitle : undefined,
231
+ seoDescription: typeof body.seoDescription === 'string' ? body.seoDescription : undefined,
232
+ };
233
+ const updated = parseProductUpdate(await send(updateProduct(shop, accessToken, w)));
234
+ log({ msg: 'product updated', shop, productId: updated.id });
235
+ return json(res, 200, { ok: true, product: updated });
236
+ }
237
+
238
+ // handleWebhookAction dispatches a verified webhook. Order creation could
239
+ // auto-draft a support summary (needs the shop's token + Hanzo key); app
240
+ // uninstall drops the stored token; GDPR topics are acknowledged (a real
241
+ // deployment fulfills the data request/redaction against its own store here).
242
+ // Never throws into the http handler — logs and returns.
243
+ async function handleWebhookAction(cfg: ServerConfig, action: WebhookAction): Promise<void> {
244
+ switch (action.kind) {
245
+ case 'order_created':
246
+ log({ msg: 'webhook: order created', shop: action.shop, orderId: action.orderId, canDraft: !!cfg.hanzoApiKey });
247
+ // A deployment that wants an auto-drafted summary reads the order via the
248
+ // shop's stored token and runs runOrderAction('summarize', …), then stores
249
+ // the result (hanzoai/docdb) or notifies the merchant. Delivery target is a
250
+ // per-deployment choice (documented in the README).
251
+ return;
252
+ case 'app_uninstalled':
253
+ shops.delete(action.shop);
254
+ log({ msg: 'webhook: app uninstalled, token dropped', shop: action.shop });
255
+ return;
256
+ case 'gdpr':
257
+ log({ msg: 'webhook: gdpr topic acknowledged', topic: action.topic, shop: action.shop });
258
+ return;
259
+ case 'ignored':
260
+ log({ msg: 'webhook: ignored', topic: action.topic, shop: action.shop });
261
+ return;
262
+ }
263
+ }
264
+
265
+ // POST /webhooks → verify, then dispatch. Verification is the trust gate: a
266
+ // webhook with a bad/absent HMAC is a 401 before any action. Shopify sends the
267
+ // signature in X-Shopify-Hmac-Sha256, the shop in X-Shopify-Shop-Domain, and the
268
+ // topic in X-Shopify-Topic.
269
+ async function handleWebhook(cfg: ServerConfig, req: IncomingMessage, res: ServerResponse): Promise<void> {
270
+ const raw = await readRawBody(req);
271
+ const headerHmac = req.headers['x-shopify-hmac-sha256'];
272
+ const hmac = Array.isArray(headerHmac) ? headerHmac[0] : headerHmac;
273
+
274
+ if (!verifyWebhook(cfg.shopifyApiSecret, hmac, raw)) {
275
+ log({ msg: 'webhook: signature verification failed' });
276
+ return json(res, 401, { error: 'invalid signature' });
277
+ }
278
+
279
+ const topic = String(req.headers['x-shopify-topic'] ?? '');
280
+ const shop = String(req.headers['x-shopify-shop-domain'] ?? '');
281
+ let body: any;
282
+ try {
283
+ body = JSON.parse(raw);
284
+ } catch {
285
+ return json(res, 400, { error: 'invalid json' });
286
+ }
287
+
288
+ // Acknowledge immediately; do the work after (Shopify retries slow endpoints).
289
+ json(res, 200, { ok: true });
290
+ void handleWebhookAction(cfg, routeWebhook(topic, shop, body));
291
+ }
292
+
293
+ // createHandler is the request router. Every route is a small handler over the
294
+ // pure modules. Exported so a test can drive it with a mock req/res if desired.
295
+ export function createHandler(cfg: ServerConfig) {
296
+ return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
297
+ const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
298
+ try {
299
+ if (req.method === 'GET' && url.pathname === '/oauth/install') return handleInstall(cfg, url, res);
300
+ if (req.method === 'GET' && url.pathname === '/oauth/callback') return await handleOAuthCallback(cfg, url, res);
301
+ if (req.method === 'GET' && url.pathname === '/v1/product') return await handleGetProduct(url, res);
302
+ if (req.method === 'GET' && url.pathname === '/v1/order') return await handleGetOrder(url, res);
303
+ if (req.method === 'GET' && url.pathname === '/v1/models') return await handleModels(cfg, url, res);
304
+ if (req.method === 'POST' && url.pathname === '/webhooks') return await handleWebhook(cfg, req, res);
305
+ if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
306
+
307
+ if (req.method === 'POST') {
308
+ const raw = await readRawBody(req);
309
+ let body: any = {};
310
+ if (raw) {
311
+ try {
312
+ body = JSON.parse(raw);
313
+ } catch {
314
+ return json(res, 400, { error: 'invalid json' });
315
+ }
316
+ }
317
+ if (url.pathname === '/v1/product/action') return await handleProductAction(cfg, body, res);
318
+ if (url.pathname === '/v1/order/action') return await handleOrderAction(cfg, body, res);
319
+ if (url.pathname === '/v1/product/update') return await handleProductUpdate(body, res);
320
+ }
321
+
322
+ return json(res, 404, { error: 'not found' });
323
+ } catch (e: any) {
324
+ const msg = e?.message || 'error';
325
+ // Boundary errors (bad input, un-installed shop) are 400; the rest 500.
326
+ const status = /invalid|missing|not installed|unknown|not found/i.test(msg) ? 400 : 500;
327
+ log({ msg: 'request error', path: url.pathname, error: msg, status });
328
+ return json(res, status, { error: msg });
329
+ }
330
+ };
331
+ }
332
+
333
+ // main boots the server when run directly. Import-safe: only the direct entry
334
+ // listens, so tests import the handlers without opening a port.
335
+ export function main(): void {
336
+ const cfg = readServerConfig(process.env);
337
+ const server = createServer(createHandler(cfg));
338
+ server.listen(cfg.port, () => {
339
+ log({ msg: 'hanzo shopify service up', port: cfg.port, ai: !!cfg.hanzoApiKey });
340
+ });
341
+ }
342
+
343
+ if (process.argv[1] && process.argv[1].endsWith('server.js')) {
344
+ main();
345
+ }
@@ -0,0 +1,278 @@
1
+ // Shopify Admin GraphQL API — pure request shaping + response parsing. Every
2
+ // function returns a PreparedGraphql (url + headers + JSON body) or parses a
3
+ // response body; none opens a socket, so the whole API surface is unit-testable
4
+ // without a network. server.ts is the thin glue that fetches these shapes with a
5
+ // shop's offline access token.
6
+ //
7
+ // The endpoint is `https://{shop}/admin/api/{version}/graphql.json` (see
8
+ // config.adminGraphqlUrl). Authorization is the per-shop token in the
9
+ // X-Shopify-Access-Token header (NOT a Bearer). Docs:
10
+ // shopify.dev/docs/api/admin-graphql
11
+
12
+ import { adminGraphqlUrl } from '../config.js';
13
+
14
+ // A prepared GraphQL POST: the pieces a single fetch needs. The access token
15
+ // rides in the X-Shopify-Access-Token header, per Shopify's Admin API.
16
+ export interface PreparedGraphql {
17
+ url: string;
18
+ headers: Record<string, string>;
19
+ body: string;
20
+ }
21
+
22
+ // graphql builds a PreparedGraphql from a query + variables against a shop.
23
+ // One place assembles the token header + JSON body so every call is identical.
24
+ export function graphql(
25
+ shop: string,
26
+ accessToken: string,
27
+ query: string,
28
+ variables: Record<string, unknown>,
29
+ ): PreparedGraphql {
30
+ return {
31
+ url: adminGraphqlUrl(shop),
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ 'X-Shopify-Access-Token': accessToken,
35
+ },
36
+ body: JSON.stringify({ query, variables }),
37
+ };
38
+ }
39
+
40
+ // ---- Queries / mutations --------------------------------------------------
41
+
42
+ // PRODUCT_QUERY reads the fields the AI needs to write content: title, current
43
+ // description (as body HTML + plain text), product type, vendor, tags, the SEO
44
+ // title/description, and the first handful of variants for price/option context.
45
+ // We ask for exactly what the prompt uses — no over-fetching.
46
+ export const PRODUCT_QUERY = /* GraphQL */ `
47
+ query ProductForAI($id: ID!) {
48
+ product(id: $id) {
49
+ id
50
+ title
51
+ handle
52
+ descriptionHtml
53
+ description
54
+ productType
55
+ vendor
56
+ tags
57
+ status
58
+ seo { title description }
59
+ options { name values }
60
+ variants(first: 10) {
61
+ nodes { title sku price }
62
+ }
63
+ }
64
+ }
65
+ `;
66
+
67
+ // getProduct — fetch one product by its GID (gid://shopify/Product/123).
68
+ export function getProduct(shop: string, accessToken: string, id: string): PreparedGraphql {
69
+ return graphql(shop, accessToken, PRODUCT_QUERY, { id });
70
+ }
71
+
72
+ // PRODUCT_UPDATE_MUTATION writes AI-generated content back: the description HTML
73
+ // and/or the SEO title/description. `input` is Shopify's ProductInput; we only
74
+ // ever send the fields we changed. userErrors is requested so a validation
75
+ // failure surfaces as data, not a thrown 200.
76
+ export const PRODUCT_UPDATE_MUTATION = /* GraphQL */ `
77
+ mutation UpdateProduct($input: ProductInput!) {
78
+ productUpdate(input: $input) {
79
+ product { id title descriptionHtml seo { title description } }
80
+ userErrors { field message }
81
+ }
82
+ }
83
+ `;
84
+
85
+ // The write-back fields the AI produces. Any subset may be present — we send
86
+ // only what is set, so a "rewrite description" never clobbers the SEO title.
87
+ export interface ProductWriteback {
88
+ id: string;
89
+ descriptionHtml?: string;
90
+ seoTitle?: string;
91
+ seoDescription?: string;
92
+ }
93
+
94
+ // buildProductInput turns a ProductWriteback into Shopify's ProductInput shape,
95
+ // including `seo` only when a title or description is set. Pure so the exact
96
+ // mutation variables are asserted in a test. Throws on a missing id (the mutation
97
+ // cannot target a product without it) — a boundary error surfaced to the caller.
98
+ export function buildProductInput(w: ProductWriteback): Record<string, unknown> {
99
+ if (!w.id) throw new Error('productUpdate requires a product id');
100
+ const input: Record<string, unknown> = { id: w.id };
101
+ if (w.descriptionHtml !== undefined) input.descriptionHtml = w.descriptionHtml;
102
+ if (w.seoTitle !== undefined || w.seoDescription !== undefined) {
103
+ const seo: Record<string, unknown> = {};
104
+ if (w.seoTitle !== undefined) seo.title = w.seoTitle;
105
+ if (w.seoDescription !== undefined) seo.description = w.seoDescription;
106
+ input.seo = seo;
107
+ }
108
+ return input;
109
+ }
110
+
111
+ // updateProduct — the productUpdate mutation with the built input.
112
+ export function updateProduct(shop: string, accessToken: string, w: ProductWriteback): PreparedGraphql {
113
+ return graphql(shop, accessToken, PRODUCT_UPDATE_MUTATION, { input: buildProductInput(w) });
114
+ }
115
+
116
+ // ORDER_QUERY reads the fields the AI needs to summarize an order and draft a
117
+ // customer reply: name/number, financial + fulfillment status, totals, the
118
+ // customer, line items, the shipping address, and the internal note. Enough to
119
+ // answer "what is this order" and "draft a reply" without a second round trip.
120
+ export const ORDER_QUERY = /* GraphQL */ `
121
+ query OrderForAI($id: ID!) {
122
+ order(id: $id) {
123
+ id
124
+ name
125
+ note
126
+ email
127
+ displayFinancialStatus
128
+ displayFulfillmentStatus
129
+ createdAt
130
+ totalPriceSet { shopMoney { amount currencyCode } }
131
+ customer { firstName lastName email }
132
+ shippingAddress { city province country }
133
+ lineItems(first: 50) {
134
+ nodes { title quantity sku }
135
+ }
136
+ }
137
+ }
138
+ `;
139
+
140
+ // getOrder — fetch one order by its GID (gid://shopify/Order/123).
141
+ export function getOrder(shop: string, accessToken: string, id: string): PreparedGraphql {
142
+ return graphql(shop, accessToken, ORDER_QUERY, { id });
143
+ }
144
+
145
+ // ---- Response parsing -----------------------------------------------------
146
+
147
+ // The normalized product our prompt assembly consumes. Snake/camel from the wire
148
+ // is normalized here so nothing downstream touches the GraphQL shape.
149
+ export interface ProductInfo {
150
+ id: string;
151
+ title: string;
152
+ handle: string;
153
+ descriptionHtml: string;
154
+ description: string;
155
+ productType: string;
156
+ vendor: string;
157
+ tags: string[];
158
+ status: string;
159
+ seoTitle: string;
160
+ seoDescription: string;
161
+ options: Array<{ name: string; values: string[] }>;
162
+ variants: Array<{ title: string; sku: string; price: string }>;
163
+ }
164
+
165
+ // graphqlErrors extracts the top-level `errors` array a GraphQL endpoint returns
166
+ // on a bad query (distinct from userErrors on a mutation). Returns the joined
167
+ // messages, or '' when there are none. Pure.
168
+ export function graphqlErrors(data: any): string {
169
+ const errs = data?.errors;
170
+ if (!Array.isArray(errs) || errs.length === 0) return '';
171
+ return errs.map((e: any) => String(e?.message ?? e)).join('; ');
172
+ }
173
+
174
+ // parseProduct reads a getProduct response into a ProductInfo. Throws on a
175
+ // GraphQL error or a null product (a bad id) so the caller learns immediately.
176
+ export function parseProduct(data: any): ProductInfo {
177
+ const err = graphqlErrors(data);
178
+ if (err) throw new Error(`Shopify GraphQL error: ${err}`);
179
+ const p = data?.data?.product;
180
+ if (!p) throw new Error('product not found');
181
+ return {
182
+ id: String(p.id ?? ''),
183
+ title: String(p.title ?? ''),
184
+ handle: String(p.handle ?? ''),
185
+ descriptionHtml: String(p.descriptionHtml ?? ''),
186
+ description: String(p.description ?? ''),
187
+ productType: String(p.productType ?? ''),
188
+ vendor: String(p.vendor ?? ''),
189
+ tags: Array.isArray(p.tags) ? p.tags.map(String) : [],
190
+ status: String(p.status ?? ''),
191
+ seoTitle: String(p.seo?.title ?? ''),
192
+ seoDescription: String(p.seo?.description ?? ''),
193
+ options: Array.isArray(p.options)
194
+ ? p.options.map((o: any) => ({
195
+ name: String(o?.name ?? ''),
196
+ values: Array.isArray(o?.values) ? o.values.map(String) : [],
197
+ }))
198
+ : [],
199
+ variants: Array.isArray(p.variants?.nodes)
200
+ ? p.variants.nodes.map((v: any) => ({
201
+ title: String(v?.title ?? ''),
202
+ sku: String(v?.sku ?? ''),
203
+ price: String(v?.price ?? ''),
204
+ }))
205
+ : [],
206
+ };
207
+ }
208
+
209
+ // The normalized order our prompt assembly + summary consume.
210
+ export interface OrderInfo {
211
+ id: string;
212
+ name: string;
213
+ note: string;
214
+ email: string;
215
+ financialStatus: string;
216
+ fulfillmentStatus: string;
217
+ createdAt: string;
218
+ totalAmount: string;
219
+ currency: string;
220
+ customerName: string;
221
+ shipTo: string;
222
+ lineItems: Array<{ title: string; quantity: number; sku: string }>;
223
+ }
224
+
225
+ // parseOrder reads a getOrder response into an OrderInfo. Throws on a GraphQL
226
+ // error or a null order.
227
+ export function parseOrder(data: any): OrderInfo {
228
+ const err = graphqlErrors(data);
229
+ if (err) throw new Error(`Shopify GraphQL error: ${err}`);
230
+ const o = data?.data?.order;
231
+ if (!o) throw new Error('order not found');
232
+ const money = o.totalPriceSet?.shopMoney ?? {};
233
+ const cust = o.customer ?? {};
234
+ const addr = o.shippingAddress ?? {};
235
+ const customerName = [cust.firstName, cust.lastName].filter(Boolean).map(String).join(' ').trim();
236
+ const shipTo = [addr.city, addr.province, addr.country].filter(Boolean).map(String).join(', ');
237
+ return {
238
+ id: String(o.id ?? ''),
239
+ name: String(o.name ?? ''),
240
+ note: String(o.note ?? ''),
241
+ email: String(o.email ?? cust.email ?? ''),
242
+ financialStatus: String(o.displayFinancialStatus ?? ''),
243
+ fulfillmentStatus: String(o.displayFulfillmentStatus ?? ''),
244
+ createdAt: String(o.createdAt ?? ''),
245
+ totalAmount: String(money.amount ?? ''),
246
+ currency: String(money.currencyCode ?? ''),
247
+ customerName,
248
+ shipTo,
249
+ lineItems: Array.isArray(o.lineItems?.nodes)
250
+ ? o.lineItems.nodes.map((li: any) => ({
251
+ title: String(li?.title ?? ''),
252
+ quantity: Number(li?.quantity ?? 0) || 0,
253
+ sku: String(li?.sku ?? ''),
254
+ }))
255
+ : [],
256
+ };
257
+ }
258
+
259
+ // parseProductUpdate reads a productUpdate response, surfacing userErrors (a
260
+ // validation failure Shopify returns inside a 200) as a thrown error so the
261
+ // write-back path has ONE way to fail: reject. Returns the updated product's id
262
+ // + title on success. Pure.
263
+ export function parseProductUpdate(data: any): { id: string; title: string } {
264
+ const err = graphqlErrors(data);
265
+ if (err) throw new Error(`Shopify GraphQL error: ${err}`);
266
+ const result = data?.data?.productUpdate;
267
+ if (!result) throw new Error('productUpdate returned no result');
268
+ const userErrors = Array.isArray(result.userErrors) ? result.userErrors : [];
269
+ if (userErrors.length > 0) {
270
+ const msg = userErrors
271
+ .map((e: any) => `${Array.isArray(e?.field) ? e.field.join('.') : e?.field ?? ''}: ${e?.message ?? ''}`.trim())
272
+ .join('; ');
273
+ throw new Error(`productUpdate failed: ${msg}`);
274
+ }
275
+ const product = result.product;
276
+ if (!product?.id) throw new Error('productUpdate returned no product');
277
+ return { id: String(product.id), title: String(product.title ?? '') };
278
+ }