@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.
- package/LICENSE +12 -0
- package/README.md +172 -0
- package/dist/app.css +2 -0
- package/dist/app.css.map +7 -0
- package/dist/app.js +56 -0
- package/dist/app.js.map +7 -0
- package/dist/index.html +22 -0
- package/dist/server.js +56 -0
- package/dist/server.js.map +7 -0
- package/package.json +66 -0
- package/src/app/App.tsx +37 -0
- package/src/app/Assistant.tsx +330 -0
- package/src/app/api.ts +126 -0
- package/src/app/context.ts +54 -0
- package/src/app/index.html +21 -0
- package/src/app/main.tsx +51 -0
- package/src/app/session-fetch.ts +31 -0
- package/src/config.ts +122 -0
- package/src/server/actions.ts +122 -0
- package/src/server/hanzo.ts +222 -0
- package/src/server/oauth.ts +165 -0
- package/src/server/server.ts +345 -0
- package/src/server/shopify-api.ts +278 -0
- package/src/server/webhooks.ts +90 -0
|
@@ -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
|
+
}
|