@joy-loyalty/mcp 0.3.7 → 0.3.8
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/build/config.js +15 -1
- package/build/index.js +2802 -164
- package/package.json +2 -2
package/build/index.js
CHANGED
|
@@ -53,9 +53,19 @@ function loadConfig(env = process.env) {
|
|
|
53
53
|
}
|
|
54
54
|
const mode = privateBotKey ? "internal-bot" : "merchant";
|
|
55
55
|
const writesEnabled = mode === "merchant" && env.JOY_MCP_ENABLE_WRITES === "true";
|
|
56
|
+
const configWritesEnabled = writesEnabled && env.JOY_MCP_ENABLE_CONFIG_WRITES === "true";
|
|
56
57
|
const rawThreshold = Number(env.JOY_MCP_DEDUCT_DRYRUN_THRESHOLD ?? "1000");
|
|
57
58
|
const deductDryRunThreshold = Number.isFinite(rawThreshold) && rawThreshold >= 0 ? rawThreshold : 1e3;
|
|
58
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
apiBaseUrl,
|
|
61
|
+
appKey,
|
|
62
|
+
secretKey,
|
|
63
|
+
privateBotKey,
|
|
64
|
+
mode,
|
|
65
|
+
writesEnabled,
|
|
66
|
+
configWritesEnabled,
|
|
67
|
+
deductDryRunThreshold
|
|
68
|
+
};
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
// ../mcp-core/build/client/joyApi.js
|
|
@@ -71,7 +81,7 @@ var JoyApiError = class extends Error {
|
|
|
71
81
|
this.name = "JoyApiError";
|
|
72
82
|
}
|
|
73
83
|
};
|
|
74
|
-
var DEFAULT_TIMEOUT_MS =
|
|
84
|
+
var DEFAULT_TIMEOUT_MS = 2e4;
|
|
75
85
|
function extractServerMessage(body) {
|
|
76
86
|
if (!body || typeof body !== "object")
|
|
77
87
|
return void 0;
|
|
@@ -112,11 +122,24 @@ function classifyError(status, serverMessage, retryAfter, credentialHint) {
|
|
|
112
122
|
}
|
|
113
123
|
return new JoyApiError("forbidden", `This operation is not permitted with the current credential (read-only key or insufficient scope).${detail} Do NOT retry with the same credential.`, status);
|
|
114
124
|
}
|
|
125
|
+
if (status === 402) {
|
|
126
|
+
return new JoyApiError("plan_gate", `The shop's plan does not include REST API or MCP access.${detail} Do NOT retry \u2014 tell the merchant to upgrade their Joy plan; the response's upgrade_url links straight to their subscription page.`, status);
|
|
127
|
+
}
|
|
115
128
|
if (status === 429) {
|
|
116
129
|
const wait = retryAfter ? `${retryAfter} seconds` : "~10 seconds";
|
|
117
|
-
return new JoyApiError(
|
|
130
|
+
return new JoyApiError(
|
|
131
|
+
"rate_limit",
|
|
132
|
+
// No hard-coded ceiling here: the limit is per-plan (and there are two of them, a burst
|
|
133
|
+
// window and a daily quota), so any number written into this string is wrong for most
|
|
134
|
+
// shops. The server states the real one in `detail`, and Retry-After carries the wait.
|
|
135
|
+
`Rate limited by the Joy API.${detail} Wait ${wait} before the next call, and prefer fewer, more specific queries (e.g. filter by email instead of listing pages). Do NOT retry immediately.`,
|
|
136
|
+
status
|
|
137
|
+
);
|
|
118
138
|
}
|
|
119
139
|
if (status === 404) {
|
|
140
|
+
if (!serverMessage) {
|
|
141
|
+
return new JoyApiError("not_found", "This endpoint does not exist on the Joy backend this shop is connected to. Nothing is wrong with the arguments and there is no identifier to re-check \u2014 the shop is on a deployment older than this tool. Do NOT retry, do NOT try a lookup tool, and do NOT substitute data from another tool. Tell the merchant this feature is unavailable on their current Joy version.", status);
|
|
142
|
+
}
|
|
120
143
|
return new JoyApiError("not_found", `Not found.${detail} Verify the identifier and try a lookup tool first (e.g. joy_find_member) instead of guessing IDs.`, status);
|
|
121
144
|
}
|
|
122
145
|
if (status === 400 || status === 422) {
|
|
@@ -184,7 +207,7 @@ var JoyApi = class {
|
|
|
184
207
|
});
|
|
185
208
|
} catch (error) {
|
|
186
209
|
if (error instanceof Error && error.name === "TimeoutError") {
|
|
187
|
-
throw new JoyApiError("timeout", `The Joy API did not respond within ${Math.round(timeoutMs / 1e3)}s. Safe to retry ONCE; if it times out again, report the outage instead of retrying further.`);
|
|
210
|
+
throw new JoyApiError("timeout", `The Joy API did not respond within ${Math.round(timeoutMs / 1e3)}s. The first call after an idle period pays a Cloud Functions cold start, so a lone timeout is usually transient rather than an outage. Safe to retry ONCE; if it times out again, report the outage instead of retrying further.`);
|
|
188
211
|
}
|
|
189
212
|
throw new JoyApiError("network", `Could not reach the Joy API at ${this.config.apiBaseUrl}. Check network connectivity and JOY_API_BASE_URL. Safe to retry ONCE.`);
|
|
190
213
|
}
|
|
@@ -235,6 +258,9 @@ var TtlCache = class {
|
|
|
235
258
|
};
|
|
236
259
|
|
|
237
260
|
// ../mcp-core/build/tools/members.js
|
|
261
|
+
import { z as z2 } from "zod";
|
|
262
|
+
|
|
263
|
+
// ../mcp-core/build/toolkit.js
|
|
238
264
|
import { z } from "zod";
|
|
239
265
|
|
|
240
266
|
// ../mcp-core/build/logger.js
|
|
@@ -278,6 +304,38 @@ function registerReadTool(server, spec) {
|
|
|
278
304
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
279
305
|
}, callback);
|
|
280
306
|
}
|
|
307
|
+
function registerConfigWriteTool(server, ctx, spec) {
|
|
308
|
+
const callback = (async (args) => {
|
|
309
|
+
if (!ctx.config.configWritesEnabled) {
|
|
310
|
+
return {
|
|
311
|
+
isError: true,
|
|
312
|
+
content: [
|
|
313
|
+
{
|
|
314
|
+
type: "text",
|
|
315
|
+
text: `${spec.name} is disabled: this server cannot change shop configuration. The merchant must set BOTH JOY_MCP_ENABLE_WRITES="true" and JOY_MCP_ENABLE_CONFIG_WRITES="true" (quoted strings, with their secret key) in the MCP config. Do not retry until they have \u2014 and do not fall back to a member-level write tool to approximate a configuration change.`
|
|
316
|
+
}
|
|
317
|
+
]
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return runHandler(spec.name, () => spec.handler(args ?? {}));
|
|
321
|
+
});
|
|
322
|
+
const registration = {
|
|
323
|
+
title: spec.title,
|
|
324
|
+
description: spec.description,
|
|
325
|
+
annotations: {
|
|
326
|
+
readOnlyHint: false,
|
|
327
|
+
destructiveHint: spec.destructiveHint,
|
|
328
|
+
idempotentHint: false,
|
|
329
|
+
openWorldHint: false
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
if (spec.strictArguments) {
|
|
333
|
+
const inputSchema = z.object(spec.inputSchema).strict();
|
|
334
|
+
server.registerTool(spec.name, { ...registration, inputSchema }, callback);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
server.registerTool(spec.name, { ...registration, inputSchema: spec.inputSchema }, callback);
|
|
338
|
+
}
|
|
281
339
|
function registerWriteTool(server, ctx, spec) {
|
|
282
340
|
const callback = (async (args) => {
|
|
283
341
|
if (!ctx.config.writesEnabled) {
|
|
@@ -415,8 +473,8 @@ var REWARD_KEYS = [
|
|
|
415
473
|
"createdAt"
|
|
416
474
|
];
|
|
417
475
|
var memberIdentifierSchema = {
|
|
418
|
-
email:
|
|
419
|
-
shopify_customer_id:
|
|
476
|
+
email: z2.string().optional().describe("Member email. Matched exactly after lowercasing/trimming \u2014 typos return no result."),
|
|
477
|
+
shopify_customer_id: z2.string().optional().describe('Shopify customer ID (numeric string), e.g. "7348912345".')
|
|
420
478
|
};
|
|
421
479
|
function normalizeEmail(email) {
|
|
422
480
|
return email.trim().toLowerCase();
|
|
@@ -501,12 +559,12 @@ function registerMemberTools(server, ctx) {
|
|
|
501
559
|
title: "List loyalty members",
|
|
502
560
|
description: 'List loyalty members a page at a time, with sorting and an optional VIP-tier filter. Default order is most RECENT ACTIVITY (updatedAt desc). For ranking questions use sort_by \u2014 "which customer has the most points?" / "top 10 members" is ONE call with sort_by "points" (the first page IS the leaderboard; never page through the whole list for this). "How many customers/members does the shop have?" is ONE call with include_total_count true and limit 1 \u2014 never sum tier counts or page the list for this. sort_by "created_at" answers newest/oldest signups; tier_id narrows any of these to one tier. To inspect ONE known customer, prefer joy_find_member (cheaper and more precise). Returns up to `limit` members plus `has_more` and `next_after`; pass `next_after` back as `after` for the next page. Do not page through the whole list to answer aggregate questions \u2014 that burns the shop\'s API rate limit.',
|
|
503
561
|
inputSchema: {
|
|
504
|
-
limit:
|
|
505
|
-
sort_by:
|
|
506
|
-
direction:
|
|
507
|
-
tier_id:
|
|
508
|
-
include_total_count:
|
|
509
|
-
after:
|
|
562
|
+
limit: z2.number().int().min(1).max(100).optional().describe("Members per page (default 20, max 100)."),
|
|
563
|
+
sort_by: z2.enum(["points", "tier_points", "created_at", "recent_activity"]).optional().describe('Sort field (default recent_activity). "points" = current spendable balance (use for top/bottom members by points); "tier_points" = VIP tier progress points; "created_at" = signup date; "recent_activity" = last update.'),
|
|
564
|
+
direction: z2.enum(["desc", "asc"]).optional().describe('"desc" (default) = highest/newest first; "asc" = lowest/oldest first.'),
|
|
565
|
+
tier_id: z2.string().optional().describe("Only members currently in this VIP tier. Must be a tier `id` from joy_list_tiers, NOT the tier name."),
|
|
566
|
+
include_total_count: z2.boolean().optional().describe("Set true when the merchant asks HOW MANY members/customers there are \u2014 returns total_members (exact count of the whole list, respecting tier_id). Combine with limit 1 when only the number is needed. Costs extra database reads; leave unset otherwise."),
|
|
567
|
+
after: z2.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one, and only reuse it with the SAME sort_by/direction/tier_id that produced it.")
|
|
510
568
|
},
|
|
511
569
|
handler: async (args) => {
|
|
512
570
|
const limit = args.limit ?? 20;
|
|
@@ -563,8 +621,8 @@ function registerMemberTools(server, ctx) {
|
|
|
563
621
|
description: 'Point transactions (earned, spent, adjusted, refund deductions\u2026) for ONE member, most recent first. Use to answer "why is this customer\'s balance X?" or to audit changes. Each entry shows the point change, balance before/after (oldPoint \u2192 newPoint), the source system, and any notes. Returns `next_after` when more history exists \u2014 pass it back as `after` to keep auditing.',
|
|
564
622
|
inputSchema: {
|
|
565
623
|
...memberIdentifierSchema,
|
|
566
|
-
limit:
|
|
567
|
-
after:
|
|
624
|
+
limit: z2.number().int().min(1).max(50).optional().describe("Transactions per page (default 10, max 50)."),
|
|
625
|
+
after: z2.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one.")
|
|
568
626
|
},
|
|
569
627
|
handler: async (args) => {
|
|
570
628
|
const limit = args.limit ?? 10;
|
|
@@ -600,8 +658,8 @@ function registerMemberTools(server, ctx) {
|
|
|
600
658
|
description: `Coupons and rewards ONE member has already redeemed \u2014 code, discount program, status (active/used/expired), expiry. This is the member's OWN history, NOT the shop's catalog of what CAN be redeemed (use joy_list_redemption_rules for that). Use to answer "what coupons does this customer have?" or "did they already use that discount code?".`,
|
|
601
659
|
inputSchema: {
|
|
602
660
|
...memberIdentifierSchema,
|
|
603
|
-
limit:
|
|
604
|
-
after:
|
|
661
|
+
limit: z2.number().int().min(1).max(50).optional().describe("Rewards per page (default 10, max 50)."),
|
|
662
|
+
after: z2.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one.")
|
|
605
663
|
},
|
|
606
664
|
handler: async (args) => {
|
|
607
665
|
const limit = args.limit ?? 10;
|
|
@@ -634,7 +692,7 @@ function registerMemberTools(server, ctx) {
|
|
|
634
692
|
}
|
|
635
693
|
|
|
636
694
|
// ../mcp-core/build/tools/tiers.js
|
|
637
|
-
import { z as
|
|
695
|
+
import { z as z3 } from "zod";
|
|
638
696
|
var TIER_KEYS = [
|
|
639
697
|
"id",
|
|
640
698
|
"name",
|
|
@@ -662,7 +720,7 @@ function registerTierTools(server, ctx) {
|
|
|
662
720
|
title: "List VIP tiers",
|
|
663
721
|
description: 'List the shop\'s VIP tiers with thresholds (targetPoint/targetAmount), earning multipliers, and per-tier benefits (tierRewards). Use to understand the tier ladder before answering tier questions. For "how many customers are in tier X?" set include_member_counts \u2014 it returns memberCount (live count of current members) per tier; the plain `members` field is a stale cached snapshot, NEVER quote it as the member count. Config-only calls are cached ~15 minutes \u2014 one call per conversation is enough.',
|
|
664
722
|
inputSchema: {
|
|
665
|
-
include_member_counts:
|
|
723
|
+
include_member_counts: z3.boolean().optional().describe("Set true ONLY when the merchant asks how many customers/members are in tiers \u2014 adds memberCount (live count) per tier. Leave unset for tier-config questions; counting costs extra database reads.")
|
|
666
724
|
},
|
|
667
725
|
handler: async (args) => {
|
|
668
726
|
if (args.include_member_counts === true) {
|
|
@@ -691,9 +749,9 @@ function registerTierWriteTools(server, ctx) {
|
|
|
691
749
|
description: "WRITE \u2014 manually moves ONE member to a VIP tier (may trigger tier rewards for the member). Find valid tier IDs via joy_list_tiers first. Setting the tier the member already has is a safe no-op. admin_note is recorded on the tier-change activity.",
|
|
692
750
|
inputSchema: {
|
|
693
751
|
...memberIdentifierSchema,
|
|
694
|
-
tier_id:
|
|
695
|
-
reason:
|
|
696
|
-
admin_note:
|
|
752
|
+
tier_id: z3.string().describe("Target tier ID (from joy_list_tiers)."),
|
|
753
|
+
reason: z3.string().min(3).describe("Why the tier is being changed \u2014 REQUIRED, recorded on the tier-change activity."),
|
|
754
|
+
admin_note: z3.string().optional().describe("Optional extra note for the merchant team.")
|
|
697
755
|
},
|
|
698
756
|
destructiveHint: true,
|
|
699
757
|
idempotentHint: true,
|
|
@@ -719,7 +777,7 @@ function registerTierWriteTools(server, ctx) {
|
|
|
719
777
|
}
|
|
720
778
|
|
|
721
779
|
// ../mcp-core/build/tools/programs.js
|
|
722
|
-
import { z as
|
|
780
|
+
import { z as z4 } from "zod";
|
|
723
781
|
var EARNING_KEYS = [
|
|
724
782
|
"id",
|
|
725
783
|
"event",
|
|
@@ -785,7 +843,7 @@ function registerProgramTools(server, ctx) {
|
|
|
785
843
|
title: "List point-redemption rules",
|
|
786
844
|
description: 'List the ways customers SPEND points (discount amount, percentage off, free shipping, free gift\u2026), including point costs (spendPoint) and min/max limits. Use to answer "what can customers redeem?" and to find the programId for a redemption. For USAGE questions \u2014 "which reward is redeemed most?", "how many times was program X redeemed?" \u2014 set include_redeem_counts to get redeemCount (all-time REDEMPTIONS, not unique customers) per rule in the same call. Redeeming itself is a separate write operation, not available through this tool.',
|
|
787
845
|
inputSchema: {
|
|
788
|
-
include_redeem_counts:
|
|
846
|
+
include_redeem_counts: z4.boolean().optional().describe("Set true ONLY when the merchant asks about usage/popularity \u2014 adds redeemCount (all-time number of redemptions) to each rule. Leave unset for pure configuration questions; counting costs extra database reads.")
|
|
789
847
|
},
|
|
790
848
|
handler: async (args) => {
|
|
791
849
|
const withCounts = args.include_redeem_counts === true;
|
|
@@ -809,7 +867,102 @@ function registerProgramTools(server, ctx) {
|
|
|
809
867
|
}
|
|
810
868
|
|
|
811
869
|
// ../mcp-core/build/tools/referrals.js
|
|
812
|
-
import { z as
|
|
870
|
+
import { z as z5 } from "zod";
|
|
871
|
+
|
|
872
|
+
// ../mcp-core/build/tools/configSupport.js
|
|
873
|
+
function parseShopifyProductUrl(productUrl) {
|
|
874
|
+
try {
|
|
875
|
+
const url = new URL(productUrl);
|
|
876
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
877
|
+
return { ok: false, error: "Product URLs must start with http:// or https://." };
|
|
878
|
+
}
|
|
879
|
+
const path = url.pathname.split("/").filter(Boolean);
|
|
880
|
+
const directHandle = path.length === 2 && path[0] === "products" ? path[1] : void 0;
|
|
881
|
+
const collectionHandle = path.length === 4 && path[0] === "collections" && path[2] === "products" ? path[3] : void 0;
|
|
882
|
+
const handle = directHandle ?? collectionHandle;
|
|
883
|
+
if (!handle) {
|
|
884
|
+
return {
|
|
885
|
+
ok: false,
|
|
886
|
+
error: "Use a Shopify product URL in the form https://shop.myshopify.com/products/<handle> or https://shop.myshopify.com/collections/<collection>/products/<handle>."
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
return { ok: true, handle: decodeURIComponent(handle) };
|
|
890
|
+
} catch {
|
|
891
|
+
return {
|
|
892
|
+
ok: false,
|
|
893
|
+
error: "Use a Shopify product URL in the form https://shop.myshopify.com/products/<handle> or https://shop.myshopify.com/collections/<collection>/products/<handle>."
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
function isSameValue(a, b) {
|
|
898
|
+
if (a === b)
|
|
899
|
+
return true;
|
|
900
|
+
if (typeof a === "number" && typeof b === "string")
|
|
901
|
+
return String(a) === b;
|
|
902
|
+
if (typeof a === "string" && typeof b === "number")
|
|
903
|
+
return a === String(b);
|
|
904
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
905
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
906
|
+
return false;
|
|
907
|
+
}
|
|
908
|
+
function diffFields(current, incoming) {
|
|
909
|
+
const changes = [];
|
|
910
|
+
for (const [field, to] of Object.entries(incoming)) {
|
|
911
|
+
if (to === void 0)
|
|
912
|
+
continue;
|
|
913
|
+
const from = current[field];
|
|
914
|
+
if (isSameValue(from, to))
|
|
915
|
+
continue;
|
|
916
|
+
changes.push({ field, from, to });
|
|
917
|
+
}
|
|
918
|
+
return changes;
|
|
919
|
+
}
|
|
920
|
+
function droppedFields(incoming, allowedKeys) {
|
|
921
|
+
const allowed = new Set(allowedKeys);
|
|
922
|
+
return Object.entries(incoming).filter(([field, value]) => value !== void 0 && !allowed.has(field)).map(([field]) => field);
|
|
923
|
+
}
|
|
924
|
+
function findDuplicate(existing, candidate) {
|
|
925
|
+
const normalise = (value) => String(value ?? "").trim().toLowerCase();
|
|
926
|
+
const event = normalise(candidate.event);
|
|
927
|
+
const title = normalise(candidate.title);
|
|
928
|
+
return existing.find((item) => {
|
|
929
|
+
if (event && normalise(item.event) !== event)
|
|
930
|
+
return false;
|
|
931
|
+
if (title)
|
|
932
|
+
return normalise(item.title) === title;
|
|
933
|
+
return Boolean(event);
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
var formatValue = (value) => value === void 0 || value === null || value === "" ? "(unset)" : JSON.stringify(value);
|
|
937
|
+
function buildChangePreview(input) {
|
|
938
|
+
const lines = [];
|
|
939
|
+
lines.push(`DRY RUN \u2014 nothing has been changed yet.`);
|
|
940
|
+
lines.push(`Would ${input.action} ${input.target}.`);
|
|
941
|
+
lines.push(`Reason given: ${input.reason}`);
|
|
942
|
+
if (input.duplicate) {
|
|
943
|
+
lines.push(`\u26A0\uFE0F A matching rule already exists (id ${String(input.duplicate.id)}, "${String(input.duplicate.title)}"). Creating this would leave TWO live rules for the same event \u2014 configuration writes are not idempotent, so this is not a replay. Update the existing one by passing its id instead, unless the merchant explicitly wants a second rule.`);
|
|
944
|
+
}
|
|
945
|
+
if (input.changes.length === 0) {
|
|
946
|
+
lines.push("No field would change \u2014 this call is a no-op. Do not report it as a change.");
|
|
947
|
+
} else {
|
|
948
|
+
lines.push("", "Changes:");
|
|
949
|
+
for (const change of input.changes) {
|
|
950
|
+
lines.push(` ${change.field}: ${formatValue(change.from)} \u2192 ${formatValue(change.to)}`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (input.dropped && input.dropped.length > 0) {
|
|
954
|
+
lines.push("", `\u26A0\uFE0F These fields would be SILENTLY DISCARDED by the backend (not valid for this record type): ${input.dropped.join(", ")}. The call would still return success. Remove them or use the right tool \u2014 do not report them as applied.`);
|
|
955
|
+
}
|
|
956
|
+
for (const warning of input.warnings ?? [])
|
|
957
|
+
lines.push("", `\u26A0\uFE0F ${warning}`);
|
|
958
|
+
lines.push("", "This affects every customer, not one member, and takes effect as soon as it saves. Show this to the merchant and get an explicit yes; then call again with apply: true and the same arguments. Joy does not record who changed configuration or why, so this preview is the only audit trail.");
|
|
959
|
+
return lines.join("\n");
|
|
960
|
+
}
|
|
961
|
+
function requireApply(apply, preview) {
|
|
962
|
+
return apply === true ? void 0 : preview;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ../mcp-core/build/tools/referrals.js
|
|
813
966
|
var REFERRAL_ACTIVITY_KEYS = [
|
|
814
967
|
"id",
|
|
815
968
|
"email",
|
|
@@ -841,6 +994,113 @@ var REFERRAL_PROGRAM_KEYS = [
|
|
|
841
994
|
"referralSharingMode",
|
|
842
995
|
"autoRevokeReferrerReward"
|
|
843
996
|
];
|
|
997
|
+
var CUSTOMER_REWARD_DISCOUNT_TYPES = [
|
|
998
|
+
"percentage",
|
|
999
|
+
"amount",
|
|
1000
|
+
"point",
|
|
1001
|
+
"point_order_value",
|
|
1002
|
+
"store_credit_fixed",
|
|
1003
|
+
"store_credit_order_value"
|
|
1004
|
+
];
|
|
1005
|
+
var FRIEND_REWARD_DISCOUNT_TYPES = ["percentage", "amount", "store_credit_fixed"];
|
|
1006
|
+
var REFERRAL_EXPIRATION_PATTERN = /^(?:permanent|specific|\d+(?:min|D|W|M|Y))$/;
|
|
1007
|
+
var ORDER_REQUIREMENTS = ["none", "min_amount", "min_quantity"];
|
|
1008
|
+
var REFERRAL_PROGRAM_ARGUMENTS = {
|
|
1009
|
+
status: "status",
|
|
1010
|
+
title: "title",
|
|
1011
|
+
customer_reward_earn_amount: "customerRewardEarnAmount",
|
|
1012
|
+
customer_reward_discount_type: "customerRewardDiscountType",
|
|
1013
|
+
customer_reward_expired_after: "customerRewardExpiredAfter",
|
|
1014
|
+
customer_reward_order_req: "customerRewardOrderReq",
|
|
1015
|
+
customer_reward_order_req_amount: "customerRewardOrderReqAmount",
|
|
1016
|
+
friend_reward_earn_amount: "friendRewardEarnAmount",
|
|
1017
|
+
friend_reward_discount_type: "friendRewardDiscountType",
|
|
1018
|
+
friend_reward_expired_after: "friendRewardExpiredAfter",
|
|
1019
|
+
friend_reward_order_req: "friendRewardOrderReq",
|
|
1020
|
+
friend_reward_order_req_amount: "friendRewardOrderReqAmount",
|
|
1021
|
+
max_referrals_per_customer: "maxReferralsPerCustomer"
|
|
1022
|
+
};
|
|
1023
|
+
var referralConfigInputShape = {
|
|
1024
|
+
status: z5.boolean().optional().describe("Enable (true) or disable (false) the referral program."),
|
|
1025
|
+
title: z5.string().trim().min(1).optional().describe("Merchant-facing referral program title."),
|
|
1026
|
+
customer_reward_earn_amount: z5.number().min(0).optional().describe("Reward amount for the REFERRER (the existing customer/advocate)."),
|
|
1027
|
+
customer_reward_discount_type: z5.enum(CUSTOMER_REWARD_DISCOUNT_TYPES).optional().describe("REFERRER reward type. Supports six methods: percentage, amount, point, point_order_value, store_credit_fixed, or store_credit_order_value. This is deliberately broader than friend_reward_discount_type."),
|
|
1028
|
+
customer_reward_expired_after: z5.string().regex(REFERRAL_EXPIRATION_PATTERN).optional().describe("REFERRER reward expiration: permanent, specific, or a duration such as 30D, 2W, 3M, 1Y, or 90min."),
|
|
1029
|
+
customer_reward_order_req: z5.enum(ORDER_REQUIREMENTS).optional().describe("Order requirement for the REFERRER reward: none, min_amount, or min_quantity."),
|
|
1030
|
+
customer_reward_order_req_amount: z5.number().min(0).optional().describe("Minimum amount or quantity for the REFERRER reward order requirement."),
|
|
1031
|
+
friend_reward_earn_amount: z5.number().min(0).optional().describe("Reward amount for the referred FRIEND. Must be 100 or less when friend_reward_discount_type is percentage."),
|
|
1032
|
+
friend_reward_discount_type: z5.enum(FRIEND_REWARD_DISCOUNT_TYPES).optional().describe("Referred FRIEND reward type. ONLY percentage, amount, or store_credit_fixed are supported. Unlike the referrer reward, point and order-value reward types are invalid here."),
|
|
1033
|
+
friend_reward_expired_after: z5.string().regex(REFERRAL_EXPIRATION_PATTERN).optional().describe("FRIEND reward expiration: permanent, specific, or a duration such as 30D, 2W, 3M, 1Y, or 90min."),
|
|
1034
|
+
friend_reward_order_req: z5.enum(ORDER_REQUIREMENTS).optional().describe("Order requirement for the FRIEND reward: none, min_amount, or min_quantity."),
|
|
1035
|
+
friend_reward_order_req_amount: z5.number().min(0).optional().describe("Minimum amount or quantity for the FRIEND reward order requirement."),
|
|
1036
|
+
max_referrals_per_customer: z5.number().int().min(0).optional().describe("Maximum successful referrals allowed per customer. Use 0 only when the merchant intends that configured value."),
|
|
1037
|
+
reason: z5.string().min(3).describe("Why the merchant wants this shop-wide referral configuration changed."),
|
|
1038
|
+
apply: z5.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
1039
|
+
};
|
|
1040
|
+
var referralConfigArgsSchema = z5.object(referralConfigInputShape).strict().superRefine((args, ctx) => {
|
|
1041
|
+
if (args.friend_reward_discount_type === "percentage" && (args.friend_reward_earn_amount ?? 0) > 100) {
|
|
1042
|
+
ctx.addIssue({
|
|
1043
|
+
code: z5.ZodIssueCode.custom,
|
|
1044
|
+
path: ["friend_reward_earn_amount"],
|
|
1045
|
+
message: "A percentage friend reward cannot exceed 100."
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
function toReferralProgramBody(args) {
|
|
1050
|
+
const body = {};
|
|
1051
|
+
for (const [argument, field] of Object.entries(REFERRAL_PROGRAM_ARGUMENTS)) {
|
|
1052
|
+
const value = args[argument];
|
|
1053
|
+
if (value !== void 0)
|
|
1054
|
+
body[field] = value;
|
|
1055
|
+
}
|
|
1056
|
+
return body;
|
|
1057
|
+
}
|
|
1058
|
+
function parseReferralConfigArgs(args) {
|
|
1059
|
+
const parsed = referralConfigArgsSchema.safeParse(args);
|
|
1060
|
+
if (parsed.success)
|
|
1061
|
+
return parsed.data;
|
|
1062
|
+
const issue = parsed.error.issues[0];
|
|
1063
|
+
const field = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
|
|
1064
|
+
throw new JoyApiError("validation", `Invalid referral configuration \u2014 ${field}${issue.message} Fix the named parameter and call again.`);
|
|
1065
|
+
}
|
|
1066
|
+
function validateEffectiveFriendPercentage(current, body) {
|
|
1067
|
+
const type = body.friendRewardDiscountType ?? current.friendRewardDiscountType;
|
|
1068
|
+
const amount = body.friendRewardEarnAmount ?? current.friendRewardEarnAmount;
|
|
1069
|
+
if (type === "percentage" && typeof amount === "number" && amount > 100) {
|
|
1070
|
+
throw new JoyApiError("validation", "Invalid referral configuration \u2014 friend_reward_earn_amount cannot exceed 100 when the effective friend reward type is percentage. Lower the amount or choose amount/store_credit_fixed, then call again.");
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
async function configureReferralProgram(ctx, input) {
|
|
1074
|
+
const args = parseReferralConfigArgs(input);
|
|
1075
|
+
const body = toReferralProgramBody(args);
|
|
1076
|
+
if (Object.keys(body).length === 0) {
|
|
1077
|
+
throw new JoyApiError("validation", `At least one referral configuration field is required. Valid fields: ${Object.keys(REFERRAL_PROGRAM_ARGUMENTS).join(", ")}.`);
|
|
1078
|
+
}
|
|
1079
|
+
const { data } = await ctx.api.getData("/referrals/program");
|
|
1080
|
+
const current = data ?? {};
|
|
1081
|
+
validateEffectiveFriendPercentage(current, body);
|
|
1082
|
+
const changes = diffFields(current, body);
|
|
1083
|
+
const preview = buildChangePreview({
|
|
1084
|
+
action: "UPDATE",
|
|
1085
|
+
target: `referral program "${String(body.title ?? current.title ?? "current")}"`,
|
|
1086
|
+
reason: args.reason,
|
|
1087
|
+
changes
|
|
1088
|
+
});
|
|
1089
|
+
if (changes.length === 0)
|
|
1090
|
+
return preview;
|
|
1091
|
+
const blocked = requireApply(args.apply, preview);
|
|
1092
|
+
if (blocked)
|
|
1093
|
+
return blocked;
|
|
1094
|
+
await ctx.api.mutateData("/referrals/program", { method: "PUT", body });
|
|
1095
|
+
const { data: refreshedData } = await ctx.api.getData("/referrals/program");
|
|
1096
|
+
const refreshed = refreshedData ?? {};
|
|
1097
|
+
const unconfirmed = diffFields(refreshed, body).map((change) => change.field);
|
|
1098
|
+
if (unconfirmed.length > 0) {
|
|
1099
|
+
throw new JoyApiError("server", `The referral program write was not confirmed: these requested fields did not match after the required read-back: ${unconfirmed.join(", ")}. Do not report the referral program as configured; ask the merchant to verify it in Joy admin and report this backend failure. Do not retry blindly.`);
|
|
1100
|
+
}
|
|
1101
|
+
const appliedFields = Object.keys(body);
|
|
1102
|
+
return summaryWithJson(`Updated the referral program and confirmed ${appliedFields.length} field${appliedFields.length === 1 ? "" : "s"} by reading it back. This affects every customer immediately.`, compact(refreshed, appliedFields));
|
|
1103
|
+
}
|
|
844
1104
|
function formatReferralReward(amount, type) {
|
|
845
1105
|
if (amount === void 0 || amount === null)
|
|
846
1106
|
return "not configured";
|
|
@@ -880,8 +1140,8 @@ function registerReferralTools(server, ctx) {
|
|
|
880
1140
|
title: "Get referral program performance",
|
|
881
1141
|
description: "Shop-level referral analytics. NOTE: the underlying API currently reports one metric only \u2014 how many referral coupon codes were used (i.e. completed referrals). It does NOT report clicks, signups, or revenue, and it does NOT tell you whether the program is enabled \u2014 use joy_get_referral_program for status/config. Optionally filter by date range.",
|
|
882
1142
|
inputSchema: {
|
|
883
|
-
start_date:
|
|
884
|
-
end_date:
|
|
1143
|
+
start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD), inclusive start of the range."),
|
|
1144
|
+
end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD), inclusive end of the range.")
|
|
885
1145
|
},
|
|
886
1146
|
handler: async (args) => {
|
|
887
1147
|
const { data } = await ctx.api.getData("/referrals/analytics", {
|
|
@@ -914,14 +1174,25 @@ function registerReferralTools(server, ctx) {
|
|
|
914
1174
|
}
|
|
915
1175
|
});
|
|
916
1176
|
}
|
|
1177
|
+
function registerReferralConfigTools(server, ctx) {
|
|
1178
|
+
registerConfigWriteTool(server, ctx, {
|
|
1179
|
+
name: "joy_configure_referral_program",
|
|
1180
|
+
title: "Configure the referral program",
|
|
1181
|
+
description: "CONFIGURATION WRITE \u2014 updates the shop's single existing referral program; there is no create branch and no program ID. customer_reward_* fields configure the REFERRER/advocate reward, while friend_reward_* fields configure the referred FRIEND reward. Their discount-type enums deliberately differ: the referrer supports six methods, but the friend supports ONLY percentage, amount, or store_credit_fixed (never point or order-value types). Previews by default, requires explicit approval to apply, and reads every requested field back before reporting success. Anti-cheat and eligibility settings are intentionally unavailable through this tool.",
|
|
1182
|
+
inputSchema: referralConfigInputShape,
|
|
1183
|
+
destructiveHint: true,
|
|
1184
|
+
strictArguments: true,
|
|
1185
|
+
handler: (args) => configureReferralProgram(ctx, args)
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
917
1188
|
function registerReferralWriteTools(server, ctx) {
|
|
918
1189
|
registerWriteTool(server, ctx, {
|
|
919
1190
|
name: "joy_create_referral_invite",
|
|
920
1191
|
title: "Generate a referral invite link",
|
|
921
1192
|
description: "WRITE \u2014 generates a personal referral invite link for an email address. For an EXISTING member this just returns their own link (safe, cheap). For an email that is NOT yet a member, this CREATES A REAL SHOPIFY CUSTOMER RECORD \u2014 irreversible from this tool \u2014 and may trigger sign-up point rewards for them. Double-check the email spelling with the merchant before calling for a non-member; a typo creates a junk customer, not an error. Identify by email only (this can create a new record, so it never accepts shopify_customer_id).",
|
|
922
1193
|
inputSchema: {
|
|
923
|
-
email:
|
|
924
|
-
use_marketing:
|
|
1194
|
+
email: z5.string().email().describe("Email address to generate the invite link for. Must be a real, verified address \u2014 a typo creates a real customer record, not an error."),
|
|
1195
|
+
use_marketing: z5.boolean().optional().describe("Marketing consent for a NEW customer record. Defaults to false (no consent). Only pass true if the person actually opted in to marketing \u2014 this can set real email-marketing-consent on their Shopify customer record.")
|
|
925
1196
|
},
|
|
926
1197
|
destructiveHint: false,
|
|
927
1198
|
idempotentHint: false,
|
|
@@ -967,7 +1238,7 @@ function registerOverviewTools(server, ctx) {
|
|
|
967
1238
|
}
|
|
968
1239
|
|
|
969
1240
|
// ../mcp-core/build/tools/analytics.js
|
|
970
|
-
import { z as
|
|
1241
|
+
import { z as z6 } from "zod";
|
|
971
1242
|
function toDateRangeQuery(args) {
|
|
972
1243
|
const query = {};
|
|
973
1244
|
if (args.start_date !== void 0)
|
|
@@ -1064,8 +1335,8 @@ var LOYALTY_REVENUE_KEYS = [
|
|
|
1064
1335
|
var TIER_PERFORMANCE_KEYS = ["metric", "period", "rows", "topGroup"];
|
|
1065
1336
|
var HEALTH_CHECK_KEYS = ["healthScore", "flagCount", "flags", "snapshot"];
|
|
1066
1337
|
var reportDateRangeSchema = {
|
|
1067
|
-
start_date:
|
|
1068
|
-
end_date:
|
|
1338
|
+
start_date: z6.string().optional().describe("ISO date (YYYY-MM-DD), inclusive start of the report window. Defaults to 30 days ago. Effective range max 366 days."),
|
|
1339
|
+
end_date: z6.string().optional().describe("ISO date (YYYY-MM-DD), inclusive end of the report window. Defaults to today.")
|
|
1069
1340
|
};
|
|
1070
1341
|
var SCOPE_HELP = "A 403 mentioning read_reports means the MERCHANT has not granted Shopify reports access to Joy \u2014 not a credential problem, do not retry; ask them to grant it from Joy settings.";
|
|
1071
1342
|
function withScopeGuidance(error) {
|
|
@@ -1113,9 +1384,9 @@ function registerAnalyticsTools(server, ctx) {
|
|
|
1113
1384
|
title: "Loyalty analytics report (Analytics V2)",
|
|
1114
1385
|
description: 'THE primary tool for loyalty analytics questions \u2014 members, VIP tiers, points economy, rewards ROI, coupons, referrals, and loyalty revenue impact. Retrieves any Analytics V2 report using the SAME data and cache as the Analytics dashboard the merchant sees in the Joy admin, so your numbers always match theirs. Joy-internal data \u2014 no Shopify permission needed. Defaults to the last 30 days (max 366-day range, cached server-side ~1h). Returns a pre-digested text summary (time series are collapsed to count/total/latest; tables capped at 8 rows). A 409 error means the shop is still on Analytics V1 (no V2 data exists \u2014 follow the error guidance about migrating, never report zeros); a summary starting with "WARNING: \u2026 backfill" means the historical backfill is unfinished and numbers may be incomplete \u2014 relay that caveat. For Shopify-sales-attributed revenue (order-tag attribution) use joy_get_loyalty_revenue_report instead \u2014 a DIFFERENT data source whose numbers may legitimately differ from assisted_revenue.\n' + V2_REPORT_MENU,
|
|
1115
1386
|
inputSchema: {
|
|
1116
|
-
report_type:
|
|
1387
|
+
report_type: z6.enum(V2_REPORT_TYPES).describe("Which Analytics V2 loyalty report to retrieve (see the menu in the tool description)."),
|
|
1117
1388
|
...reportDateRangeSchema,
|
|
1118
|
-
unit:
|
|
1389
|
+
unit: z6.enum(["day", "month"]).optional().describe("Time grain for trend reports. Defaults by range size (\u2264100 days \u2192 day, else month).")
|
|
1119
1390
|
},
|
|
1120
1391
|
handler: async (args) => {
|
|
1121
1392
|
let report;
|
|
@@ -1141,11 +1412,11 @@ ${summary}`);
|
|
|
1141
1412
|
title: "Loyalty revenue impact (Shopify sales data)",
|
|
1142
1413
|
description: `Loyalty revenue impact measured from SHOPIFY'S OWN sales dataset (ShopifyQL), attributed by the shop's "Joy assisted" order tag. Use for: loyalty ROI, "how much revenue does the loyalty program drive?", "is the program making money?", loyalty AOV, year-over-year (YoY) comparison. Metrics: total_sales = revenue from orders tagged "Joy assisted"; aov = average order value of those orders; returning_rate = returning-customer rate among customers tagged as active redeemers; orders = count of Joy-assisted orders. compare_to_last_year applies to total_sales ONLY and is IGNORED when a custom range is given. Custom range = BOTH start_date and end_date (ISO YYYY-MM-DD), max 366 days, not in the future. ` + SCOPE_HELP + " joy_get_analytics_report (reportType assisted_revenue) measures assisted revenue from Joy's own event log \u2014 a DIFFERENT data source with different attribution; the two numbers may legitimately disagree.",
|
|
1143
1414
|
inputSchema: {
|
|
1144
|
-
metric:
|
|
1145
|
-
period:
|
|
1146
|
-
compare_to_last_year:
|
|
1147
|
-
start_date:
|
|
1148
|
-
end_date:
|
|
1415
|
+
metric: z6.enum(["total_sales", "aov", "returning_rate", "orders"]).describe("Which revenue metric to compute (see description for definitions)."),
|
|
1416
|
+
period: z6.enum(["last_7d", "last_30d", "last_90d", "last_1y", "this_month"]).optional().describe("Relative reporting period. Default last_30d. Ignored when start_date/end_date are given."),
|
|
1417
|
+
compare_to_last_year: z6.boolean().optional().describe("Add previous-year comparison (previousValue + deltaPercent). total_sales only; ignored with a custom date range."),
|
|
1418
|
+
start_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range start. Requires end_date too; max 366 days."),
|
|
1419
|
+
end_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range end. Requires start_date too; not in the future.")
|
|
1149
1420
|
},
|
|
1150
1421
|
handler: async (args) => {
|
|
1151
1422
|
const data = await getScopedReport(ctx, "/analytics/loyalty-revenue", {
|
|
@@ -1161,9 +1432,9 @@ ${summary}`);
|
|
|
1161
1432
|
title: "Sales performance by VIP tier",
|
|
1162
1433
|
description: 'Shopify sales performance broken down by VIP tier (ShopifyQL, grouped by the Joy tier metafield). Use for: "which tier spends the most?", "revenue by tier", "AOV per tier", "orders per customer by tier". Default window is ALL-TIME; pass BOTH start_date and end_date (YYYY-MM-DD, max 366 days) to bound it. Rows are tier names sorted by value; "(unassigned)" = customers without a tier. A 422 METAFIELD_NOT_CONFIGURED error means the Joy tier metafield definition is DISABLED on this shop \u2014 that is a setup gate, NOT empty data; ask the merchant to enable it in Joy \u2192 Settings \u2192 General \u2192 Metafield section. For loyalty-side tier reports (member counts, upgrades/downgrades) use joy_get_analytics_report with tier_performance / tier_trend / tier_transitions. ' + SCOPE_HELP,
|
|
1163
1434
|
inputSchema: {
|
|
1164
|
-
metric:
|
|
1165
|
-
start_date:
|
|
1166
|
-
end_date:
|
|
1435
|
+
metric: z6.enum(["total_sales", "aov", "orders_per_customer", "spend_per_customer"]).describe("Which per-tier metric to compute."),
|
|
1436
|
+
start_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range start. Requires end_date too; max 366 days. Omit both for all-time."),
|
|
1437
|
+
end_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range end. Requires start_date too.")
|
|
1167
1438
|
},
|
|
1168
1439
|
handler: async (args) => {
|
|
1169
1440
|
let data;
|
|
@@ -1188,8 +1459,8 @@ ${summary}`);
|
|
|
1188
1459
|
title: "Loyalty program health check",
|
|
1189
1460
|
description: `Loyalty program health diagnostic \u2014 use FIRST for open-ended questions: "how is my loyalty program doing?", "is anything wrong?", "why aren't customers earning points?". Compares the current window (default last 30 days) against the equal-length prior window using Joy-internal data only \u2014 aggregate numbers, no customer PII, and NO Shopify permission needed. healthScore = 100 minus a penalty per flag (low 5, medium 15, high 25; floor 0): >=80 Healthy, 60-79 Watch, <60 Needs attention. Flags check: sandbox/test earning mode (real orders are NOT earning points \u2014 the most urgent flag), redemption rate, points-earned trend, redeem-revenue trend, data availability, and tier concentration. Pass start_date+end_date (both, YYYY-MM-DD) to move the current window. For the underlying detail behind a flag, follow up with joy_get_analytics_report.`,
|
|
1190
1461
|
inputSchema: {
|
|
1191
|
-
start_date:
|
|
1192
|
-
end_date:
|
|
1462
|
+
start_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom current-window start. Requires end_date too."),
|
|
1463
|
+
end_date: z6.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom current-window end. Requires start_date too.")
|
|
1193
1464
|
},
|
|
1194
1465
|
handler: async (args) => {
|
|
1195
1466
|
const { data } = await ctx.api.getData("/analytics/health-check", {
|
|
@@ -1208,7 +1479,7 @@ function registerAnalyticsWriteTools(server, ctx) {
|
|
|
1208
1479
|
title: "Switch the shop to Analytics V2 (starts data backfill)",
|
|
1209
1480
|
description: 'WRITE \u2014 enables the new Analytics V2 for this shop and starts the historical data backfill in the background. Call this when joy_get_analytics_report returned the "shop is still on Analytics V1" error AND the merchant has EXPLICITLY agreed to switch \u2014 never call it unprompted: it changes the default Analytics experience in their Joy admin and runs a data migration that can take from a few minutes to a few tens of minutes. IDEMPOTENT and doubles as a STATUS CHECK: calling it again never restarts a running backfill \u2014 it returns progress (running/failed/already enabled) instead, so use it to answer "is the migration done yet?".',
|
|
1210
1481
|
inputSchema: {
|
|
1211
|
-
merchant_confirmed:
|
|
1482
|
+
merchant_confirmed: z6.literal(true).describe("Must be true \u2014 pass it ONLY after the merchant explicitly agreed to switch to the new Analytics. If they have not been asked yet, ask first instead of calling this tool.")
|
|
1212
1483
|
},
|
|
1213
1484
|
destructiveHint: false,
|
|
1214
1485
|
idempotentHint: true,
|
|
@@ -1224,7 +1495,7 @@ function registerAnalyticsWriteTools(server, ctx) {
|
|
|
1224
1495
|
}
|
|
1225
1496
|
|
|
1226
1497
|
// ../mcp-core/build/tools/points.js
|
|
1227
|
-
import { z as
|
|
1498
|
+
import { z as z7 } from "zod";
|
|
1228
1499
|
|
|
1229
1500
|
// ../mcp-core/build/tools/writeSupport.js
|
|
1230
1501
|
import { randomUUID } from "node:crypto";
|
|
@@ -1290,9 +1561,9 @@ var MUTATION_RESULT_KEYS = [
|
|
|
1290
1561
|
];
|
|
1291
1562
|
var writeArgsSchema = {
|
|
1292
1563
|
...memberIdentifierSchema,
|
|
1293
|
-
reason:
|
|
1294
|
-
admin_note:
|
|
1295
|
-
idempotency_key:
|
|
1564
|
+
reason: z7.string().min(3).describe("Why this change is being made \u2014 REQUIRED, recorded permanently in the audit trail."),
|
|
1565
|
+
admin_note: z7.string().optional().describe("Optional internal note for the merchant team."),
|
|
1566
|
+
idempotency_key: z7.string().max(255).optional().describe("Only pass this when RETRYING an uncertain outcome: reuse the exact key from the previous error message so the backend replays instead of applying twice. Omit for a new operation.")
|
|
1296
1567
|
};
|
|
1297
1568
|
async function executePointMutation(params) {
|
|
1298
1569
|
const { ctx, path, member, point, reason, adminNote, idempotencyKey } = params;
|
|
@@ -1320,7 +1591,7 @@ function registerPointWriteTools(server, ctx) {
|
|
|
1320
1591
|
description: "WRITE \u2014 gives points to ONE loyalty member (points are merchant money; the host will ask the user to approve). Identify the member by email or Shopify customer ID. reason is required and goes to the permanent audit trail. The operation is protected by an idempotency key, so a confirmed retry with the returned key can never double-award.",
|
|
1321
1592
|
inputSchema: {
|
|
1322
1593
|
...writeArgsSchema,
|
|
1323
|
-
points:
|
|
1594
|
+
points: z7.number().int().positive().describe("Points to add (positive integer).")
|
|
1324
1595
|
},
|
|
1325
1596
|
destructiveHint: false,
|
|
1326
1597
|
idempotentHint: false,
|
|
@@ -1345,8 +1616,8 @@ function registerPointWriteTools(server, ctx) {
|
|
|
1345
1616
|
description: `WRITE, destructive \u2014 removes points from ONE member (points are merchant money; cannot be undone except by a compensating award). reason is required and audited. Deductions larger than ${ctx.config.deductDryRunThreshold} points return a DRY-RUN preview first \u2014 call again with apply: true to execute. Idempotency-protected.`,
|
|
1346
1617
|
inputSchema: {
|
|
1347
1618
|
...writeArgsSchema,
|
|
1348
|
-
points:
|
|
1349
|
-
apply:
|
|
1619
|
+
points: z7.number().int().positive().describe("Points to remove (positive integer)."),
|
|
1620
|
+
apply: z7.boolean().optional().describe(`Required (true) to EXECUTE a deduction above ${ctx.config.deductDryRunThreshold} points after reviewing the dry-run preview.`)
|
|
1350
1621
|
},
|
|
1351
1622
|
destructiveHint: true,
|
|
1352
1623
|
idempotentHint: false,
|
|
@@ -1387,8 +1658,8 @@ function registerPointWriteTools(server, ctx) {
|
|
|
1387
1658
|
description: "WRITE, destructive \u2014 corrects a member's balance by a signed delta (points are merchant money). ALWAYS dry-runs first: the initial call returns a preview of current \u2192 resulting balance WITHOUT changing anything; call again with apply: true to execute. reason is required and audited. Idempotency-protected.",
|
|
1388
1659
|
inputSchema: {
|
|
1389
1660
|
...writeArgsSchema,
|
|
1390
|
-
points_delta:
|
|
1391
|
-
apply:
|
|
1661
|
+
points_delta: z7.number().int().refine((value) => value !== 0, { message: "points_delta must not be 0" }).describe("Signed change: positive adds, negative removes."),
|
|
1662
|
+
apply: z7.boolean().optional().describe("Must be true to EXECUTE. Omit (or false) to get the dry-run preview.")
|
|
1392
1663
|
},
|
|
1393
1664
|
destructiveHint: true,
|
|
1394
1665
|
idempotentHint: false,
|
|
@@ -1423,8 +1694,8 @@ function registerPointWriteTools(server, ctx) {
|
|
|
1423
1694
|
description: "WRITE, destructive \u2014 spends a member's points on a redemption program and returns the issued coupon code. Find the program_id via joy_list_redemption_rules first. Can take up to ~30s (a Shopify discount is minted inline). NO server-side retry protection yet: if the outcome is uncertain (timeout), verify via joy_get_member_activity BEFORE any retry \u2014 a blind retry can double-spend.",
|
|
1424
1695
|
inputSchema: {
|
|
1425
1696
|
...memberIdentifierSchema,
|
|
1426
|
-
program_id:
|
|
1427
|
-
redeem_points:
|
|
1697
|
+
program_id: z7.string().describe("Redemption program ID (from joy_list_redemption_rules)."),
|
|
1698
|
+
redeem_points: z7.number().int().positive().describe("Points to spend on this redemption.")
|
|
1428
1699
|
},
|
|
1429
1700
|
destructiveHint: true,
|
|
1430
1701
|
idempotentHint: false,
|
|
@@ -1458,7 +1729,7 @@ function registerPointWriteTools(server, ctx) {
|
|
|
1458
1729
|
}
|
|
1459
1730
|
|
|
1460
1731
|
// ../mcp-core/build/tools/rewards.js
|
|
1461
|
-
import { z as
|
|
1732
|
+
import { z as z8 } from "zod";
|
|
1462
1733
|
function registerRewardWriteTools(server, ctx) {
|
|
1463
1734
|
registerWriteTool(server, ctx, {
|
|
1464
1735
|
name: "joy_refund_coupon",
|
|
@@ -1466,8 +1737,8 @@ function registerRewardWriteTools(server, ctx) {
|
|
|
1466
1737
|
description: 'WRITE \u2014 refunds, revokes, cancels, voids, or undoes a coupon a member redeemed with points (use this for ANY request to "revoke", "disable", "cancel", "void", or "undo" a point-redeemed coupon/discount code). IMPORTANT: this AUTOMATICALLY credits the spent points back to the member as part of the refund (a refund_point_redeem activity is recorded with your reason) \u2014 do NOT also call joy_award_points afterward, that would double-credit them. Only coupons that came from a POINT REDEMPTION can be refunded this way (not coupons from other sources, e.g. manual discounts). Identify the member by email or Shopify customer ID \u2014 do not ask the caller for a raw customer ID.',
|
|
1467
1738
|
inputSchema: {
|
|
1468
1739
|
...memberIdentifierSchema,
|
|
1469
|
-
coupon_code:
|
|
1470
|
-
reason:
|
|
1740
|
+
coupon_code: z8.string().describe("The discount code to refund (from joy_get_member_rewards or joy_get_member_activity)."),
|
|
1741
|
+
reason: z8.string().min(3).describe("Why this coupon is being refunded \u2014 REQUIRED, recorded in the audit trail.")
|
|
1471
1742
|
},
|
|
1472
1743
|
destructiveHint: true,
|
|
1473
1744
|
idempotentHint: false,
|
|
@@ -1493,7 +1764,7 @@ function registerRewardWriteTools(server, ctx) {
|
|
|
1493
1764
|
}
|
|
1494
1765
|
|
|
1495
1766
|
// ../mcp-core/build/tools/retention.js
|
|
1496
|
-
import { z as
|
|
1767
|
+
import { z as z9 } from "zod";
|
|
1497
1768
|
var POINTS_EXPIRING_KEYS = [
|
|
1498
1769
|
"customerId",
|
|
1499
1770
|
"name",
|
|
@@ -1532,10 +1803,10 @@ function registerRetentionTools(server, ctx) {
|
|
|
1532
1803
|
title: "Find members at risk of losing points or dropping a VIP tier",
|
|
1533
1804
|
description: 'READ-ONLY retention/outreach tool \u2014 "which members are about to lose points or drop a VIP tier, so I can proactively reach out?" Two independent signals via `signal`: "points_expiring" = members with points expiring within `window_days` (ONLY covers shops on earn-time/FIFO point expiration \u2014 shops using all-at-once expiration are not covered by this signal, so an empty result here does not mean no one is losing points); "tier_drop_risk" = members the REAL tier-demotion engine will demote soon \u2014 Joy demotes on INACTIVITY (no order since the tier program\'s configured demotion window), so a member is at risk when their last order is already past that cutoff (`daysUntilDemotion: 0` = demotable on the very next demotion run) or will pass it within `window_days` unless they order again; each row carries `demotion`: "down_one_tier" or "reset_to_lowest_tier" per the program\'s setting. A shop can also (or instead) run a SCHEDULED STORE-WIDE reset on a fixed calendar day \u2014 then the response carries `tier_scheduled_reset` (resetAt + daysUntilReset): EVERY member resets to the lowest tier on that date at once, so the correct answer is that DATE, not a per-member list. If the shop has tier demotion disabled, the response includes `tierDropRiskNote` saying the signal does not apply \u2014 report that note, never conclude "no one is at risk" from the empty list. Known limit: shops using assessment-based demotion (periodic spending re-evaluation) are not predicted by this signal. Optionally scope to one tier with `tier_id` (the lowest tier is never at risk \u2014 nothing to drop to). Default "both" returns both signals in one call. Results are HARD-CAPPED: window_days max 30, limit max 50 per signal (values above the cap are silently clamped, not rejected) \u2014 this is a shortlist for outreach, never the full at-risk population. Returns only the fields needed to act (name/email, expiring points/days, last-order date/days-until-demotion) plus has_more \u2014 never the member\'s full activity history.',
|
|
1534
1805
|
inputSchema: {
|
|
1535
|
-
signal:
|
|
1536
|
-
window_days:
|
|
1537
|
-
tier_id:
|
|
1538
|
-
limit:
|
|
1806
|
+
signal: z9.enum(["points_expiring", "tier_drop_risk", "both"]).optional().describe('Which retention-risk signal to check. "points_expiring" for expiring-points questions, "tier_drop_risk" for tier-downgrade-risk questions, "both" (default) when the merchant asks broadly (e.g. "who is at risk of leaving?").'),
|
|
1807
|
+
window_days: z9.number().int().min(1).max(30).optional().describe("Lookahead for BOTH signals (default 7, hard-capped at 30 \u2014 larger values are clamped, not rejected): points_expiring = points expiring within this many days; tier_drop_risk = members who are already demotable or become demotable within this many days."),
|
|
1808
|
+
tier_id: z9.string().optional().describe("For tier_drop_risk: scope to one VIP tier ID (from joy_list_tiers). Omit to check every non-exclusive tier above the lowest \u2014 the result limit is split evenly across tiers."),
|
|
1809
|
+
limit: z9.number().int().min(1).max(50).optional().describe("Max members per signal (default 20, hard-capped at 50).")
|
|
1539
1810
|
},
|
|
1540
1811
|
handler: async (args) => {
|
|
1541
1812
|
const { data } = await ctx.api.getData("/customers/retention-risk", {
|
|
@@ -1634,7 +1905,7 @@ function registerRiskTools(server, ctx) {
|
|
|
1634
1905
|
}
|
|
1635
1906
|
|
|
1636
1907
|
// ../mcp-core/build/tools/orderExplain.js
|
|
1637
|
-
import { z as
|
|
1908
|
+
import { z as z10 } from "zod";
|
|
1638
1909
|
var RULE_KEYS = ["programId", "title", "priority", "matched", "earnedPoints", "reasons"];
|
|
1639
1910
|
function describeRule(rule) {
|
|
1640
1911
|
const title = typeof rule.title === "string" ? rule.title : String(rule.programId ?? "rule");
|
|
@@ -1651,7 +1922,7 @@ function registerOrderExplainTools(server, ctx) {
|
|
|
1651
1922
|
title: "Explain why an order did or didn't earn points",
|
|
1652
1923
|
description: "READ-ONLY explanation of the loyalty points earning rule engine for ONE order \u2014 \"why did/didn't this order earn points (or earn fewer than expected)?\" The response's `explainSource` field tells you which of two modes produced it: `recorded` means the answer comes from the decision-time trace the REAL earn pipeline stored on the order's activity when it was processed (historical ground truth \u2014 per-rule pass/fail with the exact condition fields that failed, recorded at `recordedAt`); `replay` means no stored trace exists (order processed before tracing shipped, or a legacy calculation path) and this is a RECONSTRUCTION run against the order's CURRENT Shopify snapshot, reusing the exact same gate and rule-matching functions the real earn pipeline uses \u2014 NOT a historical record. If the order was modified after points were originally calculated (e.g. a partial refund applied after the initial award), this tool reports against the order's current state, which may not match what was actually awarded at the time \u2014 do not present this as infallible ground truth for what happened historically; treat it as \"what the rules say NOW\". Evaluation order: (1) pre-rule-engine gates \u2014 no customer matched on the order, guest checkout not opted in, customer left the loyalty program, order not paid/fulfilled per the shop's setting, order fully refunded, or an update arriving after the order was already closed; if any gate blocks, `blockedByGate` explains which one and no rules were evaluated. (2) If gates pass, every active place-order earning rule is evaluated in priority order (`rules`), each with `matched` true/false/null and human-readable `reasons` \u2014 `matched: null` means the rule was NEVER evaluated because a higher-priority rule matched first with \"stop processing\" enabled (distinct from `false`, which means it WAS evaluated and did not match). (3) `maxTheoreticalPoints` is the total before the global per-customer/tier earning cap; `totalEarnedPoints` is after; `cappedBy` is set only when that cap actually reduced the total. Scope: covers the place_order (checkout) earning event only \u2014 birthday, sign-up, and milestone rewards are NOT covered by this tool.",
|
|
1653
1924
|
inputSchema: {
|
|
1654
|
-
order_id:
|
|
1925
|
+
order_id: z10.string().describe('The order to explain \u2014 either the internal Shopify order ID (numeric) or the order name as shown to the merchant, e.g. "#1234".')
|
|
1655
1926
|
},
|
|
1656
1927
|
handler: async (args) => {
|
|
1657
1928
|
const { data } = await ctx.api.getData(`/orders/${encodeURIComponent(args.order_id)}/points-explanation`);
|
|
@@ -1691,7 +1962,7 @@ function registerOrderExplainTools(server, ctx) {
|
|
|
1691
1962
|
}
|
|
1692
1963
|
|
|
1693
1964
|
// ../mcp-core/build/tools/activityAudit.js
|
|
1694
|
-
import { z as
|
|
1965
|
+
import { z as z11 } from "zod";
|
|
1695
1966
|
var AUDIT_GROUPS = [
|
|
1696
1967
|
"tier_downgraded",
|
|
1697
1968
|
"tier_upgraded",
|
|
@@ -1738,11 +2009,11 @@ function registerActivityAuditTools(server, ctx) {
|
|
|
1738
2009
|
title: "List loyalty activities across members (audit by activity group)",
|
|
1739
2010
|
description: 'READ-ONLY shop-wide activity audit \u2014 "WHICH members did X in this period", the cross-member counterpart of joy_get_member_activity (one member\'s history) and of joy_get_analytics_report report_type=tier_transitions (aggregate COUNTS only, no names). Use this when the merchant asks WHO, use analytics when they ask HOW MANY. One semantic `group` per call, each spanning every raw activity type that feature area produces: "tier_downgraded" covers ALL THREE demotion mechanisms (reassessment demote_tier, inactivity downgrade_tier, relaunch reset_tier \u2014 never query one of these alone or you will miss mechanisms); "tier_upgraded" = tier upgrades; "points_earned" = program point earns (rows include orderName when the earn came from an order); "points_redeemed" = redemptions web + POS (each row carries programId/programTitle \u2014 combine with `program_id` for "who redeemed program A?"); "points_refunded" / "points_expired" / "points_adjusted" = the corresponding point movements; "referral" = referral outcomes (referred members, referred orders, referral rewards + revocations \u2014 rows carry referralCustomerEmail linking referrer and referee); "members_joined" = joins/rejoins. Time range: from_date/to_date (defaults to the last 30 days, range hard-capped at 366 days \u2014 an over-wide range is clamped to the most recent 366 days, not rejected). Results are a HARD-CAPPED shortlist: limit max 50 (default 20), newest first, plus has_more \u2014 never the full population; treat has_more:true as "narrow the range or filter, do not page exhaustively".',
|
|
1740
2011
|
inputSchema: {
|
|
1741
|
-
group:
|
|
1742
|
-
program_id:
|
|
1743
|
-
from_date:
|
|
1744
|
-
to_date:
|
|
1745
|
-
limit:
|
|
2012
|
+
group: z11.enum(AUDIT_GROUPS).describe('Which activity group to audit. Pick by merchant intent: "who dropped a tier" \u2192 tier_downgraded; "who redeemed (program A)" \u2192 points_redeemed (+ program_id); "who earned points from orders" \u2192 points_earned; "who referred people" \u2192 referral.'),
|
|
2013
|
+
program_id: z11.string().optional().describe("Scope to one program (from joy_list_earning_rules / joy_list_redemption_rules), e.g. redemptions of one specific reward. Applied as a post-filter \u2014 a sparse program in a busy shop may fill fewer rows than `limit`."),
|
|
2014
|
+
from_date: z11.string().optional().describe("Start of the period, ISO date (e.g. 2026-07-01). Default: 30 days before to_date."),
|
|
2015
|
+
to_date: z11.string().optional().describe("End of the period, ISO date. Default: now."),
|
|
2016
|
+
limit: z11.number().int().min(1).max(50).optional().describe("Max rows (default 20, hard-capped at 50).")
|
|
1746
2017
|
},
|
|
1747
2018
|
handler: async (args) => {
|
|
1748
2019
|
const { data } = await ctx.api.getData("/activities/audit", {
|
|
@@ -1765,7 +2036,7 @@ function registerActivityAuditTools(server, ctx) {
|
|
|
1765
2036
|
}
|
|
1766
2037
|
|
|
1767
2038
|
// ../mcp-core/build/tools/tierUpgrade.js
|
|
1768
|
-
import { z as
|
|
2039
|
+
import { z as z12 } from "zod";
|
|
1769
2040
|
var UPGRADE_ITEM_KEYS = [
|
|
1770
2041
|
"customerId",
|
|
1771
2042
|
"name",
|
|
@@ -1795,9 +2066,9 @@ function registerTierUpgradeTools(server, ctx) {
|
|
|
1795
2066
|
title: "Find members close to reaching the next VIP tier",
|
|
1796
2067
|
description: 'READ-ONLY growth/upsell outreach tool \u2014 "which members are ABOUT TO reach the next VIP tier, so I can nudge them over the line?" (e.g. "kh\xE1ch n\xE0o s\u1EAFp l\xEAn Gold?"). The mirror of joy_get_retention_risk_members\' tier_drop_risk signal: that one finds members about to FALL, this one finds members about to CLIMB. Upgrades in Joy apply immediately when tierPoint reaches the next tier\'s threshold, so distance-to-threshold is the exact signal \u2014 each row reports the member\'s current tier, tierPoint, the next tier and its threshold, and `pointsToNextTier` (how little they still need). Sorted closest-first. `margin_points` defines "close" (default: 10% of the next tier\'s own threshold, so it scales with the shop); note tierPoint is measured in the tier program\'s entry metric \u2014 points, money spent, or order count depending on the program setup \u2014 so quote distances in that unit, not always "points". Optionally scope to one CURRENT tier with `tier_id` (from joy_list_tiers); the highest tier is never a candidate (nothing to climb to), exclusive/VIP-deal tiers are excluded on both ends. Results are a HARD-CAPPED shortlist (limit max 50, default 20, split evenly across tiers when unscoped) plus has_more \u2014 never the full population. For ONE member\'s own distance to the next tier, prefer joy_find_member (field pointsRemain) instead.',
|
|
1797
2068
|
inputSchema: {
|
|
1798
|
-
tier_id:
|
|
1799
|
-
margin_points:
|
|
1800
|
-
limit:
|
|
2069
|
+
tier_id: z12.string().optional().describe("Scope to members currently in this VIP tier ID (from joy_list_tiers). Omit to check every climbable tier \u2014 the result limit is split evenly across tiers."),
|
|
2070
|
+
margin_points: z12.number().min(1).optional().describe(`How close (in the tier program's entry metric) counts as "near the next tier". Default: 10% of the next tier's threshold. Use an explicit value when the merchant names one ("within 100 points of Gold").`),
|
|
2071
|
+
limit: z12.number().int().min(1).max(50).optional().describe("Max members returned (default 20, hard-capped at 50).")
|
|
1801
2072
|
},
|
|
1802
2073
|
handler: async (args) => {
|
|
1803
2074
|
const { data } = await ctx.api.getData("/customers/tier-upgrade-candidates", {
|
|
@@ -1816,99 +2087,2452 @@ function registerTierUpgradeTools(server, ctx) {
|
|
|
1816
2087
|
});
|
|
1817
2088
|
}
|
|
1818
2089
|
|
|
1819
|
-
// ../mcp-core/build/
|
|
1820
|
-
import { z as
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2090
|
+
// ../mcp-core/build/tools/programsWrite.js
|
|
2091
|
+
import { z as z13 } from "zod";
|
|
2092
|
+
var PROGRAM_TYPE_BY_KIND = {
|
|
2093
|
+
earning: "earning",
|
|
2094
|
+
redemption: "spending"
|
|
2095
|
+
};
|
|
2096
|
+
var SIMPLE_SOCIAL_EVENTS = /* @__PURE__ */ new Set([
|
|
2097
|
+
"like_facebook",
|
|
2098
|
+
"follow_instagram",
|
|
2099
|
+
"follow_twitter",
|
|
2100
|
+
"follow_pinterest",
|
|
2101
|
+
"follow_tiktok",
|
|
2102
|
+
"subscribe_youtube",
|
|
2103
|
+
"join_discord",
|
|
2104
|
+
"join_telegram",
|
|
2105
|
+
"join_reddit"
|
|
2106
|
+
]);
|
|
2107
|
+
var REVIEW_APPS = [
|
|
2108
|
+
"airReviewApp",
|
|
2109
|
+
"judgeMeApp",
|
|
2110
|
+
"yotpoApp",
|
|
2111
|
+
"feraApp",
|
|
2112
|
+
"reviewRocketApp",
|
|
2113
|
+
"klaviyoApp",
|
|
2114
|
+
"stampedApp",
|
|
2115
|
+
"looxApp",
|
|
2116
|
+
"okendoApp",
|
|
2117
|
+
"ReviewsIoApp",
|
|
2118
|
+
"growaveApp",
|
|
2119
|
+
"typdalApp"
|
|
2120
|
+
];
|
|
2121
|
+
var REVIEW_FLOW_APPS = /* @__PURE__ */ new Set([
|
|
2122
|
+
"stampedApp",
|
|
2123
|
+
"looxApp",
|
|
2124
|
+
"okendoApp",
|
|
2125
|
+
"ReviewsIoApp",
|
|
2126
|
+
"growaveApp",
|
|
2127
|
+
"typdalApp"
|
|
2128
|
+
]);
|
|
2129
|
+
var REVIEW_PHOTO_VIDEO_APPS = /* @__PURE__ */ new Set(["reviewRocketApp", "looxApp"]);
|
|
2130
|
+
var EARNING_CREATE_EVENTS = /* @__PURE__ */ new Set([
|
|
2131
|
+
"place_order",
|
|
2132
|
+
"sign_up",
|
|
2133
|
+
"birthday",
|
|
2134
|
+
"interact_website",
|
|
2135
|
+
"write_review",
|
|
2136
|
+
...SIMPLE_SOCIAL_EVENTS
|
|
2137
|
+
]);
|
|
2138
|
+
var REDEMPTION_CREATE_EVENTS = /* @__PURE__ */ new Set([
|
|
2139
|
+
"amount_discount",
|
|
2140
|
+
"percentage_discount",
|
|
2141
|
+
"free_shipping",
|
|
2142
|
+
"free_gift"
|
|
2143
|
+
]);
|
|
2144
|
+
var FREE_GIFT_PROGRAM_APPLIED_TO = ["sf_product", "specific", "exclude_collection"];
|
|
2145
|
+
var EARNING_FIELDS = [
|
|
2146
|
+
"event",
|
|
2147
|
+
"title",
|
|
2148
|
+
"status",
|
|
2149
|
+
"priority",
|
|
2150
|
+
"startDate",
|
|
2151
|
+
"endDate",
|
|
2152
|
+
"earnBy",
|
|
2153
|
+
"rateMoney",
|
|
2154
|
+
"rateItem",
|
|
2155
|
+
"earnPoint",
|
|
2156
|
+
"typeEarn",
|
|
2157
|
+
"isUseLayoutMultiRewards",
|
|
2158
|
+
"dateFormat",
|
|
2159
|
+
"typeRewardTiming",
|
|
2160
|
+
"limitUnit",
|
|
2161
|
+
"limitInterval",
|
|
2162
|
+
"urlAccount",
|
|
2163
|
+
"reviewApp",
|
|
2164
|
+
"descriptionReview",
|
|
2165
|
+
"extraPoints",
|
|
2166
|
+
"extraPointsForImages",
|
|
2167
|
+
"extraPointsForVideos",
|
|
2168
|
+
"isAppliedVipTier",
|
|
2169
|
+
"earnPointsTiers",
|
|
2170
|
+
"appliedPlaceOrderTo"
|
|
2171
|
+
];
|
|
2172
|
+
var REDEMPTION_FIELDS = [
|
|
2173
|
+
"event",
|
|
2174
|
+
"title",
|
|
2175
|
+
"status",
|
|
2176
|
+
"priority",
|
|
2177
|
+
"startDate",
|
|
2178
|
+
"endDate",
|
|
2179
|
+
"spendPoint",
|
|
2180
|
+
"earnAmount",
|
|
2181
|
+
"redeemType",
|
|
2182
|
+
"shippingDiscountType",
|
|
2183
|
+
"shippingDiscountAmount",
|
|
2184
|
+
"appliedTo",
|
|
2185
|
+
"specificProducts",
|
|
2186
|
+
"specificCollections",
|
|
2187
|
+
"appliedCollectionIds",
|
|
2188
|
+
"collectionQuantity"
|
|
2189
|
+
];
|
|
2190
|
+
function withoutUnsetFields(body) {
|
|
2191
|
+
return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== void 0));
|
|
2192
|
+
}
|
|
2193
|
+
function missingFields(event, fields) {
|
|
2194
|
+
return { ok: false, error: `Creating ${event} requires: ${fields.join(", ")}.` };
|
|
2195
|
+
}
|
|
2196
|
+
function unsupportedCreate(event) {
|
|
2197
|
+
return {
|
|
2198
|
+
ok: false,
|
|
2199
|
+
error: `Cannot create the ${event} program through this tool. Create it once in Joy admin, then call this tool with its program_id to tune points/status/priority/dates.`
|
|
2200
|
+
};
|
|
2201
|
+
}
|
|
2202
|
+
function vipTierFlagError(args, current = {}) {
|
|
2203
|
+
if (!args.earn_points_tiers)
|
|
2204
|
+
return void 0;
|
|
2205
|
+
if (Object.keys(args.earn_points_tiers).length === 0) {
|
|
2206
|
+
return "earn_points_tiers is empty. Provide at least one tier, or omit it.";
|
|
2207
|
+
}
|
|
2208
|
+
const willBeApplied = args.is_applied_vip_tier ?? current.isAppliedVipTier === true;
|
|
2209
|
+
if (!willBeApplied) {
|
|
2210
|
+
return "earn_points_tiers was given but per-tier earning is off for this rule, so the backend would ignore the whole map and keep using the flat earn_point. Pass is_applied_vip_tier: true in the same call to actually apply it.";
|
|
2211
|
+
}
|
|
2212
|
+
return void 0;
|
|
2213
|
+
}
|
|
2214
|
+
function unknownTierIds(earnPointsTiers, knownTierIds) {
|
|
2215
|
+
if (!earnPointsTiers)
|
|
2216
|
+
return [];
|
|
2217
|
+
const known = new Set(knownTierIds);
|
|
2218
|
+
return Object.keys(earnPointsTiers).filter((tierId) => !known.has(tierId));
|
|
2219
|
+
}
|
|
2220
|
+
function baseEarningBody(args) {
|
|
2221
|
+
return withoutUnsetFields({
|
|
2222
|
+
event: args.event,
|
|
2223
|
+
title: args.title,
|
|
2224
|
+
status: args.status,
|
|
2225
|
+
priority: args.priority,
|
|
2226
|
+
startDate: args.start_date,
|
|
2227
|
+
endDate: args.end_date,
|
|
2228
|
+
earnBy: args.earn_by,
|
|
2229
|
+
rateMoney: args.rate_money,
|
|
2230
|
+
rateItem: args.rate_item,
|
|
2231
|
+
earnPoint: args.earn_point,
|
|
2232
|
+
dateFormat: args.date_format,
|
|
2233
|
+
typeRewardTiming: args.type_reward_timing,
|
|
2234
|
+
limitUnit: args.limit_unit,
|
|
2235
|
+
limitInterval: args.limit_interval,
|
|
2236
|
+
urlAccount: args.url_account,
|
|
2237
|
+
reviewApp: args.review_app,
|
|
2238
|
+
descriptionReview: args.description_review,
|
|
2239
|
+
extraPointsForImages: args.extra_points_for_images,
|
|
2240
|
+
extraPointsForVideos: args.extra_points_for_videos,
|
|
2241
|
+
isAppliedVipTier: args.is_applied_vip_tier,
|
|
2242
|
+
appliedPlaceOrderTo: args.applied_place_order_to,
|
|
2243
|
+
earnPointsTiers: args.earn_points_tiers ? Object.fromEntries(Object.entries(args.earn_points_tiers).map(([tierId, rate]) => [
|
|
2244
|
+
tierId,
|
|
2245
|
+
withoutUnsetFields({
|
|
2246
|
+
earnPoint: rate.earn_point,
|
|
2247
|
+
rateMoney: rate.rate_money,
|
|
2248
|
+
typeEarn: rate.type_earn
|
|
2249
|
+
})
|
|
2250
|
+
])) : void 0
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
function baseRedemptionBody(args) {
|
|
2254
|
+
return withoutUnsetFields({
|
|
2255
|
+
event: args.event,
|
|
2256
|
+
title: args.title,
|
|
2257
|
+
status: args.status,
|
|
2258
|
+
priority: args.priority,
|
|
2259
|
+
startDate: args.start_date,
|
|
2260
|
+
endDate: args.end_date,
|
|
2261
|
+
spendPoint: args.spend_point,
|
|
2262
|
+
earnAmount: args.earn_amount,
|
|
2263
|
+
shippingDiscountType: args.shipping_discount_type,
|
|
2264
|
+
shippingDiscountAmount: args.shipping_discount_amount,
|
|
2265
|
+
appliedTo: args.applied_to,
|
|
2266
|
+
specificProducts: args.specific_products,
|
|
2267
|
+
specificCollections: args.specific_collections,
|
|
2268
|
+
appliedCollectionIds: args.applied_collection_ids,
|
|
2269
|
+
collectionQuantity: args.collection_quantity
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
function freeGiftProgramSelectionError(args) {
|
|
2273
|
+
if (args.event !== "free_gift")
|
|
2274
|
+
return void 0;
|
|
2275
|
+
if (!args.applied_to) {
|
|
2276
|
+
return "Creating free_gift requires applied_to. Ask the merchant which product or collection is the gift; product and collection IDs must come from the merchant and cannot be invented.";
|
|
2277
|
+
}
|
|
2278
|
+
if (!FREE_GIFT_PROGRAM_APPLIED_TO.includes(args.applied_to)) {
|
|
2279
|
+
return `free_gift applied_to must be one of: ${FREE_GIFT_PROGRAM_APPLIED_TO.join(", ")}. Tier-reward values such as "all" and "specific_product" do not apply here.`;
|
|
2280
|
+
}
|
|
2281
|
+
if (args.applied_to === "sf_product" && !args.specific_products?.length) {
|
|
2282
|
+
return 'Creating free_gift with applied_to "sf_product" requires specific_products. Ask the merchant for the product IDs; product IDs cannot be invented.';
|
|
2283
|
+
}
|
|
2284
|
+
if ((args.applied_to === "specific" || args.applied_to === "exclude_collection") && !args.applied_collection_ids?.length) {
|
|
2285
|
+
return `Creating free_gift with applied_to "${args.applied_to}" requires applied_collection_ids. Ask the merchant for the collection IDs; product or collection IDs cannot be invented.`;
|
|
2286
|
+
}
|
|
2287
|
+
if ((args.applied_to === "specific" || args.applied_to === "exclude_collection") && (!Number.isInteger(args.collection_quantity) || (args.collection_quantity ?? 0) < 1)) {
|
|
2288
|
+
return `Creating free_gift with applied_to "${args.applied_to}" requires collection_quantity as a positive integer.`;
|
|
2289
|
+
}
|
|
2290
|
+
return void 0;
|
|
2291
|
+
}
|
|
2292
|
+
function toEarningProgramChange(args) {
|
|
2293
|
+
const fractionalError = fractionalTierRateError(args.earn_points_tiers);
|
|
2294
|
+
if (fractionalError)
|
|
2295
|
+
return { ok: false, error: fractionalError };
|
|
2296
|
+
const body = baseEarningBody(args);
|
|
2297
|
+
if (args.program_id) {
|
|
2298
|
+
return { ok: true, action: "UPDATE", programId: args.program_id, body, dropped: droppedFields(body, EARNING_FIELDS) };
|
|
2299
|
+
}
|
|
2300
|
+
const vipFlagError = vipTierFlagError(args);
|
|
2301
|
+
if (vipFlagError)
|
|
2302
|
+
return { ok: false, error: vipFlagError };
|
|
2303
|
+
if (!args.event)
|
|
2304
|
+
return { ok: false, error: "event is required when creating an earning program." };
|
|
2305
|
+
if (!EARNING_CREATE_EVENTS.has(args.event))
|
|
2306
|
+
return unsupportedCreate(args.event);
|
|
2307
|
+
if (!args.title)
|
|
2308
|
+
return missingFields(args.event, ["title"]);
|
|
2309
|
+
if (args.event === "place_order") {
|
|
2310
|
+
const earnBy = args.earn_by ?? "price";
|
|
2311
|
+
if (!["price", "fixed_order", "quantity", "order"].includes(earnBy)) {
|
|
2312
|
+
return { ok: false, error: "place_order earn_by must be price, fixed_order, quantity, or order." };
|
|
1828
2313
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
content: {
|
|
1834
|
-
type: "text",
|
|
1835
|
-
text: [
|
|
1836
|
-
`A customer (${email}) reports they did not receive loyalty points${order_number ? ` for order ${order_number}` : ""}. Investigate before changing anything:`,
|
|
1837
|
-
"",
|
|
1838
|
-
"1. Look up the member with joy_find_member \u2014 flag anything unusual (disabled state, guest, recently created).",
|
|
1839
|
-
`2. Check joy_get_member_activity for an earn entry matching the order${order_number ? "" : " they describe (ask me for the order number if you cannot identify it)"}.`,
|
|
1840
|
-
order_number ? `3. Run joy_explain_order_points for ${order_number} \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.` : "3. Once the order is identified, run joy_explain_order_points on it \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.",
|
|
1841
|
-
"4. Conclude with exactly one of: (a) points were already awarded (show the activity entry), (b) points were correctly not awarded (show the blocking gate/rule \u2014 cite its configured threshold or condition, do not paraphrase from memory), or (c) points are genuinely missing.",
|
|
1842
|
-
"5. Only for case (c): state the exact number of points owed and propose a joy_award_points call with a reason that names the order and the root cause. Wait for my explicit confirmation before executing \u2014 never award in the same turn as the diagnosis.",
|
|
1843
|
-
"",
|
|
1844
|
-
"Caveat to include when you rely on joy_explain_order_points: it recomputes against the order as it exists now \u2014 if the order changed after the original webhook (e.g. partial refund), the explanation reflects the current state, not the historical moment."
|
|
1845
|
-
].join("\n")
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
]
|
|
1849
|
-
}));
|
|
1850
|
-
server.registerPrompt("program_review", {
|
|
1851
|
-
title: "Loyalty program review (health + revenue + tiers + referral)",
|
|
1852
|
-
description: "Periodic business review: program health check, revenue impact, tier performance, and referral results \u2014 with data-source rules so numbers from different systems are never mixed.",
|
|
1853
|
-
argsSchema: {
|
|
1854
|
-
period: z12.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
|
|
2314
|
+
if (args.earn_point === void 0)
|
|
2315
|
+
return missingFields(args.event, ["earn_point"]);
|
|
2316
|
+
if (["price", "fixed_order"].includes(earnBy) && args.rate_money === void 0) {
|
|
2317
|
+
return missingFields(args.event, ["rate_money"]);
|
|
1855
2318
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
{
|
|
1859
|
-
role: "user",
|
|
1860
|
-
content: {
|
|
1861
|
-
type: "text",
|
|
1862
|
-
text: [
|
|
1863
|
-
`Review my loyalty program for ${period ?? "the last 30 days"}. Run, in parallel where possible:`,
|
|
1864
|
-
"",
|
|
1865
|
-
"1. joy_get_loyalty_health_check \u2014 overall score and flags.",
|
|
1866
|
-
"2. joy_get_loyalty_revenue_report \u2014 revenue impact (Shopify sales data).",
|
|
1867
|
-
"3. joy_get_tier_performance_breakdown \u2014 which VIP tier earns its keep.",
|
|
1868
|
-
"4. joy_get_analytics_report with report_type referral_summary, and points_movement for the same period.",
|
|
1869
|
-
"",
|
|
1870
|
-
"Data-source rules (important):",
|
|
1871
|
-
"- joy_get_loyalty_revenue_report and joy_get_tier_performance_breakdown read Shopify sales data (ShopifyQL); joy_get_analytics_report reads the Joy event log (BigQuery). These measure differently \u2014 label the source of every figure and never combine numbers across the two sources into one derived metric.",
|
|
1872
|
-
"- If a ShopifyQL tool returns a 403 about the read_reports scope, relay its grant instructions instead of substituting numbers from the other source.",
|
|
1873
|
-
"- If any response carries a backfill WARNING, repeat that caveat next to the affected numbers \u2014 do not present them as final.",
|
|
1874
|
-
"",
|
|
1875
|
-
"Format: a 3-sentence executive summary first, then anomalies worth my attention (with the flag or figure that triggered each), then exactly one concrete recommended action. Skip metrics that are unremarkable."
|
|
1876
|
-
].join("\n")
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
]
|
|
1880
|
-
}));
|
|
1881
|
-
server.registerPrompt("investigate_member", {
|
|
1882
|
-
title: "Investigate a member for abuse risk",
|
|
1883
|
-
description: "Fraud/abuse triage for one member: risk flags, point activity, and referral history \u2014 with the mandatory caveat about signal limitations and no punitive action.",
|
|
1884
|
-
argsSchema: {
|
|
1885
|
-
email: z12.string().describe("Email of the member to investigate")
|
|
2319
|
+
if (earnBy === "quantity" && args.rate_item === void 0) {
|
|
2320
|
+
return missingFields(args.event, ["rate_item"]);
|
|
1886
2321
|
}
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
2322
|
+
const createBody2 = {
|
|
2323
|
+
...body,
|
|
2324
|
+
earnBy,
|
|
2325
|
+
typeEarn: "point",
|
|
2326
|
+
...placeOrderVipTierPatch(args.event, args)
|
|
2327
|
+
};
|
|
2328
|
+
return {
|
|
2329
|
+
ok: true,
|
|
2330
|
+
action: "CREATE",
|
|
2331
|
+
body: createBody2,
|
|
2332
|
+
dropped: droppedFields(createBody2, EARNING_FIELDS)
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
if (args.event === "sign_up") {
|
|
2336
|
+
if (args.earn_point === void 0)
|
|
2337
|
+
return missingFields(args.event, ["earn_point"]);
|
|
2338
|
+
const createBody2 = { ...body, typeEarn: "point" };
|
|
2339
|
+
return { ok: true, action: "CREATE", body: createBody2, dropped: droppedFields(createBody2, EARNING_FIELDS) };
|
|
2340
|
+
}
|
|
2341
|
+
if (args.event === "birthday") {
|
|
2342
|
+
if (args.earn_point === void 0)
|
|
2343
|
+
return missingFields(args.event, ["earn_point"]);
|
|
2344
|
+
if (!args.date_format)
|
|
2345
|
+
return missingFields(args.event, ["date_format"]);
|
|
2346
|
+
if (!["MM/DD", "DD/MM", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"].includes(args.date_format)) {
|
|
2347
|
+
return { ok: false, error: "birthday date_format is invalid." };
|
|
2348
|
+
}
|
|
2349
|
+
if (!args.type_reward_timing)
|
|
2350
|
+
return missingFields(args.event, ["type_reward_timing"]);
|
|
2351
|
+
if (!["exact_birthday", "begin_of_month", "around_birthday"].includes(args.type_reward_timing)) {
|
|
2352
|
+
return { ok: false, error: "birthday type_reward_timing is invalid." };
|
|
2353
|
+
}
|
|
2354
|
+
const createBody2 = { ...body, typeEarn: "point", isUseLayoutMultiRewards: false };
|
|
2355
|
+
return { ok: true, action: "CREATE", body: createBody2, dropped: droppedFields(createBody2, EARNING_FIELDS) };
|
|
2356
|
+
}
|
|
2357
|
+
if (SIMPLE_SOCIAL_EVENTS.has(args.event)) {
|
|
2358
|
+
if (args.earn_point === void 0)
|
|
2359
|
+
return missingFields(args.event, ["earn_point"]);
|
|
2360
|
+
if (!args.url_account) {
|
|
2361
|
+
return {
|
|
2362
|
+
ok: false,
|
|
2363
|
+
error: `Creating ${args.event} requires url_account \u2014 the full URL of the shop's profile on that network (e.g. https://instagram.com/yourbrand). Ask the merchant for it; do not invent one.`
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
const createBody2 = { ...body, typeEarn: "point" };
|
|
2367
|
+
return { ok: true, action: "CREATE", body: createBody2, dropped: droppedFields(createBody2, EARNING_FIELDS) };
|
|
2368
|
+
}
|
|
2369
|
+
if (args.event === "write_review") {
|
|
2370
|
+
if (args.earn_point === void 0)
|
|
2371
|
+
return missingFields(args.event, ["earn_point"]);
|
|
2372
|
+
if (!args.review_app) {
|
|
2373
|
+
return {
|
|
2374
|
+
ok: false,
|
|
2375
|
+
error: `Creating write_review requires review_app \u2014 which review app the shop runs. One of: ${REVIEW_APPS.join(", ")}. Ask the merchant which one is installed; the wrong value means reviews never award points.`
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
if (REVIEW_FLOW_APPS.has(args.review_app) && !args.description_review) {
|
|
2379
|
+
return {
|
|
2380
|
+
ok: false,
|
|
2381
|
+
error: `review_app "${args.review_app}" awards points through Shopify Flow, so description_review is required.`
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
const wantsMediaBonus = args.extra_points_for_images !== void 0 || args.extra_points_for_videos !== void 0;
|
|
2385
|
+
if (wantsMediaBonus && !REVIEW_PHOTO_VIDEO_APPS.has(args.review_app)) {
|
|
2386
|
+
return {
|
|
2387
|
+
ok: false,
|
|
2388
|
+
error: `Photo/video review bonuses only work with reviewRocketApp or looxApp \u2014 "${args.review_app}" does not report them, so those points would never be awarded. Remove extra_points_for_images/extra_points_for_videos, or confirm the shop's review app.`
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
const createBody2 = { ...body, typeEarn: "point" };
|
|
2392
|
+
return { ok: true, action: "CREATE", body: createBody2, dropped: droppedFields(createBody2, EARNING_FIELDS) };
|
|
2393
|
+
}
|
|
2394
|
+
if (args.earn_point === void 0)
|
|
2395
|
+
return missingFields(args.event, ["earn_point"]);
|
|
2396
|
+
const createBody = {
|
|
2397
|
+
...body,
|
|
2398
|
+
limitUnit: args.limit_unit ?? "day",
|
|
2399
|
+
limitInterval: args.limit_interval ?? 1
|
|
2400
|
+
};
|
|
2401
|
+
return { ok: true, action: "CREATE", body: createBody, dropped: droppedFields(createBody, EARNING_FIELDS) };
|
|
1908
2402
|
}
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
2403
|
+
function toRedemptionProgramChange(args) {
|
|
2404
|
+
const body = baseRedemptionBody(args);
|
|
2405
|
+
if (args.program_id) {
|
|
2406
|
+
return { ok: true, action: "UPDATE", programId: args.program_id, body, dropped: droppedFields(body, REDEMPTION_FIELDS) };
|
|
2407
|
+
}
|
|
2408
|
+
if (!args.event)
|
|
2409
|
+
return { ok: false, error: "event is required when creating a redemption program." };
|
|
2410
|
+
if (!REDEMPTION_CREATE_EVENTS.has(args.event))
|
|
2411
|
+
return unsupportedCreate(args.event);
|
|
2412
|
+
if (!args.title)
|
|
2413
|
+
return missingFields(args.event, ["title"]);
|
|
2414
|
+
if (!Number.isInteger(args.spend_point) || (args.spend_point ?? 0) <= 0) {
|
|
2415
|
+
return missingFields(args.event, ["spend_point (a positive integer)"]);
|
|
2416
|
+
}
|
|
2417
|
+
if (args.event === "free_gift") {
|
|
2418
|
+
const selectionError = freeGiftProgramSelectionError(args);
|
|
2419
|
+
if (selectionError)
|
|
2420
|
+
return { ok: false, error: selectionError };
|
|
2421
|
+
return { ok: true, action: "CREATE", body, dropped: droppedFields(body, REDEMPTION_FIELDS) };
|
|
2422
|
+
}
|
|
2423
|
+
if (args.event === "amount_discount") {
|
|
2424
|
+
if (args.earn_amount === void 0)
|
|
2425
|
+
return missingFields(args.event, ["earn_amount"]);
|
|
2426
|
+
const createBody = { ...body, redeemType: "fixed" };
|
|
2427
|
+
return { ok: true, action: "CREATE", body: createBody, dropped: droppedFields(createBody, REDEMPTION_FIELDS) };
|
|
2428
|
+
}
|
|
2429
|
+
if (args.event === "percentage_discount") {
|
|
2430
|
+
if (args.earn_amount === void 0)
|
|
2431
|
+
return missingFields(args.event, ["earn_amount"]);
|
|
2432
|
+
if (args.earn_amount >= 100) {
|
|
2433
|
+
return { ok: false, error: "percentage_discount earn_amount must be less than 100." };
|
|
2434
|
+
}
|
|
2435
|
+
return { ok: true, action: "CREATE", body, dropped: droppedFields(body, REDEMPTION_FIELDS) };
|
|
2436
|
+
}
|
|
2437
|
+
if (!args.shipping_discount_type)
|
|
2438
|
+
return missingFields(args.event, ["shipping_discount_type"]);
|
|
2439
|
+
if (!["percentage", "fixedAmount"].includes(args.shipping_discount_type)) {
|
|
2440
|
+
return { ok: false, error: "free_shipping shipping_discount_type must be percentage or fixedAmount." };
|
|
2441
|
+
}
|
|
2442
|
+
if (args.shipping_discount_amount === void 0)
|
|
2443
|
+
return missingFields(args.event, ["shipping_discount_amount"]);
|
|
2444
|
+
if (args.shipping_discount_type === "percentage" && args.shipping_discount_amount > 100) {
|
|
2445
|
+
return { ok: false, error: "free_shipping shipping_discount_amount must be 100 or less for percentage." };
|
|
2446
|
+
}
|
|
2447
|
+
return { ok: true, action: "CREATE", body, dropped: droppedFields(body, REDEMPTION_FIELDS) };
|
|
2448
|
+
}
|
|
2449
|
+
function toProgramList(value) {
|
|
2450
|
+
if (Array.isArray(value))
|
|
2451
|
+
return value.filter(isProgramRecord);
|
|
2452
|
+
if (!isProgramRecord(value))
|
|
2453
|
+
return [];
|
|
2454
|
+
if (Array.isArray(value.items))
|
|
2455
|
+
return value.items.filter(isProgramRecord);
|
|
2456
|
+
if (Array.isArray(value.programs))
|
|
2457
|
+
return value.programs.filter(isProgramRecord);
|
|
2458
|
+
return [];
|
|
2459
|
+
}
|
|
2460
|
+
function isProgramRecord(value) {
|
|
2461
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2462
|
+
}
|
|
2463
|
+
function unsupportedUpdateProgramType(kind, current) {
|
|
2464
|
+
const actualType = typeof current.type === "string" && current.type ? current.type : "unknown";
|
|
2465
|
+
const expectedType = PROGRAM_TYPE_BY_KIND[kind];
|
|
2466
|
+
if (actualType === expectedType)
|
|
2467
|
+
return void 0;
|
|
2468
|
+
return `Refusing to update this ${kind} program: its actual type is "${actualType}", not "${expectedType}". Configure ${actualType === "unknown" ? "this program" : `the ${actualType} program`} ${actualType === "referral" ? "with joy_configure_referral_program" : "in Joy admin"} instead; this tool only supports ${kind} programs and must not partially update another program type.`;
|
|
2469
|
+
}
|
|
2470
|
+
function buildProgramChangePreview(params) {
|
|
2471
|
+
const { kind, args, prepared, current = {}, existing = [] } = params;
|
|
2472
|
+
const title = String(prepared.body.title ?? current.title ?? prepared.body.event ?? "unnamed");
|
|
2473
|
+
const duplicate = prepared.action === "CREATE" ? findDuplicate(existing, { event: String(prepared.body.event ?? ""), title: args.title }) : void 0;
|
|
2474
|
+
return buildChangePreview({
|
|
2475
|
+
action: prepared.action,
|
|
2476
|
+
target: `${kind} program "${title}"`,
|
|
2477
|
+
reason: args.reason,
|
|
2478
|
+
changes: diffFields(current, prepared.body),
|
|
2479
|
+
dropped: prepared.dropped,
|
|
2480
|
+
duplicate
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
function isProgramChangeNoop(current, prepared) {
|
|
2484
|
+
return diffFields(current, prepared.body).length === 0;
|
|
2485
|
+
}
|
|
2486
|
+
var programConfigInputSchema = {
|
|
2487
|
+
program_id: z13.string().min(1).optional().describe("Existing Joy program ID. Omit to create a new program."),
|
|
2488
|
+
event: z13.string().optional().describe("Program event. Required for create; optional for update."),
|
|
2489
|
+
title: z13.string().min(1).optional().describe("Customer-facing program title."),
|
|
2490
|
+
status: z13.boolean().optional().describe("Set false to disable this rule instead of deleting it."),
|
|
2491
|
+
priority: z13.number().int().optional().describe("Optional program priority."),
|
|
2492
|
+
start_date: z13.string().optional().describe("Optional ISO start date."),
|
|
2493
|
+
end_date: z13.string().optional().describe("Optional ISO end date."),
|
|
2494
|
+
earn_by: z13.enum(["price", "fixed_order", "quantity", "order"]).optional(),
|
|
2495
|
+
rate_money: z13.number().optional(),
|
|
2496
|
+
rate_item: z13.number().optional(),
|
|
2497
|
+
earn_point: z13.number().optional(),
|
|
2498
|
+
date_format: z13.enum(["MM/DD", "DD/MM", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"]).optional(),
|
|
2499
|
+
type_reward_timing: z13.enum(["exact_birthday", "begin_of_month", "around_birthday"]).optional(),
|
|
2500
|
+
limit_unit: z13.string().optional(),
|
|
2501
|
+
limit_interval: z13.number().int().positive().optional(),
|
|
2502
|
+
url_account: z13.string().regex(/^https?:\/\//, "url_account must start with http:// or https://").optional().describe("REQUIRED when creating a social rule (follow_instagram, subscribe_youtube, like_facebook, follow_tiktok, follow_twitter, follow_pinterest, join_discord, join_telegram, join_reddit). The full URL of the shop's profile on that network \u2014 customers are sent here to complete the action."),
|
|
2503
|
+
review_app: z13.enum(REVIEW_APPS).optional().describe("REQUIRED when creating a write_review rule: the review app the shop actually runs. Points cannot be awarded for reviews unless this matches the installed app."),
|
|
2504
|
+
description_review: z13.string().optional().describe("Required only when review_app is a Shopify Flow app (stampedApp, looxApp, okendoApp, ReviewsIoApp, growaveApp, typdalApp) \u2014 the merchant-facing note describing how the Flow awards points."),
|
|
2505
|
+
extra_points_for_images: z13.number().int().nonnegative().optional().describe("Bonus points for a review with a photo. Only reviewRocketApp and looxApp report photos; ignored for every other review app."),
|
|
2506
|
+
extra_points_for_videos: z13.number().int().nonnegative().optional().describe("Bonus points for a review with a video. Only reviewRocketApp and looxApp report videos; ignored for every other review app."),
|
|
2507
|
+
spend_point: z13.number().int().positive().optional(),
|
|
2508
|
+
earn_amount: z13.number().optional(),
|
|
2509
|
+
shipping_discount_type: z13.enum(["percentage", "fixedAmount"]).optional(),
|
|
2510
|
+
shipping_discount_amount: z13.number().optional(),
|
|
2511
|
+
applied_to: z13.enum(FREE_GIFT_PROGRAM_APPLIED_TO).optional().describe("free_gift only \u2014 sf_product, specific, or exclude_collection. This is a program-level enum and differs from tier rewards; it has no all value."),
|
|
2512
|
+
specific_products: z13.array(z13.unknown()).optional().describe("free_gift with applied_to sf_product only \u2014 the merchant-provided product records/IDs. Do not invent product IDs."),
|
|
2513
|
+
specific_collections: z13.array(z13.unknown()).optional(),
|
|
2514
|
+
applied_collection_ids: z13.array(z13.string()).optional().describe("free_gift with applied_to specific or exclude_collection only \u2014 merchant-provided collection IDs."),
|
|
2515
|
+
collection_quantity: z13.number().int().positive().optional().describe("Required for collection-based free_gift rules (applied_to specific or exclude_collection)."),
|
|
2516
|
+
product_url: z13.array(z13.string()).optional().describe("free_gift with applied_to sf_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
|
|
2517
|
+
applied_place_order_to: z13.enum(["all", "vip-tier", "member_and_guest"]).optional().describe(`place_order only \u2014 the admin's "Who to reward" setting. Leave unset: enabling per-tier earning sets 'vip-tier' automatically, which legacy place_order programs REQUIRE (they predate isEventPlaceOrderV2 and ignore is_applied_vip_tier).`),
|
|
2518
|
+
is_applied_vip_tier: z13.boolean().optional().describe("Turn per-VIP-tier earning on for this rule. MUST be true for earn_points_tiers to have any effect \u2014 the backend reads the per-tier map only when this flag is set, and otherwise silently uses the flat earn_point."),
|
|
2519
|
+
earn_points_tiers: z13.record(z13.object({
|
|
2520
|
+
earn_point: z13.number().optional().describe("Points this tier earns, replacing the flat earn_point."),
|
|
2521
|
+
rate_money: z13.number().optional().describe("place_order only: money amount that earns earn_point for this tier."),
|
|
2522
|
+
type_earn: z13.enum(["point", "store_credit"]).optional()
|
|
2523
|
+
})).optional().describe("Per-VIP-tier earning, keyed by TIER ID (from joy_list_tiers) \u2014 not tier name. For place_order a \xD71.25 multiplier on a 1-point-per-$1 base is earn_point 1.25, not 1.25 as a factor: the value REPLACES the base rate. Requires is_applied_vip_tier: true."),
|
|
2524
|
+
reason: z13.string().min(3).describe("Why the merchant wants this configuration change."),
|
|
2525
|
+
apply: z13.boolean().optional().describe("Set true only after reviewing this tool's dry-run preview.")
|
|
2526
|
+
};
|
|
2527
|
+
var PLACE_ORDER_EVENTS = /* @__PURE__ */ new Set(["place_order", "place_order_subscription"]);
|
|
2528
|
+
function placeOrderVipTierPatch(event, args) {
|
|
2529
|
+
const enablingVipTier = args.is_applied_vip_tier === true || args.earn_points_tiers !== void 0;
|
|
2530
|
+
if (!enablingVipTier)
|
|
2531
|
+
return {};
|
|
2532
|
+
if (!PLACE_ORDER_EVENTS.has(String(event)))
|
|
2533
|
+
return {};
|
|
2534
|
+
if (args.applied_place_order_to)
|
|
2535
|
+
return {};
|
|
2536
|
+
return { appliedPlaceOrderTo: "vip-tier" };
|
|
2537
|
+
}
|
|
2538
|
+
function toIntegerRatio(earnPoint, rateMoney) {
|
|
2539
|
+
const isWhole = (value) => Math.abs(value - Math.round(value)) < 1e-9;
|
|
2540
|
+
for (let factor = 2; factor <= 1e3; factor++) {
|
|
2541
|
+
const points = earnPoint * factor;
|
|
2542
|
+
const money = rateMoney * factor;
|
|
2543
|
+
if (isWhole(points) && isWhole(money)) {
|
|
2544
|
+
return { earnPoint: Math.round(points), rateMoney: Math.round(money) };
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
return void 0;
|
|
2548
|
+
}
|
|
2549
|
+
function fractionalTierRateError(earnPointsTiers) {
|
|
2550
|
+
if (!earnPointsTiers)
|
|
2551
|
+
return void 0;
|
|
2552
|
+
const offenders = [];
|
|
2553
|
+
for (const [tierId, rate] of Object.entries(earnPointsTiers)) {
|
|
2554
|
+
const points = rate.earn_point;
|
|
2555
|
+
if (points === void 0 || Number.isInteger(points))
|
|
2556
|
+
continue;
|
|
2557
|
+
const ratio = toIntegerRatio(points, rate.rate_money ?? 1);
|
|
2558
|
+
offenders.push(ratio ? `${tierId}: earn_point ${points} \u2192 use earn_point ${ratio.earnPoint} with rate_money ${ratio.rateMoney}` : `${tierId}: earn_point ${points} (no clean integer ratio found)`);
|
|
2559
|
+
}
|
|
2560
|
+
if (offenders.length === 0)
|
|
2561
|
+
return void 0;
|
|
2562
|
+
return `Fractional earn_point does not create a multiplier. Points are (amount / rate_money) * earn_point, then rounded, and the admin's point field is integer-stepped \u2014 a fractional value silently rounds, so a tier earns the wrong amount. Express the multiplier as an integer RATIO instead: ${offenders.join("; ")}. The backend stores the ratio as sent and derives ratePercent from it (verified against live staging: earn_point 5 / rate_money 4 reads back as ratePercent 125), so no extra flag is needed to stop it collapsing.`;
|
|
2563
|
+
}
|
|
2564
|
+
function earnPointsTiersDropped(sent, saved) {
|
|
2565
|
+
if (!sent || Object.keys(sent).length === 0)
|
|
2566
|
+
return false;
|
|
2567
|
+
if (!saved || typeof saved !== "object")
|
|
2568
|
+
return true;
|
|
2569
|
+
return Object.keys(saved).length === 0;
|
|
2570
|
+
}
|
|
2571
|
+
async function vipTierIdError(ctx, args) {
|
|
2572
|
+
if (!args.earn_points_tiers)
|
|
2573
|
+
return void 0;
|
|
2574
|
+
const { data } = await ctx.api.getData("/tiers");
|
|
2575
|
+
const tiers = Array.isArray(data) ? data : [];
|
|
2576
|
+
const knownIds = tiers.map((tier) => String(tier.id));
|
|
2577
|
+
const unknown = unknownTierIds(args.earn_points_tiers, knownIds);
|
|
2578
|
+
if (unknown.length === 0)
|
|
2579
|
+
return void 0;
|
|
2580
|
+
const known = tiers.map((tier) => `${String(tier.id)} (${String(tier.name)})`).join(", ");
|
|
2581
|
+
return `earn_points_tiers is keyed by tier ID, and these keys are not tiers on this shop: ${unknown.join(", ")}. Points for those keys would never be awarded and the write would still succeed. Valid tiers: ${known}.`;
|
|
2582
|
+
}
|
|
2583
|
+
async function resolveProgramProductUrls(ctx, args) {
|
|
2584
|
+
if (!args.product_url?.length)
|
|
2585
|
+
return args;
|
|
2586
|
+
if (args.event !== "free_gift" || args.applied_to !== "sf_product") {
|
|
2587
|
+
throw new JoyApiError("validation", 'product_url is only supported for free_gift programs with applied_to "sf_product". Use merchant-provided product IDs for another selection mode.');
|
|
2588
|
+
}
|
|
2589
|
+
const resolvedProducts = await Promise.all(args.product_url.map(async (productUrl) => {
|
|
2590
|
+
const parsed = parseShopifyProductUrl(productUrl);
|
|
2591
|
+
if (!parsed.ok)
|
|
2592
|
+
throw new JoyApiError("validation", `${parsed.error} Received: ${productUrl}`);
|
|
2593
|
+
const { data } = await ctx.api.getData(`/products/resolve?handle=${encodeURIComponent(parsed.handle)}`);
|
|
2594
|
+
if (!data || !Number.isSafeInteger(data.id) || data.id <= 0) {
|
|
2595
|
+
throw new JoyApiError("not_found", `No Shopify product was found for handle "${parsed.handle}". Ask the merchant for a valid product link; never fabricate an ID.`);
|
|
2596
|
+
}
|
|
2597
|
+
return { id: data.id, title: data.title, handle: data.handle };
|
|
2598
|
+
}));
|
|
2599
|
+
return { ...args, specific_products: [...args.specific_products ?? [], ...resolvedProducts] };
|
|
2600
|
+
}
|
|
2601
|
+
async function executeProgramChange(ctx, kind, args, toChange) {
|
|
2602
|
+
const resolvedArgs = await resolveProgramProductUrls(ctx, args);
|
|
2603
|
+
const prepared = toChange(resolvedArgs);
|
|
2604
|
+
if (!prepared.ok)
|
|
2605
|
+
return prepared.error;
|
|
2606
|
+
if (prepared.action === "UPDATE") {
|
|
2607
|
+
const { data: data2 } = await ctx.api.getData(`/programs/${prepared.programId}`);
|
|
2608
|
+
const current = data2 ?? {};
|
|
2609
|
+
const flagError = vipTierFlagError(resolvedArgs, current);
|
|
2610
|
+
if (flagError)
|
|
2611
|
+
return flagError;
|
|
2612
|
+
const fractionError = fractionalTierRateError(resolvedArgs.earn_points_tiers);
|
|
2613
|
+
if (fractionError)
|
|
2614
|
+
return fractionError;
|
|
2615
|
+
Object.assign(prepared.body, placeOrderVipTierPatch(current.event, resolvedArgs));
|
|
2616
|
+
const tierError = await vipTierIdError(ctx, resolvedArgs);
|
|
2617
|
+
if (tierError)
|
|
2618
|
+
return tierError;
|
|
2619
|
+
const unsupportedType = unsupportedUpdateProgramType(kind, current);
|
|
2620
|
+
if (unsupportedType)
|
|
2621
|
+
return unsupportedType;
|
|
2622
|
+
const preview2 = buildProgramChangePreview({ kind, args: resolvedArgs, prepared, current });
|
|
2623
|
+
if (isProgramChangeNoop(current, prepared))
|
|
2624
|
+
return preview2;
|
|
2625
|
+
const stop2 = requireApply(resolvedArgs.apply, preview2);
|
|
2626
|
+
if (stop2)
|
|
2627
|
+
return stop2;
|
|
2628
|
+
await ctx.api.mutateData(`/programs/${prepared.programId}`, { method: "PUT", body: prepared.body });
|
|
2629
|
+
if (resolvedArgs.earn_points_tiers) {
|
|
2630
|
+
const { data: after } = await ctx.api.getData(`/programs/${prepared.programId}`);
|
|
2631
|
+
if (earnPointsTiersDropped(resolvedArgs.earn_points_tiers, after?.earnPointsTiers)) {
|
|
2632
|
+
return `Every other field saved, but earnPointsTiers did NOT: the backend stored an empty map. REST v2 declares earnPointsTiers as a shapeless Yup.object() and validates with stripUnknown, which strips every tier key. Per-VIP-tier earning cannot currently be set through this API by any caller \u2014 configure it in Joy admin, and report this so the schema gets a real shape. Do not retry; the result will be the same.`;
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
return `Updated ${kind} program ${prepared.programId}. This affects every customer immediately.`;
|
|
2636
|
+
}
|
|
2637
|
+
const createTierError = await vipTierIdError(ctx, resolvedArgs);
|
|
2638
|
+
if (createTierError)
|
|
2639
|
+
return createTierError;
|
|
2640
|
+
const { data } = await ctx.api.getData(`/programs/${kind}`);
|
|
2641
|
+
const preview = buildProgramChangePreview({ kind, args: resolvedArgs, prepared, existing: toProgramList(data) });
|
|
2642
|
+
const stop = requireApply(resolvedArgs.apply, preview);
|
|
2643
|
+
if (stop)
|
|
2644
|
+
return stop;
|
|
2645
|
+
await ctx.api.mutateData(`/programs/${kind}`, { method: "POST", body: prepared.body });
|
|
2646
|
+
return `Created ${kind} program. This affects every customer immediately.`;
|
|
2647
|
+
}
|
|
2648
|
+
function registerProgramConfigTools(server, ctx) {
|
|
2649
|
+
registerConfigWriteTool(server, ctx, {
|
|
2650
|
+
name: "joy_configure_earning_program",
|
|
2651
|
+
title: "Create or update an earning program",
|
|
2652
|
+
description: "WRITE \u2014 changes earning behaviour for every customer immediately. Creates place_order, sign_up, birthday, interact_website, write_review, and the nine social-follow rules (Instagram, YouTube, TikTok, Facebook, X/Twitter, Pinterest, Discord, Telegram, Reddit); social rules need url_account and write_review needs review_app. Use program_id to update ANY existing earning rule, including types this tool cannot create. Previews by default. Joy has no safe delete here: set status: false to reversibly disable a rule instead.",
|
|
2653
|
+
inputSchema: programConfigInputSchema,
|
|
2654
|
+
destructiveHint: true,
|
|
2655
|
+
handler: (args) => executeProgramChange(ctx, "earning", args, toEarningProgramChange)
|
|
2656
|
+
});
|
|
2657
|
+
registerConfigWriteTool(server, ctx, {
|
|
2658
|
+
name: "joy_configure_redemption_program",
|
|
2659
|
+
title: "Create or update a redemption program",
|
|
2660
|
+
description: "WRITE \u2014 changes redemption behaviour for every customer immediately. Creates amount_discount, percentage_discount, free_shipping, and free_gift rules. A free_gift accepts merchant-provided product_url values (Shopify product links) or product/collection IDs: program-level applied_to is sf_product, specific, or exclude_collection (never all). Joy resolves product-link handles and never invents IDs. Use program_id to update any existing redemption rule. Previews by default. Joy has no safe delete here: set status: false to reversibly disable a rule instead.",
|
|
2661
|
+
inputSchema: programConfigInputSchema,
|
|
2662
|
+
destructiveHint: true,
|
|
2663
|
+
handler: (args) => executeProgramChange(ctx, "redemption", args, toRedemptionProgramChange)
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// ../mcp-core/build/tools/tiersWrite.js
|
|
2668
|
+
import { z as z14 } from "zod";
|
|
2669
|
+
var TIER_THRESHOLD_WARNING = "Threshold changes are STAGED, not live. Members are not re-evaluated until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that.";
|
|
2670
|
+
var RESET_SCHEDULE_FIELDS = [
|
|
2671
|
+
"useResetTier",
|
|
2672
|
+
"resetFrequency",
|
|
2673
|
+
"resetIntervalValue",
|
|
2674
|
+
"resetIntervalUnit",
|
|
2675
|
+
"resetStartDate",
|
|
2676
|
+
"monthResetTier",
|
|
2677
|
+
"dayResetTier"
|
|
2678
|
+
];
|
|
2679
|
+
var TIER_UPDATE_FIELDS = [
|
|
2680
|
+
"name",
|
|
2681
|
+
"icon",
|
|
2682
|
+
"iconCustom",
|
|
2683
|
+
"bgImage",
|
|
2684
|
+
"defaultAvatar",
|
|
2685
|
+
"targetPointUpdate",
|
|
2686
|
+
"imageBlock",
|
|
2687
|
+
"isExclusiveTier",
|
|
2688
|
+
"hideDefaultTier",
|
|
2689
|
+
"textColor",
|
|
2690
|
+
"progressBarColor",
|
|
2691
|
+
"boxBgColor",
|
|
2692
|
+
"boxTextColor",
|
|
2693
|
+
"bgCardColor",
|
|
2694
|
+
"cardOpacity",
|
|
2695
|
+
"iconColor",
|
|
2696
|
+
"bgType",
|
|
2697
|
+
"tierNamesLanguage"
|
|
2698
|
+
];
|
|
2699
|
+
var TIER_REWARD_FIELDS = [
|
|
2700
|
+
"title",
|
|
2701
|
+
"status",
|
|
2702
|
+
"event",
|
|
2703
|
+
"type",
|
|
2704
|
+
"earnAmount",
|
|
2705
|
+
"spendPoint",
|
|
2706
|
+
"bonusPoints",
|
|
2707
|
+
"codeName",
|
|
2708
|
+
"appliedTo",
|
|
2709
|
+
"orderReq",
|
|
2710
|
+
"orderReqAmount",
|
|
2711
|
+
"specificProducts",
|
|
2712
|
+
"specificCollections",
|
|
2713
|
+
"appliedCollectionIds",
|
|
2714
|
+
"combinedWith",
|
|
2715
|
+
"hasLimitShipping",
|
|
2716
|
+
"limitShipping",
|
|
2717
|
+
"expiredAfter",
|
|
2718
|
+
"expiredUnit",
|
|
2719
|
+
"isRefundCoupon",
|
|
2720
|
+
"priority",
|
|
2721
|
+
"showLoyaltyPage",
|
|
2722
|
+
"perkTitleLanguage",
|
|
2723
|
+
"redeemType",
|
|
2724
|
+
"minSpendPoint",
|
|
2725
|
+
"maxSpendPoint",
|
|
2726
|
+
"rewardProductSelectionMode",
|
|
2727
|
+
"rewardProductQuantity"
|
|
2728
|
+
];
|
|
2729
|
+
var TIER_PERK_FIELDS = ["contentPerk"];
|
|
2730
|
+
var TIER_SETTINGS_FIELDS = [
|
|
2731
|
+
"useDemotionTier",
|
|
2732
|
+
"typeDemotion",
|
|
2733
|
+
"downgradeAfter",
|
|
2734
|
+
"downgradeAfterUnit",
|
|
2735
|
+
"useResetTier",
|
|
2736
|
+
"resetAfter",
|
|
2737
|
+
"resetAfterUnit",
|
|
2738
|
+
"resetFrequency",
|
|
2739
|
+
"resetIntervalValue",
|
|
2740
|
+
"resetIntervalUnit",
|
|
2741
|
+
"resetStartDate",
|
|
2742
|
+
"monthResetTier",
|
|
2743
|
+
"dayResetTier",
|
|
2744
|
+
"isSyncTagsCustomerTier",
|
|
2745
|
+
"customerTierTagPrefix",
|
|
2746
|
+
"notificationDowngradeBefore",
|
|
2747
|
+
"notificationDowngradeBeforeUnit"
|
|
2748
|
+
];
|
|
2749
|
+
var rewardEventSchema = z14.enum([
|
|
2750
|
+
"amount_discount",
|
|
2751
|
+
"percentage_discount",
|
|
2752
|
+
"free_shipping",
|
|
2753
|
+
"free_gift",
|
|
2754
|
+
"bonus_point",
|
|
2755
|
+
"buy_x_get_y"
|
|
2756
|
+
]);
|
|
2757
|
+
var finiteNumber = () => z14.number().finite();
|
|
2758
|
+
var tierRewardInputSchema = z14.object({
|
|
2759
|
+
event: rewardEventSchema.describe("Required reward kind."),
|
|
2760
|
+
title: z14.string().min(1).optional(),
|
|
2761
|
+
type: z14.enum(["tier_spending", "tier_privileges", "pre_tier_reward"]).optional(),
|
|
2762
|
+
status: z14.boolean().optional(),
|
|
2763
|
+
earn_amount: finiteNumber().optional(),
|
|
2764
|
+
spend_point: finiteNumber().optional(),
|
|
2765
|
+
bonus_points: finiteNumber().optional(),
|
|
2766
|
+
code_name: z14.string().optional(),
|
|
2767
|
+
redeem_type: z14.string().optional(),
|
|
2768
|
+
applied_to: z14.enum(["all", "specific_product", "specific"]).optional().describe("Tier-reward scope only: all, specific_product, or specific. These literals differ from program-level reward tools."),
|
|
2769
|
+
order_req: z14.enum(["none", "min_amount", "min_quantity"]).optional(),
|
|
2770
|
+
order_req_amount: finiteNumber().optional(),
|
|
2771
|
+
min_spend_point: finiteNumber().optional(),
|
|
2772
|
+
max_spend_point: finiteNumber().optional(),
|
|
2773
|
+
specific_products: z14.array(z14.unknown()).optional(),
|
|
2774
|
+
specific_collections: z14.array(z14.unknown()).optional(),
|
|
2775
|
+
applied_collection_ids: z14.array(z14.unknown()).optional(),
|
|
2776
|
+
combined_with: z14.array(z14.enum(["orderDiscounts", "shippingDiscounts", "productDiscounts"])).optional(),
|
|
2777
|
+
has_limit_shipping: z14.boolean().optional(),
|
|
2778
|
+
limit_shipping: finiteNumber().optional(),
|
|
2779
|
+
expired_after: finiteNumber().optional(),
|
|
2780
|
+
expired_unit: z14.enum(["day", "month"]).optional(),
|
|
2781
|
+
is_refund_coupon: z14.boolean().optional(),
|
|
2782
|
+
priority: finiteNumber().optional(),
|
|
2783
|
+
show_loyalty_page: z14.boolean().optional(),
|
|
2784
|
+
perk_title_language: z14.record(z14.unknown()).optional(),
|
|
2785
|
+
reward_product_selection_mode: z14.string().optional(),
|
|
2786
|
+
reward_product_quantity: finiteNumber().optional()
|
|
2787
|
+
});
|
|
2788
|
+
var tierConfigInputSchema = {
|
|
2789
|
+
tier_id: z14.string().min(1).optional().describe("Existing tier ID. Omit to create a tier."),
|
|
2790
|
+
name: z14.string().min(1).optional(),
|
|
2791
|
+
target_point: finiteNumber().nonnegative().optional().describe("Tier threshold. On an existing tier this is staged; it does not immediately re-evaluate members."),
|
|
2792
|
+
icon: z14.string().optional(),
|
|
2793
|
+
icon_custom: z14.string().optional(),
|
|
2794
|
+
bg_image: z14.string().optional().describe("Membership card background image URL for this ONE tier. Pass bg_type: 'image' alongside bg_image for it to show, or '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
|
|
2795
|
+
default_avatar: z14.string().optional().describe("Membership card avatar image URL for members in this ONE tier. Pass '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
|
|
2796
|
+
image_block: z14.string().optional(),
|
|
2797
|
+
is_exclusive_tier: z14.boolean().optional(),
|
|
2798
|
+
hide_default_tier: z14.boolean().optional(),
|
|
2799
|
+
text_color: z14.string().optional(),
|
|
2800
|
+
progress_bar_color: z14.string().optional(),
|
|
2801
|
+
box_bg_color: z14.string().optional(),
|
|
2802
|
+
box_text_color: z14.string().optional(),
|
|
2803
|
+
bg_card_color: z14.string().optional(),
|
|
2804
|
+
card_opacity: finiteNumber().optional(),
|
|
2805
|
+
icon_color: z14.string().optional(),
|
|
2806
|
+
bg_type: z14.string().optional(),
|
|
2807
|
+
tier_names_language: z14.record(z14.unknown()).optional(),
|
|
2808
|
+
rewards: z14.array(tierRewardInputSchema).optional().describe("Optional rewards to create with a NEW tier in this one configuration write."),
|
|
2809
|
+
reason: z14.string().min(3).describe("Why the merchant wants this tier configuration."),
|
|
2810
|
+
apply: z14.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2811
|
+
};
|
|
2812
|
+
var tierRewardToolInputSchema = {
|
|
2813
|
+
tier_id: z14.string().min(1).describe("Tier ID from joy_list_tiers."),
|
|
2814
|
+
reward_id: z14.string().min(1).optional().describe("Existing reward ID. Omit to create."),
|
|
2815
|
+
...tierRewardInputSchema.shape,
|
|
2816
|
+
product_url: z14.array(z14.string()).optional().describe("free_gift with applied_to specific_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
|
|
2817
|
+
reason: z14.string().min(3).describe("Why the merchant wants this reward configuration."),
|
|
2818
|
+
apply: z14.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2819
|
+
};
|
|
2820
|
+
var tierSettingsInputSchema = {
|
|
2821
|
+
use_demotion_tier: z14.boolean().optional(),
|
|
2822
|
+
type_demotion: z14.enum(["downgrade_tier", "reset_tier"]).optional(),
|
|
2823
|
+
downgrade_after: z14.number().int().positive().optional(),
|
|
2824
|
+
downgrade_after_unit: z14.enum(["day", "month", "year"]).optional(),
|
|
2825
|
+
use_reset_tier: z14.boolean().optional(),
|
|
2826
|
+
reset_after: z14.number().int().positive().optional(),
|
|
2827
|
+
reset_after_unit: z14.enum(["day", "month", "year"]).optional(),
|
|
2828
|
+
reset_frequency: z14.enum(["monthly", "quarterly", "half_yearly", "yearly", "custom"]).optional(),
|
|
2829
|
+
reset_interval_value: z14.number().int().positive().optional(),
|
|
2830
|
+
reset_interval_unit: z14.enum(["day", "month", "year"]).optional(),
|
|
2831
|
+
reset_start_date: z14.string().min(1).optional(),
|
|
2832
|
+
month_reset_tier: z14.number().int().min(1).max(12).optional(),
|
|
2833
|
+
day_reset_tier: z14.number().int().min(1).max(31).optional(),
|
|
2834
|
+
is_sync_tags_customer_tier: z14.boolean().optional(),
|
|
2835
|
+
customer_tier_tag_prefix: z14.string().optional(),
|
|
2836
|
+
notification_downgrade_before: z14.number().int().positive().optional(),
|
|
2837
|
+
notification_downgrade_before_unit: z14.enum(["day", "month"]).optional(),
|
|
2838
|
+
reason: z14.string().min(3).describe("Why the merchant wants these tier settings."),
|
|
2839
|
+
apply: z14.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2840
|
+
};
|
|
2841
|
+
var tierPerksInputSchema = {
|
|
2842
|
+
tier_id: z14.string().min(1).describe("Tier ID from joy_list_tiers."),
|
|
2843
|
+
perks: z14.array(z14.string().min(1)).describe("The COMPLETE replacement list of display-only perks. Include every existing perk that should remain; omitted entries are removed, and [] clears the list. Each entry is perk text or an http(s) URL rendered as a tick/cross icon in the storefront benefits table."),
|
|
2844
|
+
reason: z14.string().min(3).describe("Why the merchant wants to replace this tier perk list."),
|
|
2845
|
+
apply: z14.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2846
|
+
};
|
|
2847
|
+
function tierFreeGiftSelectionError(args, requireAppliedTo = false) {
|
|
2848
|
+
if (args.event !== "free_gift")
|
|
2849
|
+
return void 0;
|
|
2850
|
+
if (!args.applied_to) {
|
|
2851
|
+
if (!requireAppliedTo)
|
|
2852
|
+
return void 0;
|
|
2853
|
+
return "Creating free_gift requires applied_to. Ask the merchant which product or collection is the gift; product and collection IDs must come from the merchant and cannot be invented.";
|
|
2854
|
+
}
|
|
2855
|
+
if (args.applied_to === "specific_product" && !args.specific_products?.length) {
|
|
2856
|
+
return 'Creating free_gift with applied_to "specific_product" requires specific_products. Ask the merchant for the product IDs; product IDs cannot be invented.';
|
|
2857
|
+
}
|
|
2858
|
+
if (args.applied_to === "specific" && !args.applied_collection_ids?.length) {
|
|
2859
|
+
return 'Creating free_gift with applied_to "specific" requires applied_collection_ids. Ask the merchant for the collection IDs; product or collection IDs cannot be invented.';
|
|
2860
|
+
}
|
|
2861
|
+
return void 0;
|
|
2862
|
+
}
|
|
2863
|
+
function toTierConfigBody(args, action) {
|
|
2864
|
+
const body = {
|
|
2865
|
+
name: args.name,
|
|
2866
|
+
icon: args.icon,
|
|
2867
|
+
iconCustom: args.icon_custom,
|
|
2868
|
+
bgImage: args.bg_image,
|
|
2869
|
+
defaultAvatar: args.default_avatar,
|
|
2870
|
+
imageBlock: args.image_block,
|
|
2871
|
+
isExclusiveTier: args.is_exclusive_tier,
|
|
2872
|
+
hideDefaultTier: args.hide_default_tier,
|
|
2873
|
+
textColor: args.text_color,
|
|
2874
|
+
progressBarColor: args.progress_bar_color,
|
|
2875
|
+
boxBgColor: args.box_bg_color,
|
|
2876
|
+
boxTextColor: args.box_text_color,
|
|
2877
|
+
bgCardColor: args.bg_card_color,
|
|
2878
|
+
cardOpacity: args.card_opacity,
|
|
2879
|
+
iconColor: args.icon_color,
|
|
2880
|
+
bgType: args.bg_type,
|
|
2881
|
+
tierNamesLanguage: args.tier_names_language
|
|
2882
|
+
};
|
|
2883
|
+
if (action === "CREATE") {
|
|
2884
|
+
body.targetPoint = args.target_point;
|
|
2885
|
+
body.rewards = args.rewards?.map(toTierRewardFields);
|
|
2886
|
+
return omitUndefined(body);
|
|
2887
|
+
}
|
|
2888
|
+
body.targetPointUpdate = args.target_point;
|
|
2889
|
+
return omitUndefined(body);
|
|
2890
|
+
}
|
|
2891
|
+
function toTierUpdateBody(args) {
|
|
2892
|
+
return toTierConfigBody(args, "UPDATE");
|
|
2893
|
+
}
|
|
2894
|
+
function assertValidTierConfigArgs(args) {
|
|
2895
|
+
if (!args.tier_id && !args.name) {
|
|
2896
|
+
throw new JoyApiError("validation", "Creating a tier requires name.");
|
|
2897
|
+
}
|
|
2898
|
+
if (!args.tier_id && args.target_point === void 0) {
|
|
2899
|
+
throw new JoyApiError("validation", "Creating a tier requires target_point.");
|
|
2900
|
+
}
|
|
2901
|
+
if (!args.tier_id && args.target_point === 0) {
|
|
2902
|
+
throw new JoyApiError("validation", "target_point 0 is reserved for the system Bronze tier. Choose a positive threshold for a new tier.");
|
|
2903
|
+
}
|
|
2904
|
+
if (!args.tier_id) {
|
|
2905
|
+
for (const reward of args.rewards ?? []) {
|
|
2906
|
+
const selectionError = tierFreeGiftSelectionError(reward, true);
|
|
2907
|
+
if (selectionError)
|
|
2908
|
+
throw new JoyApiError("validation", selectionError);
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
if (args.tier_id && Object.keys(toTierUpdateBody(args)).length === 0) {
|
|
2912
|
+
throw new JoyApiError("validation", "Choose at least one tier field to update.");
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
function findTierDuplicate(tiers, args) {
|
|
2916
|
+
const byName = findDuplicate(tiers.map((tier) => ({ ...tier, title: tier.name })), { title: args.name });
|
|
2917
|
+
if (byName)
|
|
2918
|
+
return byName;
|
|
2919
|
+
if (args.target_point === void 0)
|
|
2920
|
+
return void 0;
|
|
2921
|
+
return findDuplicate(tiers.map((tier) => ({ ...tier, title: String(tier.targetPoint) })), { title: String(args.target_point) });
|
|
2922
|
+
}
|
|
2923
|
+
function buildTierConfigPreview(params) {
|
|
2924
|
+
const action = params.args.tier_id ? "UPDATE" : "CREATE";
|
|
2925
|
+
const body = toTierConfigBody(params.args, action);
|
|
2926
|
+
const current = action === "UPDATE" ? {
|
|
2927
|
+
...params.current,
|
|
2928
|
+
targetPointUpdate: params.current?.targetPointUpdate ?? params.current?.targetPoint
|
|
2929
|
+
} : {};
|
|
2930
|
+
const dropped = action === "UPDATE" ? droppedFields({ rewards: params.args.rewards }, TIER_UPDATE_FIELDS) : droppedFields(body, [...TIER_UPDATE_FIELDS, "targetPoint", "rewards"]);
|
|
2931
|
+
const target = params.current?.name ?? params.args.name ?? params.args.tier_id ?? "new tier";
|
|
2932
|
+
return buildChangePreview({
|
|
2933
|
+
action,
|
|
2934
|
+
target: `tier "${String(target)}"`,
|
|
2935
|
+
reason: params.args.reason,
|
|
2936
|
+
changes: diffFields(current, body),
|
|
2937
|
+
dropped,
|
|
2938
|
+
duplicate: params.duplicate,
|
|
2939
|
+
warnings: action === "UPDATE" && params.args.target_point !== void 0 ? [TIER_THRESHOLD_WARNING] : void 0
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
function rethrowTierCreateConflict(error) {
|
|
2943
|
+
if (error instanceof JoyApiError && error.status === 409) {
|
|
2944
|
+
throw new JoyApiError("conflict", "Cannot create this tier because another tier already has this name or target_point. Call joy_list_tiers, then update the existing tier with tier_id instead of retrying the create.", error.status);
|
|
2945
|
+
}
|
|
2946
|
+
throw error;
|
|
2947
|
+
}
|
|
2948
|
+
async function configureTier(ctx, args) {
|
|
2949
|
+
assertValidTierConfigArgs(args);
|
|
2950
|
+
const action = args.tier_id ? "UPDATE" : "CREATE";
|
|
2951
|
+
if (action === "UPDATE") {
|
|
2952
|
+
const current = await readTier(ctx, args.tier_id);
|
|
2953
|
+
const preview2 = buildTierConfigPreview({ args, current });
|
|
2954
|
+
const blocked2 = requireApply(args.apply, preview2);
|
|
2955
|
+
if (blocked2)
|
|
2956
|
+
return blocked2;
|
|
2957
|
+
const updated = await ctx.api.mutateData(`/tiers/${encodeURIComponent(args.tier_id)}`, {
|
|
2958
|
+
method: "PUT",
|
|
2959
|
+
timeoutMs: 15e3,
|
|
2960
|
+
body: toTierUpdateBody(args)
|
|
2961
|
+
});
|
|
2962
|
+
return tierConfigSummary("UPDATE", updated, args.target_point !== void 0);
|
|
2963
|
+
}
|
|
2964
|
+
const { data } = await ctx.api.getData("/tiers");
|
|
2965
|
+
const duplicate = findTierDuplicate(Array.isArray(data) ? data : [], args);
|
|
2966
|
+
const preview = buildTierConfigPreview({ args, duplicate });
|
|
2967
|
+
const blocked = requireApply(args.apply, preview);
|
|
2968
|
+
if (blocked)
|
|
2969
|
+
return blocked;
|
|
2970
|
+
try {
|
|
2971
|
+
const created = await ctx.api.mutateData("/tiers", {
|
|
2972
|
+
method: "POST",
|
|
2973
|
+
timeoutMs: 15e3,
|
|
2974
|
+
body: toTierConfigBody(args, "CREATE")
|
|
2975
|
+
});
|
|
2976
|
+
return tierConfigSummary("CREATE", created, false);
|
|
2977
|
+
} catch (error) {
|
|
2978
|
+
rethrowTierCreateConflict(error);
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
async function readTier(ctx, tierId) {
|
|
2982
|
+
const { data } = await ctx.api.getData(`/tiers/${encodeURIComponent(tierId)}`);
|
|
2983
|
+
if (data && typeof data === "object")
|
|
2984
|
+
return data;
|
|
2985
|
+
throw new JoyApiError("not_found", `Tier "${tierId}" was not found. Call joy_list_tiers before changing it.`);
|
|
2986
|
+
}
|
|
2987
|
+
function tierConfigSummary(action, tier, changedThreshold) {
|
|
2988
|
+
const name = String(tier.name ?? tier.id ?? "tier");
|
|
2989
|
+
const prefix = action === "CREATE" ? `Created tier "${name}".` : `Updated tier "${name}".`;
|
|
2990
|
+
const warning = changedThreshold ? ` ${TIER_THRESHOLD_WARNING}` : "";
|
|
2991
|
+
return summaryWithJson(`${prefix}${warning}`, compact(tier, ["id", "name", "targetPoint", "targetPointUpdate", "tierRewards"]));
|
|
2992
|
+
}
|
|
2993
|
+
function toTierRewardBody(args) {
|
|
2994
|
+
return toTierRewardFields(args);
|
|
2995
|
+
}
|
|
2996
|
+
function toTierRewardFields(args) {
|
|
2997
|
+
return omitUndefined({
|
|
2998
|
+
title: args.title,
|
|
2999
|
+
status: args.status,
|
|
3000
|
+
event: args.event,
|
|
3001
|
+
type: args.type,
|
|
3002
|
+
earnAmount: args.earn_amount,
|
|
3003
|
+
spendPoint: args.spend_point,
|
|
3004
|
+
bonusPoints: args.bonus_points,
|
|
3005
|
+
codeName: args.code_name,
|
|
3006
|
+
redeemType: args.redeem_type,
|
|
3007
|
+
appliedTo: args.applied_to,
|
|
3008
|
+
orderReq: args.order_req,
|
|
3009
|
+
orderReqAmount: args.order_req_amount,
|
|
3010
|
+
minSpendPoint: args.min_spend_point,
|
|
3011
|
+
maxSpendPoint: args.max_spend_point,
|
|
3012
|
+
specificProducts: args.specific_products,
|
|
3013
|
+
specificCollections: args.specific_collections,
|
|
3014
|
+
appliedCollectionIds: args.applied_collection_ids,
|
|
3015
|
+
combinedWith: args.combined_with,
|
|
3016
|
+
hasLimitShipping: args.has_limit_shipping,
|
|
3017
|
+
limitShipping: args.limit_shipping,
|
|
3018
|
+
expiredAfter: args.expired_after,
|
|
3019
|
+
expiredUnit: args.expired_unit,
|
|
3020
|
+
isRefundCoupon: args.is_refund_coupon,
|
|
3021
|
+
priority: args.priority,
|
|
3022
|
+
showLoyaltyPage: args.show_loyalty_page,
|
|
3023
|
+
perkTitleLanguage: args.perk_title_language,
|
|
3024
|
+
rewardProductSelectionMode: args.reward_product_selection_mode,
|
|
3025
|
+
rewardProductQuantity: args.reward_product_quantity
|
|
3026
|
+
});
|
|
3027
|
+
}
|
|
3028
|
+
function assertValidTierRewardArgs(args) {
|
|
3029
|
+
if (!args.reward_id && !args.title) {
|
|
3030
|
+
throw new JoyApiError("validation", "Creating a tier reward requires title.");
|
|
3031
|
+
}
|
|
3032
|
+
const selectionError = tierFreeGiftSelectionError(args, !args.reward_id);
|
|
3033
|
+
if (selectionError)
|
|
3034
|
+
throw new JoyApiError("validation", selectionError);
|
|
3035
|
+
if (args.reward_id && Object.keys(toTierRewardBody(args)).length === 1) {
|
|
3036
|
+
throw new JoyApiError("validation", "Choose at least one tier-reward field to update in addition to event.");
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
async function resolveTierRewardProductUrls(ctx, args) {
|
|
3040
|
+
if (!args.product_url?.length)
|
|
3041
|
+
return args;
|
|
3042
|
+
if (args.event !== "free_gift" || args.applied_to !== "specific_product") {
|
|
3043
|
+
throw new JoyApiError("validation", 'product_url is only supported for free_gift rewards with applied_to "specific_product". Use merchant-provided product IDs for another selection mode.');
|
|
3044
|
+
}
|
|
3045
|
+
const resolvedProducts = await Promise.all(args.product_url.map(async (productUrl) => {
|
|
3046
|
+
const parsed = parseShopifyProductUrl(productUrl);
|
|
3047
|
+
if (!parsed.ok)
|
|
3048
|
+
throw new JoyApiError("validation", `${parsed.error} Received: ${productUrl}`);
|
|
3049
|
+
const { data } = await ctx.api.getData(`/products/resolve?handle=${encodeURIComponent(parsed.handle)}`);
|
|
3050
|
+
if (!data || !Number.isSafeInteger(data.id) || data.id <= 0) {
|
|
3051
|
+
throw new JoyApiError("not_found", `No Shopify product was found for handle "${parsed.handle}". Ask the merchant for a valid product link; never fabricate an ID.`);
|
|
3052
|
+
}
|
|
3053
|
+
return { id: data.id, title: data.title, handle: data.handle };
|
|
3054
|
+
}));
|
|
3055
|
+
return { ...args, specific_products: [...args.specific_products ?? [], ...resolvedProducts] };
|
|
3056
|
+
}
|
|
3057
|
+
function findTierReward(tier, rewardId) {
|
|
3058
|
+
const rewards = Array.isArray(tier.tierRewards) ? tier.tierRewards : [];
|
|
3059
|
+
return rewards.find((reward) => isData(reward) && String(reward.id) === rewardId);
|
|
3060
|
+
}
|
|
3061
|
+
function buildTierRewardPreview(params) {
|
|
3062
|
+
const action = params.args.reward_id ? "UPDATE" : "CREATE";
|
|
3063
|
+
const body = toTierRewardBody(params.args);
|
|
3064
|
+
return buildChangePreview({
|
|
3065
|
+
action,
|
|
3066
|
+
target: `tier reward "${String(params.current?.title ?? params.args.title ?? params.args.event)}"`,
|
|
3067
|
+
reason: params.args.reason,
|
|
3068
|
+
changes: diffFields(params.current ?? {}, body),
|
|
3069
|
+
dropped: droppedFields(body, TIER_REWARD_FIELDS),
|
|
3070
|
+
duplicate: params.duplicate
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
async function configureTierReward(ctx, args) {
|
|
3074
|
+
const resolvedArgs = await resolveTierRewardProductUrls(ctx, args);
|
|
3075
|
+
assertValidTierRewardArgs(resolvedArgs);
|
|
3076
|
+
const tier = await readTier(ctx, resolvedArgs.tier_id);
|
|
3077
|
+
const action = resolvedArgs.reward_id ? "UPDATE" : "CREATE";
|
|
3078
|
+
const current = resolvedArgs.reward_id ? findTierReward(tier, resolvedArgs.reward_id) : void 0;
|
|
3079
|
+
if (resolvedArgs.reward_id && !current) {
|
|
3080
|
+
throw new JoyApiError("not_found", `Reward "${resolvedArgs.reward_id}" is not attached to tier "${resolvedArgs.tier_id}". Nothing was changed; verify the tier and reward IDs before retrying.`);
|
|
3081
|
+
}
|
|
3082
|
+
const duplicate = action === "CREATE" ? findDuplicate(Array.isArray(tier.tierRewards) ? tier.tierRewards : [], {
|
|
3083
|
+
event: resolvedArgs.event,
|
|
3084
|
+
title: resolvedArgs.title
|
|
3085
|
+
}) : void 0;
|
|
3086
|
+
const preview = buildTierRewardPreview({ args: resolvedArgs, current, duplicate });
|
|
3087
|
+
const blocked = requireApply(resolvedArgs.apply, preview);
|
|
3088
|
+
if (blocked)
|
|
3089
|
+
return blocked;
|
|
3090
|
+
const result = await ctx.api.mutateData(action === "CREATE" ? `/tiers/${encodeURIComponent(resolvedArgs.tier_id)}/rewards` : `/tiers/${encodeURIComponent(resolvedArgs.tier_id)}/rewards/${encodeURIComponent(resolvedArgs.reward_id)}`, { method: action === "CREATE" ? "POST" : "PUT", timeoutMs: 15e3, body: toTierRewardBody(resolvedArgs) });
|
|
3091
|
+
const rewardId = action === "CREATE" ? String(result.id ?? "") : resolvedArgs.reward_id;
|
|
3092
|
+
if (!rewardId) {
|
|
3093
|
+
throw new JoyApiError("server", "The tier-reward create response did not include an id. Do not assume the reward was configured; re-read the tier in Joy admin before trying again.");
|
|
3094
|
+
}
|
|
3095
|
+
const refreshed = await readTier(ctx, resolvedArgs.tier_id);
|
|
3096
|
+
const reward = findTierReward(refreshed, rewardId);
|
|
3097
|
+
if (!reward) {
|
|
3098
|
+
throw new JoyApiError("server", `The write response named reward "${rewardId}", but it is not attached to tier "${resolvedArgs.tier_id}" after re-reading. Do not report this change as applied; verify it in Joy admin.`);
|
|
3099
|
+
}
|
|
3100
|
+
return tierRewardSummary(action, reward, resolvedArgs.tier_id);
|
|
3101
|
+
}
|
|
3102
|
+
function tierRewardSummary(action, reward, tierId) {
|
|
3103
|
+
const verb = action === "CREATE" ? "Created" : "Updated";
|
|
3104
|
+
return summaryWithJson(`${verb} tier reward "${String(reward.title ?? reward.id)}" on tier "${tierId}".`, compact(reward, ["id", "title", "event", "status", "earnAmount", "spendPoint", "bonusPoints", "appliedTo"]));
|
|
3105
|
+
}
|
|
3106
|
+
function toTierPerksBody(args) {
|
|
3107
|
+
return { contentPerk: args.perks };
|
|
3108
|
+
}
|
|
3109
|
+
function buildTierPerksPreview(current, args) {
|
|
3110
|
+
const body = toTierPerksBody(args);
|
|
3111
|
+
return buildChangePreview({
|
|
3112
|
+
action: "UPDATE",
|
|
3113
|
+
target: `display-only perks on tier "${String(current.name ?? args.tier_id)}"`,
|
|
3114
|
+
reason: args.reason,
|
|
3115
|
+
changes: diffFields({ contentPerk: Array.isArray(current.contentPerk) ? current.contentPerk : [] }, body),
|
|
3116
|
+
dropped: droppedFields(body, TIER_PERK_FIELDS),
|
|
3117
|
+
warnings: [
|
|
3118
|
+
"This write REPLACES the entire perk list; it does not append. Any existing perk omitted from perks will be removed."
|
|
3119
|
+
]
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
async function configureTierPerks(ctx, args) {
|
|
3123
|
+
const current = await readTier(ctx, args.tier_id);
|
|
3124
|
+
const preview = buildTierPerksPreview(current, args);
|
|
3125
|
+
const blocked = requireApply(args.apply, preview);
|
|
3126
|
+
if (blocked)
|
|
3127
|
+
return blocked;
|
|
3128
|
+
await ctx.api.mutateData(`/tiers/${encodeURIComponent(args.tier_id)}`, {
|
|
3129
|
+
method: "PUT",
|
|
3130
|
+
timeoutMs: 15e3,
|
|
3131
|
+
body: toTierPerksBody(args)
|
|
3132
|
+
});
|
|
3133
|
+
const refreshed = await readTier(ctx, args.tier_id);
|
|
3134
|
+
if (!Array.isArray(refreshed.contentPerk) || JSON.stringify(refreshed.contentPerk) !== JSON.stringify(args.perks)) {
|
|
3135
|
+
throw new JoyApiError("server", `The tier perk write was not confirmed: contentPerk did not match the complete replacement list after re-reading tier "${args.tier_id}". Do not report these perks as applied; verify the tier in Joy admin and report this backend failure.`);
|
|
3136
|
+
}
|
|
3137
|
+
return summaryWithJson(`Replaced the complete display-only perk list on tier "${String(refreshed.name ?? args.tier_id)}" and confirmed it by reading the tier back.`, compact(refreshed, ["id", "name", "contentPerk"]));
|
|
3138
|
+
}
|
|
3139
|
+
function toTierSettingsBody(args) {
|
|
3140
|
+
return omitUndefined({
|
|
3141
|
+
useDemotionTier: args.use_demotion_tier,
|
|
3142
|
+
typeDemotion: args.type_demotion,
|
|
3143
|
+
downgradeAfter: args.downgrade_after === void 0 ? void 0 : String(args.downgrade_after),
|
|
3144
|
+
downgradeAfterUnit: args.downgrade_after_unit,
|
|
3145
|
+
useResetTier: args.use_reset_tier,
|
|
3146
|
+
resetAfter: args.reset_after === void 0 ? void 0 : String(args.reset_after),
|
|
3147
|
+
resetAfterUnit: args.reset_after_unit,
|
|
3148
|
+
resetFrequency: args.reset_frequency,
|
|
3149
|
+
resetIntervalValue: args.reset_interval_value,
|
|
3150
|
+
resetIntervalUnit: args.reset_interval_unit,
|
|
3151
|
+
resetStartDate: args.reset_start_date,
|
|
3152
|
+
monthResetTier: args.month_reset_tier === void 0 ? void 0 : String(args.month_reset_tier),
|
|
3153
|
+
dayResetTier: args.day_reset_tier === void 0 ? void 0 : String(args.day_reset_tier),
|
|
3154
|
+
isSyncTagsCustomerTier: args.is_sync_tags_customer_tier,
|
|
3155
|
+
customerTierTagPrefix: args.customer_tier_tag_prefix,
|
|
3156
|
+
notificationDowngradeBefore: args.notification_downgrade_before === void 0 ? void 0 : String(args.notification_downgrade_before),
|
|
3157
|
+
notificationDowngradeBeforeUnit: args.notification_downgrade_before_unit
|
|
3158
|
+
});
|
|
3159
|
+
}
|
|
3160
|
+
function touchesResetSchedule(body) {
|
|
3161
|
+
return RESET_SCHEDULE_FIELDS.some((field) => Object.hasOwn(body, field));
|
|
3162
|
+
}
|
|
3163
|
+
function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date()) {
|
|
3164
|
+
const body = toTierSettingsBody(args);
|
|
3165
|
+
const changes = diffFields(current, body);
|
|
3166
|
+
if (!touchesResetSchedule(body)) {
|
|
3167
|
+
return {
|
|
3168
|
+
kind: "preview",
|
|
3169
|
+
body,
|
|
3170
|
+
preview: buildChangePreview({
|
|
3171
|
+
action: "UPDATE",
|
|
3172
|
+
target: "tier settings",
|
|
3173
|
+
reason: args.reason,
|
|
3174
|
+
changes,
|
|
3175
|
+
dropped: droppedFields(body, TIER_SETTINGS_FIELDS)
|
|
3176
|
+
})
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
const projectedSettings = prepareProjectedResetSettings(current, body, now);
|
|
3180
|
+
const projected = projectScheduleResetTierAt(projectedSettings, now);
|
|
3181
|
+
if (projected === void 0) {
|
|
3182
|
+
return {
|
|
3183
|
+
kind: "refused",
|
|
3184
|
+
body,
|
|
3185
|
+
guidance: "Refusing this tier reset change: Joy cannot calculate the projected scheduleResetTierAt from the merged settings. Supply a complete valid reset frequency, interval, and anchor/start date, then preview again. Applying blind could reset or demote every member on the wrong day."
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
const calculatedCurrent = projectScheduleResetTierAt(prepareProjectedResetSettings(current, {}, now), now);
|
|
3189
|
+
const currentSchedule = current.scheduleResetTierAt ?? calculatedCurrent;
|
|
3190
|
+
const scheduleWarning = [
|
|
3191
|
+
`Current scheduleResetTierAt: ${formatSchedule(currentSchedule)}.`,
|
|
3192
|
+
`Projected scheduleResetTierAt: ${formatSchedule(projected)}.`,
|
|
3193
|
+
"This cron anchor controls when Joy demotes or resets every member."
|
|
3194
|
+
].join(" ");
|
|
3195
|
+
return {
|
|
3196
|
+
kind: "preview",
|
|
3197
|
+
body,
|
|
3198
|
+
projectedScheduleResetTierAt: projected,
|
|
3199
|
+
preview: buildChangePreview({
|
|
3200
|
+
action: "UPDATE",
|
|
3201
|
+
target: "tier settings",
|
|
3202
|
+
reason: args.reason,
|
|
3203
|
+
changes,
|
|
3204
|
+
dropped: droppedFields(body, TIER_SETTINGS_FIELDS),
|
|
3205
|
+
warnings: [scheduleWarning]
|
|
3206
|
+
})
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
async function configureTierSettings(ctx, args) {
|
|
3210
|
+
const current = await readTierSettings(ctx);
|
|
3211
|
+
const decision = buildTierSettingsPreview(current, args);
|
|
3212
|
+
if (decision.kind === "refused")
|
|
3213
|
+
return decision.guidance;
|
|
3214
|
+
const blocked = requireApply(args.apply, decision.preview);
|
|
3215
|
+
if (blocked)
|
|
3216
|
+
return blocked;
|
|
3217
|
+
try {
|
|
3218
|
+
const updated = await ctx.api.mutateData("/tiers/settings", {
|
|
3219
|
+
method: "PUT",
|
|
3220
|
+
timeoutMs: 15e3,
|
|
3221
|
+
body: decision.body
|
|
3222
|
+
});
|
|
3223
|
+
const schedule = touchesResetSchedule(decision.body) ? ` Projected scheduleResetTierAt: ${formatSchedule(decision.projectedScheduleResetTierAt)}.` : "";
|
|
3224
|
+
return summaryWithJson(`Updated tier settings.${schedule}`, compact(updated, [...TIER_SETTINGS_FIELDS, "id"]));
|
|
3225
|
+
} catch (error) {
|
|
3226
|
+
rethrowTierSettingsNotLaunched(error);
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
async function readTierSettings(ctx) {
|
|
3230
|
+
try {
|
|
3231
|
+
const { data } = await ctx.api.getData("/tiers/settings");
|
|
3232
|
+
if (data && typeof data === "object")
|
|
3233
|
+
return data;
|
|
3234
|
+
throw new JoyApiError("not_found", "Tier settings are not available. Launch tiers first in Joy admin.");
|
|
3235
|
+
} catch (error) {
|
|
3236
|
+
rethrowTierSettingsNotLaunched(error);
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
function rethrowTierSettingsNotLaunched(error) {
|
|
3240
|
+
if (error instanceof JoyApiError && error.kind === "not_found") {
|
|
3241
|
+
throw new JoyApiError("not_found", "This shop has never launched tiers, so tier settings do not exist yet. Launch the tier program in Joy admin first; this MCP tool cannot launch it.", error.status);
|
|
3242
|
+
}
|
|
3243
|
+
throw error;
|
|
3244
|
+
}
|
|
3245
|
+
function prepareProjectedResetSettings(current, body, now = /* @__PURE__ */ new Date()) {
|
|
3246
|
+
const merged = {
|
|
3247
|
+
useResetTier: false,
|
|
3248
|
+
resetFrequency: "yearly",
|
|
3249
|
+
monthResetTier: "12",
|
|
3250
|
+
dayResetTier: "31",
|
|
3251
|
+
...current,
|
|
3252
|
+
...body
|
|
3253
|
+
};
|
|
3254
|
+
if (merged.resetFrequency !== "custom" || merged.resetIntervalUnit !== "year")
|
|
3255
|
+
return merged;
|
|
3256
|
+
const timingKeys = ["resetFrequency", "resetIntervalValue", "resetIntervalUnit", "monthResetTier", "dayResetTier"];
|
|
3257
|
+
const timingChanged = timingKeys.some((key) => !sameValue(current[key], merged[key]));
|
|
3258
|
+
if (!timingChanged && validDate(current.resetStartDate))
|
|
3259
|
+
return merged;
|
|
3260
|
+
const first = nextAnchorOccurrence(merged.monthResetTier, merged.dayResetTier, 12, now, true, 2e3);
|
|
3261
|
+
if (!first)
|
|
3262
|
+
return merged;
|
|
3263
|
+
return { ...merged, resetStartDate: formatIsoDate(first) };
|
|
3264
|
+
}
|
|
3265
|
+
function projectScheduleResetTierAt(settings, now = /* @__PURE__ */ new Date()) {
|
|
3266
|
+
if (settings.useResetTier !== true)
|
|
3267
|
+
return null;
|
|
3268
|
+
const frequency = settings.resetFrequency;
|
|
3269
|
+
if (frequency === "monthly") {
|
|
3270
|
+
const next2 = nextMonthlyOccurrence(settings.dayResetTier, now, true);
|
|
3271
|
+
return next2 ? formatIsoDate(next2) : void 0;
|
|
3272
|
+
}
|
|
3273
|
+
if (frequency === "custom" && settings.resetIntervalUnit === "day") {
|
|
3274
|
+
const next2 = nextCustomDayOccurrence(settings.resetStartDate, settings.resetIntervalValue, now, true);
|
|
3275
|
+
return next2 ? formatIsoDate(next2) : void 0;
|
|
3276
|
+
}
|
|
3277
|
+
const stepMonths = resetStepMonths(frequency, settings.resetIntervalValue, settings.resetIntervalUnit);
|
|
3278
|
+
if (!stepMonths)
|
|
3279
|
+
return void 0;
|
|
3280
|
+
const baseYear = frequency === "custom" && settings.resetIntervalUnit === "year" && validDate(settings.resetStartDate) ? utcDate(new Date(settings.resetStartDate)).getUTCFullYear() : 2e3;
|
|
3281
|
+
const next = nextAnchorOccurrence(settings.monthResetTier, settings.dayResetTier, stepMonths, now, true, baseYear);
|
|
3282
|
+
return next ? formatIsoDate(next) : void 0;
|
|
3283
|
+
}
|
|
3284
|
+
function registerTierConfigTools(server, ctx) {
|
|
3285
|
+
registerConfigWriteTool(server, ctx, {
|
|
3286
|
+
name: "joy_configure_tier",
|
|
3287
|
+
title: "Create or update a VIP tier",
|
|
3288
|
+
description: "CONFIGURATION WRITE \u2014 creates a VIP tier, including optional inline rewards, or updates an existing tier. New tiers require name and target_point; target_point 0 is the system Bronze tier and cannot be created. Updating target_point is STAGED rather than live: Joy only promotes it and re-evaluates members when tiers relaunch in Joy admin.",
|
|
3289
|
+
inputSchema: tierConfigInputSchema,
|
|
3290
|
+
destructiveHint: false,
|
|
3291
|
+
handler: (args) => configureTier(ctx, args)
|
|
3292
|
+
});
|
|
3293
|
+
registerConfigWriteTool(server, ctx, {
|
|
3294
|
+
name: "joy_configure_tier_reward",
|
|
3295
|
+
title: "Create or update a VIP tier reward",
|
|
3296
|
+
description: "CONFIGURATION WRITE \u2014 attaches a reward to a tier or changes an attached reward. event is required. Tier reward applied_to values are all, specific_product, and specific; this is a DIFFERENT literal set from program-level reward tools. Numeric fields are strictly numeric because the REST backend otherwise accepts invalid strings.",
|
|
3297
|
+
inputSchema: tierRewardToolInputSchema,
|
|
3298
|
+
destructiveHint: false,
|
|
3299
|
+
handler: (args) => configureTierReward(ctx, args)
|
|
3300
|
+
});
|
|
3301
|
+
registerConfigWriteTool(server, ctx, {
|
|
3302
|
+
name: "joy_configure_tier_perks",
|
|
3303
|
+
title: "Replace display-only VIP tier perks",
|
|
3304
|
+
description: "CONFIGURATION WRITE \u2014 replaces the ENTIRE legacy contentPerk list on one VIP tier; it never appends. Pass every display-only perk that should remain, or [] to clear the list. These are storefront benefit-table labels or http(s) icon URLs and are distinct from the redeemable tierRewards managed by joy_configure_tier_reward.",
|
|
3305
|
+
inputSchema: tierPerksInputSchema,
|
|
3306
|
+
destructiveHint: true,
|
|
3307
|
+
handler: (args) => configureTierPerks(ctx, args)
|
|
3308
|
+
});
|
|
3309
|
+
registerConfigWriteTool(server, ctx, {
|
|
3310
|
+
name: "joy_configure_tier_settings",
|
|
3311
|
+
title: "Configure VIP tier demotion and reset settings",
|
|
3312
|
+
description: "CONFIGURATION WRITE \u2014 changes tier demotion, reset, customer-tag, and downgrade-notification settings. Any reset-schedule change previews the current and projected scheduleResetTierAt before it may apply, because that daily-cron anchor affects every tier member.",
|
|
3313
|
+
inputSchema: tierSettingsInputSchema,
|
|
3314
|
+
destructiveHint: true,
|
|
3315
|
+
handler: (args) => configureTierSettings(ctx, args)
|
|
3316
|
+
});
|
|
3317
|
+
}
|
|
3318
|
+
function omitUndefined(record) {
|
|
3319
|
+
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
|
|
3320
|
+
}
|
|
3321
|
+
function isData(value) {
|
|
3322
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
3323
|
+
}
|
|
3324
|
+
function sameValue(a, b) {
|
|
3325
|
+
return a === b || String(a) === String(b);
|
|
3326
|
+
}
|
|
3327
|
+
function formatSchedule(value) {
|
|
3328
|
+
if (value === void 0)
|
|
3329
|
+
return "(not exposed by REST v2)";
|
|
3330
|
+
if (value === null)
|
|
3331
|
+
return "(none)";
|
|
3332
|
+
return JSON.stringify(value);
|
|
3333
|
+
}
|
|
3334
|
+
function utcDate(value) {
|
|
3335
|
+
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
|
|
3336
|
+
}
|
|
3337
|
+
function formatIsoDate(value) {
|
|
3338
|
+
return value.toISOString().slice(0, 10);
|
|
3339
|
+
}
|
|
3340
|
+
function validDate(value) {
|
|
3341
|
+
return typeof value === "string" && !Number.isNaN(new Date(value).getTime());
|
|
3342
|
+
}
|
|
3343
|
+
function integer(value) {
|
|
3344
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
3345
|
+
return Number.isInteger(numeric) ? numeric : void 0;
|
|
3346
|
+
}
|
|
3347
|
+
function resetStepMonths(frequency, value, unit) {
|
|
3348
|
+
if (frequency === "quarterly")
|
|
3349
|
+
return 3;
|
|
3350
|
+
if (frequency === "half_yearly")
|
|
3351
|
+
return 6;
|
|
3352
|
+
if (frequency === "yearly")
|
|
3353
|
+
return 12;
|
|
3354
|
+
if (frequency !== "custom")
|
|
3355
|
+
return void 0;
|
|
3356
|
+
const interval = integer(value);
|
|
3357
|
+
if (!interval || interval < 1)
|
|
3358
|
+
return void 0;
|
|
3359
|
+
if (unit === "month")
|
|
3360
|
+
return [2, 3, 4, 6, 12].includes(interval) ? interval : void 0;
|
|
3361
|
+
if (unit === "year")
|
|
3362
|
+
return interval <= 5 ? interval * 12 : void 0;
|
|
3363
|
+
return void 0;
|
|
3364
|
+
}
|
|
3365
|
+
function nextMonthlyOccurrence(dayValue, after, inclusive) {
|
|
3366
|
+
const day = integer(dayValue);
|
|
3367
|
+
if (!day || day < 1 || day > 28)
|
|
3368
|
+
return void 0;
|
|
3369
|
+
const cursor = utcDate(after);
|
|
3370
|
+
const candidate = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), day));
|
|
3371
|
+
if (isAfterOrEqual(candidate, cursor, inclusive))
|
|
3372
|
+
return candidate;
|
|
3373
|
+
return new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, day));
|
|
3374
|
+
}
|
|
3375
|
+
function nextCustomDayOccurrence(startValue, intervalValue, after, inclusive) {
|
|
3376
|
+
const interval = integer(intervalValue);
|
|
3377
|
+
if (!validDate(startValue) || !interval || interval < 30)
|
|
3378
|
+
return void 0;
|
|
3379
|
+
const start = utcDate(new Date(startValue));
|
|
3380
|
+
const cursor = utcDate(after);
|
|
3381
|
+
const days = Math.round((cursor.getTime() - start.getTime()) / 864e5);
|
|
3382
|
+
let multiple = Math.max(0, Math.floor(days / interval));
|
|
3383
|
+
let candidate = new Date(start.getTime() + multiple * interval * 864e5);
|
|
3384
|
+
while (!isAfterOrEqual(candidate, cursor, inclusive)) {
|
|
3385
|
+
multiple += 1;
|
|
3386
|
+
candidate = new Date(start.getTime() + multiple * interval * 864e5);
|
|
3387
|
+
}
|
|
3388
|
+
return candidate;
|
|
3389
|
+
}
|
|
3390
|
+
function nextAnchorOccurrence(monthValue, dayValue, stepMonths, after, inclusive, baseYear) {
|
|
3391
|
+
const month = integer(monthValue);
|
|
3392
|
+
const day = integer(dayValue);
|
|
3393
|
+
if (!month || month < 1 || month > 12 || !day || day < 1 || day > 31)
|
|
3394
|
+
return void 0;
|
|
3395
|
+
if (day > daysInMonth(2e3, month - 1))
|
|
3396
|
+
return void 0;
|
|
3397
|
+
const cursor = utcDate(after);
|
|
3398
|
+
const anchorMonth = baseYear * 12 + month - 1;
|
|
3399
|
+
const cursorMonth = cursor.getUTCFullYear() * 12 + cursor.getUTCMonth();
|
|
3400
|
+
let step = Math.floor((cursorMonth - anchorMonth) / stepMonths);
|
|
3401
|
+
let candidate = anchorOccurrence(anchorMonth, day, step, stepMonths);
|
|
3402
|
+
while (!isAfterOrEqual(candidate, cursor, inclusive)) {
|
|
3403
|
+
step += 1;
|
|
3404
|
+
candidate = anchorOccurrence(anchorMonth, day, step, stepMonths);
|
|
3405
|
+
}
|
|
3406
|
+
return candidate;
|
|
3407
|
+
}
|
|
3408
|
+
function anchorOccurrence(anchorMonth, day, step, stepMonths) {
|
|
3409
|
+
const absoluteMonth = anchorMonth + step * stepMonths;
|
|
3410
|
+
const year = Math.floor(absoluteMonth / 12);
|
|
3411
|
+
const month = (absoluteMonth % 12 + 12) % 12;
|
|
3412
|
+
return new Date(Date.UTC(year, month, Math.min(day, daysInMonth(year, month))));
|
|
3413
|
+
}
|
|
3414
|
+
function daysInMonth(year, month) {
|
|
3415
|
+
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
3416
|
+
}
|
|
3417
|
+
function isAfterOrEqual(candidate, cursor, inclusive) {
|
|
3418
|
+
return inclusive ? candidate.getTime() >= cursor.getTime() : candidate.getTime() > cursor.getTime();
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
// ../mcp-core/build/tools/shopConfig.js
|
|
3422
|
+
import { z as z15 } from "zod";
|
|
3423
|
+
var SHOP_SETTINGS_FIELDS = [
|
|
3424
|
+
"primaryLanguageInApp",
|
|
3425
|
+
"dateFormat",
|
|
3426
|
+
"dateFormatForActivity",
|
|
3427
|
+
"pointPosition",
|
|
3428
|
+
"customerApplyDiscountBehavior",
|
|
3429
|
+
"customerRedeemCouponBehavior",
|
|
3430
|
+
"customerFreeGiftBehavior",
|
|
3431
|
+
"statusForReview",
|
|
3432
|
+
"earnByOrderStatus",
|
|
3433
|
+
"enableGlobalEarningLimit",
|
|
3434
|
+
"globalEarningLimitTimeframe",
|
|
3435
|
+
"globalEarningLimitRollingDays"
|
|
3436
|
+
];
|
|
3437
|
+
var SHOP_SETTINGS_ARGUMENTS = {
|
|
3438
|
+
primary_language_in_app: "primaryLanguageInApp",
|
|
3439
|
+
date_format: "dateFormat",
|
|
3440
|
+
date_format_for_activity: "dateFormatForActivity",
|
|
3441
|
+
point_position: "pointPosition",
|
|
3442
|
+
customer_apply_discount_behavior: "customerApplyDiscountBehavior",
|
|
3443
|
+
customer_redeem_coupon_behavior: "customerRedeemCouponBehavior",
|
|
3444
|
+
customer_free_gift_behavior: "customerFreeGiftBehavior",
|
|
3445
|
+
status_for_review: "statusForReview",
|
|
3446
|
+
earn_by_order_status: "earnByOrderStatus",
|
|
3447
|
+
enable_global_earning_limit: "enableGlobalEarningLimit",
|
|
3448
|
+
global_earning_limit_timeframe: "globalEarningLimitTimeframe",
|
|
3449
|
+
global_earning_limit_rolling_days: "globalEarningLimitRollingDays"
|
|
3450
|
+
};
|
|
3451
|
+
var SHOP_SETTINGS_VALID_ARGUMENTS = [...Object.keys(SHOP_SETTINGS_ARGUMENTS), "reason", "apply"];
|
|
3452
|
+
var TRANSLATION_LANGUAGE_VALID_ARGUMENTS = ["primary_language", "additional_language", "reason", "apply"];
|
|
3453
|
+
var SHOP_COLLECTION_FIELDS = ["statusForReview", "earnByOrderStatus"];
|
|
3454
|
+
var SETTINGS_DOCUMENT_FIELDS = SHOP_SETTINGS_FIELDS.filter((field) => !SHOP_COLLECTION_FIELDS.includes(field));
|
|
3455
|
+
var TRANSLATION_LOCALES = [
|
|
3456
|
+
"ar",
|
|
3457
|
+
"ckb",
|
|
3458
|
+
"cs",
|
|
3459
|
+
"da",
|
|
3460
|
+
"de",
|
|
3461
|
+
"el",
|
|
3462
|
+
"en",
|
|
3463
|
+
"es",
|
|
3464
|
+
"et",
|
|
3465
|
+
"fi",
|
|
3466
|
+
"fr",
|
|
3467
|
+
"ga",
|
|
3468
|
+
"he-IL",
|
|
3469
|
+
"hi",
|
|
3470
|
+
"hu",
|
|
3471
|
+
"id",
|
|
3472
|
+
"it",
|
|
3473
|
+
"ja",
|
|
3474
|
+
"ko",
|
|
3475
|
+
"lb",
|
|
3476
|
+
"lt",
|
|
3477
|
+
"lv",
|
|
3478
|
+
"ms",
|
|
3479
|
+
"nl",
|
|
3480
|
+
"nb",
|
|
3481
|
+
"pl",
|
|
3482
|
+
"pt",
|
|
3483
|
+
"ro",
|
|
3484
|
+
"ru",
|
|
3485
|
+
"sk",
|
|
3486
|
+
"sl",
|
|
3487
|
+
"sr",
|
|
3488
|
+
"sv",
|
|
3489
|
+
"th",
|
|
3490
|
+
"tr",
|
|
3491
|
+
"ur",
|
|
3492
|
+
"vi",
|
|
3493
|
+
"zh-CN",
|
|
3494
|
+
"zh-TW",
|
|
3495
|
+
"uk",
|
|
3496
|
+
"hr"
|
|
3497
|
+
];
|
|
3498
|
+
function getUnknownArguments(args, validArguments) {
|
|
3499
|
+
const valid = new Set(validArguments);
|
|
3500
|
+
return Object.keys(args).filter((argument) => !valid.has(argument));
|
|
3501
|
+
}
|
|
3502
|
+
function assertKnownArguments(args, validArguments) {
|
|
3503
|
+
const unknown = getUnknownArguments(args, validArguments);
|
|
3504
|
+
if (unknown.length > 0) {
|
|
3505
|
+
throw new Error(`Unknown argument${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. Valid arguments: ${validArguments.join(", ")}.`);
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
function toShopSettingsBody(args) {
|
|
3509
|
+
assertKnownArguments(args, SHOP_SETTINGS_VALID_ARGUMENTS);
|
|
3510
|
+
const body = {};
|
|
3511
|
+
for (const [argument, field] of Object.entries(SHOP_SETTINGS_ARGUMENTS)) {
|
|
3512
|
+
if (args[argument] !== void 0)
|
|
3513
|
+
body[field] = args[argument];
|
|
3514
|
+
}
|
|
3515
|
+
return body;
|
|
3516
|
+
}
|
|
3517
|
+
function getDroppedShopSettingsFields(args) {
|
|
3518
|
+
return getUnknownArguments(args, SHOP_SETTINGS_VALID_ARGUMENTS);
|
|
3519
|
+
}
|
|
3520
|
+
function selectedFields(body, fields) {
|
|
3521
|
+
const selected = fields.filter((field) => body[field] !== void 0);
|
|
3522
|
+
return selected.length > 0 ? selected.join(", ") : "none selected";
|
|
3523
|
+
}
|
|
3524
|
+
function buildShopSettingsPreview(current, args) {
|
|
3525
|
+
const body = toShopSettingsBody(args);
|
|
3526
|
+
return buildChangePreview({
|
|
3527
|
+
action: "UPDATE",
|
|
3528
|
+
target: "shop settings",
|
|
3529
|
+
reason: args.reason,
|
|
3530
|
+
changes: diffFields(current, body),
|
|
3531
|
+
dropped: getDroppedShopSettingsFields(args),
|
|
3532
|
+
warnings: [
|
|
3533
|
+
`This endpoint performs two independent writes: fields for the shops collection (${selectedFields(body, SHOP_COLLECTION_FIELDS)}) and fields for the separate settings document (${selectedFields(body, SETTINGS_DOCUMENT_FIELDS)}). There is no atomic rollback, so a partial failure is possible.`
|
|
3534
|
+
]
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3537
|
+
function validateTranslationLanguages(args) {
|
|
3538
|
+
const invalid = [args.primary_language, args.additional_language].filter((locale) => !TRANSLATION_LOCALES.includes(locale));
|
|
3539
|
+
if (invalid.length > 0) {
|
|
3540
|
+
throw new Error(`Invalid locale: ${invalid.join(", ")}. Valid locales: ${TRANSLATION_LOCALES.join(", ")}.`);
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
function buildTranslationLanguagePreview(args) {
|
|
3544
|
+
assertKnownArguments(args, TRANSLATION_LANGUAGE_VALID_ARGUMENTS);
|
|
3545
|
+
validateTranslationLanguages(args);
|
|
3546
|
+
return buildChangePreview({
|
|
3547
|
+
action: "CREATE",
|
|
3548
|
+
target: `storefront language "${args.additional_language}" from primary language "${args.primary_language}"`,
|
|
3549
|
+
reason: args.reason,
|
|
3550
|
+
changes: diffFields({}, {
|
|
3551
|
+
primaryLanguage: args.primary_language,
|
|
3552
|
+
additionalLanguage: args.additional_language
|
|
3553
|
+
}),
|
|
3554
|
+
warnings: [
|
|
3555
|
+
`Applying this machine-translates the shop's entire existing ${args.primary_language} primary-language content into ${args.additional_language}. This is expensive and hard to undo.`
|
|
3556
|
+
]
|
|
3557
|
+
});
|
|
3558
|
+
}
|
|
3559
|
+
function registerShopConfigTools(server, ctx) {
|
|
3560
|
+
registerConfigWriteTool(server, ctx, {
|
|
3561
|
+
name: "joy_configure_shop_settings",
|
|
3562
|
+
title: "Configure safe shop-level loyalty settings",
|
|
3563
|
+
description: "WRITE \u2014 updates a deliberately limited set of shop-level loyalty settings. The backend splits this request between the shops collection and a separate settings document without atomic rollback, so a partial failure is possible. SSO controls, tier-specific earning limits, and weekly-report BCC recipients are intentionally unavailable through this tool.",
|
|
3564
|
+
inputSchema: {
|
|
3565
|
+
primary_language_in_app: z15.enum(["en", "en-US", "fr", "es", "de", "it", "ja", "id", "zh-CN"]).optional(),
|
|
3566
|
+
date_format: z15.enum(["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD", "YYYY-MM-DD"]).optional(),
|
|
3567
|
+
date_format_for_activity: z15.enum(["HH:mm", "hh:mm A"]).optional(),
|
|
3568
|
+
point_position: z15.enum(["before", "after"]).optional(),
|
|
3569
|
+
customer_apply_discount_behavior: z15.enum(["stay_current", "go_to_cart", "open_cart_drawer"]).optional(),
|
|
3570
|
+
customer_redeem_coupon_behavior: z15.enum(["auto_apply", "save_for_later"]).optional(),
|
|
3571
|
+
customer_free_gift_behavior: z15.enum(["manual_add_to_cart", "auto_add_to_cart"]).optional(),
|
|
3572
|
+
status_for_review: z15.enum(["pending", "approveAuto"]).optional(),
|
|
3573
|
+
earn_by_order_status: z15.enum(["financial_status", "fulfillment_status"]).optional(),
|
|
3574
|
+
enable_global_earning_limit: z15.boolean().optional(),
|
|
3575
|
+
global_earning_limit_timeframe: z15.enum(["yearly", "monthly", "rolling"]).optional(),
|
|
3576
|
+
global_earning_limit_rolling_days: z15.number().int().min(1).max(365).optional(),
|
|
3577
|
+
reason: z15.string().min(3).describe("Why the merchant wants these shop-wide settings changed."),
|
|
3578
|
+
apply: z15.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
3579
|
+
},
|
|
3580
|
+
destructiveHint: false,
|
|
3581
|
+
strictArguments: true,
|
|
3582
|
+
handler: async (args) => {
|
|
3583
|
+
const current = (await ctx.api.getData("/shop/settings")).data ?? {};
|
|
3584
|
+
const preview = buildShopSettingsPreview(current, args);
|
|
3585
|
+
const stop = requireApply(args.apply, preview);
|
|
3586
|
+
if (stop)
|
|
3587
|
+
return stop;
|
|
3588
|
+
const body = toShopSettingsBody(args);
|
|
3589
|
+
if (Object.keys(body).length === 0)
|
|
3590
|
+
return preview;
|
|
3591
|
+
await ctx.api.mutateData("/shop/settings", { method: "PUT", body });
|
|
3592
|
+
return `Updated shop settings: ${Object.keys(body).join(", ")}. The backend writes the shops collection and settings document separately; verify both values if the request reported an error.`;
|
|
3593
|
+
}
|
|
3594
|
+
});
|
|
3595
|
+
registerConfigWriteTool(server, ctx, {
|
|
3596
|
+
name: "joy_add_translation_language",
|
|
3597
|
+
title: "Add a storefront translation language",
|
|
3598
|
+
description: "WRITE \u2014 adds one storefront language by machine-translating the shop's entire existing primary-language content. This can be expensive and is hard to undo, so it always requires a dry-run preview and explicit approval before applying.",
|
|
3599
|
+
inputSchema: {
|
|
3600
|
+
primary_language: z15.enum(TRANSLATION_LOCALES),
|
|
3601
|
+
additional_language: z15.enum(TRANSLATION_LOCALES),
|
|
3602
|
+
reason: z15.string().min(3).describe("Why the merchant wants this storefront language added."),
|
|
3603
|
+
apply: z15.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
3604
|
+
},
|
|
3605
|
+
destructiveHint: false,
|
|
3606
|
+
strictArguments: true,
|
|
3607
|
+
handler: async (args) => {
|
|
3608
|
+
const preview = buildTranslationLanguagePreview(args);
|
|
3609
|
+
const stop = requireApply(args.apply, preview);
|
|
3610
|
+
if (stop)
|
|
3611
|
+
return stop;
|
|
3612
|
+
await ctx.api.mutateData("/translations/languages", {
|
|
3613
|
+
method: "POST",
|
|
3614
|
+
body: { primaryLanguage: args.primary_language, additionalLanguage: args.additional_language }
|
|
3615
|
+
});
|
|
3616
|
+
return `Added storefront language ${args.additional_language}; Joy has started machine-translating the existing ${args.primary_language} primary-language content.`;
|
|
3617
|
+
}
|
|
3618
|
+
});
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
// ../mcp-core/build/tools/brandingWrite.js
|
|
3622
|
+
import { z as z16 } from "zod";
|
|
3623
|
+
var WIDGET_DRAFT_SAVED_MESSAGE = "Saved to the V4 draft. The storefront is unchanged until you publish it in Joy admin.";
|
|
3624
|
+
var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
3625
|
+
var COLOR_ROLE_PATHS = {
|
|
3626
|
+
primary: [
|
|
3627
|
+
"theme.primaryColor",
|
|
3628
|
+
"theme.primaryButtonColor",
|
|
3629
|
+
"theme.headerBackgroundColor",
|
|
3630
|
+
"theme.footerActiveTextColor",
|
|
3631
|
+
"launcher.bgColor",
|
|
3632
|
+
"launcher.pointBalance.labelBgColor",
|
|
3633
|
+
"membershipCard.beforeSignIn.buttonBgColor",
|
|
3634
|
+
"membershipCard.afterSignIn.buttonBgColor"
|
|
3635
|
+
],
|
|
3636
|
+
secondary: ["theme.secondaryColor"],
|
|
3637
|
+
text: ["theme.textColor", "theme.blockTextColor"],
|
|
3638
|
+
heading: ["theme.headingTextColor", "theme.cardHeaderColor", "header.textColor"],
|
|
3639
|
+
background: [
|
|
3640
|
+
"theme.widgetBackgroundColor",
|
|
3641
|
+
"theme.blockBgColor",
|
|
3642
|
+
"theme.footerBgColor",
|
|
3643
|
+
"theme.secondaryButtonColor",
|
|
3644
|
+
"membershipCard.beforeSignIn.bgColor",
|
|
3645
|
+
"membershipCard.afterSignIn.bgColor",
|
|
3646
|
+
"membershipCard.afterSignIn.boxBgColor"
|
|
3647
|
+
],
|
|
3648
|
+
button: ["theme.buttonStyleMode", "theme.buttonBgColor"],
|
|
3649
|
+
buttonText: [
|
|
3650
|
+
"theme.buttonTextColor",
|
|
3651
|
+
"theme.primaryTextColor",
|
|
3652
|
+
"launcher.textColor",
|
|
3653
|
+
"launcher.pointBalance.labelTextColor",
|
|
3654
|
+
"membershipCard.beforeSignIn.buttonTextColor",
|
|
3655
|
+
"membershipCard.afterSignIn.buttonTextColor"
|
|
3656
|
+
],
|
|
3657
|
+
border: ["theme.cardBorderColor"]
|
|
3658
|
+
};
|
|
3659
|
+
var COLOR_ROLES = Object.keys(COLOR_ROLE_PATHS);
|
|
3660
|
+
var CORNER_RADIUS_VALUES = ["radiusNone", "radiusSharp", "radiusSoft", "radiusRounded", "radiusExtra"];
|
|
3661
|
+
var BUTTON_STYLE_VALUES = ["store", "preset", "custom"];
|
|
3662
|
+
var CONTENT_STYLE_VALUES = ["icons_and_text", "text_only"];
|
|
3663
|
+
var DISPLAY_TYPE_VALUES = ["widget", "drawer", "floating-drawer"];
|
|
3664
|
+
var widgetDraftInputSchema = {
|
|
3665
|
+
version: z16.enum(["v3", "v4"]).describe("Widget version to change. A live target is accepted only when this is the shop\u2019s currently live version."),
|
|
3666
|
+
target: z16.enum(["draft", "live"]).default("draft").describe("Draft by default. Set live only after the merchant approves a dry-run preview of the immediate storefront change."),
|
|
3667
|
+
apply_scan: z16.boolean().optional().describe("Apply a storefront scan through Joy\u2019s shared backend design merge. Omit scan_url to scan this shop\u2019s own storefront."),
|
|
3668
|
+
scan_url: z16.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL to scan when apply_scan is true."),
|
|
3669
|
+
storefront_password: z16.string().min(1).optional().describe("Optional storefront password used only when apply_scan is true. It is sent only in the PUT body and is never returned."),
|
|
3670
|
+
apply_images: z16.boolean().optional().describe("When apply_scan is true, also replace detected logo and card imagery. Defaults to false."),
|
|
3671
|
+
color_overrides: z16.record(z16.string()).optional().describe(`Optional hex overrides for supported V4 color roles: ${COLOR_ROLES.join(", ")}.`),
|
|
3672
|
+
design_overrides: z16.object({
|
|
3673
|
+
corner_radius: z16.enum(CORNER_RADIUS_VALUES).optional().describe("Corner style. radiusNone is square/not rounded; radiusExtra is the most rounded."),
|
|
3674
|
+
button_style: z16.enum(BUTTON_STYLE_VALUES).optional().describe("store matches the storefront, custom uses the widget\u2019s own button colours."),
|
|
3675
|
+
content_style: z16.enum(CONTENT_STYLE_VALUES).optional().describe("text_only removes the icons beside earn/redeem rows; icons_and_text shows them."),
|
|
3676
|
+
display_type: z16.enum(DISPLAY_TYPE_VALUES).optional().describe("widget is the inline panel; drawer slides in from the edge; floating-drawer is the floating panel with a launcher button.")
|
|
3677
|
+
}).optional().describe("Optional non-colour design settings. Values are enums, not numbers or CSS lengths."),
|
|
3678
|
+
font_overrides: z16.object({ heading: z16.string().min(1).optional(), body: z16.string().min(1).optional() }).optional().describe("Optional heading and body font overrides."),
|
|
3679
|
+
reason: z16.string().min(3).describe("Why the merchant wants this widget draft."),
|
|
3680
|
+
apply: z16.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
|
|
3681
|
+
};
|
|
3682
|
+
var brandScanInputSchema = {
|
|
3683
|
+
url: z16.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL. Omit it to scan this shop\u2019s own storefront."),
|
|
3684
|
+
storefront_password: z16.string().min(1).optional().describe("Optional storefront password for a gated shop. It is sent only in the POST body and is never returned.")
|
|
3685
|
+
};
|
|
3686
|
+
function isHexColor(value) {
|
|
3687
|
+
return typeof value === "string" && HEX_COLOR.test(value);
|
|
3688
|
+
}
|
|
3689
|
+
function validateColorOverrides(overrides) {
|
|
3690
|
+
if (!overrides)
|
|
3691
|
+
return void 0;
|
|
3692
|
+
for (const [role, color] of Object.entries(overrides)) {
|
|
3693
|
+
if (!Object.hasOwn(COLOR_ROLE_PATHS, role)) {
|
|
3694
|
+
return `Unknown color role "${role}". Supported roles are: ${COLOR_ROLES.join(", ")}.`;
|
|
3695
|
+
}
|
|
3696
|
+
if (!isHexColor(color)) {
|
|
3697
|
+
return `Color override for "${role}" must be a hex color such as #112233.`;
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
return void 0;
|
|
3701
|
+
}
|
|
3702
|
+
function brandingErrorGuidance(error, operation, target = "draft") {
|
|
3703
|
+
if (!(error instanceof JoyApiError))
|
|
3704
|
+
return void 0;
|
|
3705
|
+
const errorCode = error.code;
|
|
3706
|
+
const passwordIncorrect = errorCode === "PASSWORD_INCORRECT" || /PASSWORD_INCORRECT|storefront password (?:did not unlock|was not accepted)|incorrect storefront password/i.test(error.modelMessage);
|
|
3707
|
+
const passwordRequired = errorCode === "PASSWORD_REQUIRED" || error.status === 409 || /PASSWORD_REQUIRED|storefront is password-protected|storefront password is required/i.test(error.modelMessage);
|
|
3708
|
+
if (operation === "scan" && passwordIncorrect) {
|
|
3709
|
+
return new JoyApiError("validation", "The storefront password was not accepted. Ask the merchant to verify the storefront password, then pass the corrected value as storefront_password. Do not guess it.", error.status);
|
|
3710
|
+
}
|
|
3711
|
+
if (operation === "scan" && passwordRequired) {
|
|
3712
|
+
return new JoyApiError("conflict", "The storefront is password-protected. Ask the merchant for the storefront password, then pass it as storefront_password to joy_scan_brand_design. Do not guess the password, and do not invent colors or fonts.", error.status);
|
|
3713
|
+
}
|
|
3714
|
+
if (operation === "scan" && error.status === 503) {
|
|
3715
|
+
return new JoyApiError("server", "Joy could not complete the storefront design scan. No design has been returned, so do not invent colors, fonts, or button styling. Ask the merchant to retry later or configure the draft manually.", error.status);
|
|
3716
|
+
}
|
|
3717
|
+
if (operation === "draft" && target === "live" && error.status === 409) {
|
|
3718
|
+
return new JoyApiError("conflict", "The live widget document exists only for the version the shop is currently using. This target: 'live' request used a non-live version and was refused. Check which widget version is currently live before retrying; do not blindly retry the other version.", error.status);
|
|
3719
|
+
}
|
|
3720
|
+
if (operation === "draft" && error.status === 409) {
|
|
3721
|
+
return new JoyApiError("conflict", "The requested widget version is currently live. This tool only writes the non-live draft so it cannot alter the storefront. Choose the other version or edit the live widget manually in Joy admin.", error.status);
|
|
3722
|
+
}
|
|
3723
|
+
return void 0;
|
|
3724
|
+
}
|
|
3725
|
+
function asRecord(value) {
|
|
3726
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3727
|
+
}
|
|
3728
|
+
function getPath(source, path) {
|
|
3729
|
+
let current = source;
|
|
3730
|
+
for (const part of path.split(".")) {
|
|
3731
|
+
if (!current || typeof current !== "object" || Array.isArray(current))
|
|
3732
|
+
return void 0;
|
|
3733
|
+
current = current[part];
|
|
3734
|
+
}
|
|
3735
|
+
return current;
|
|
3736
|
+
}
|
|
3737
|
+
function sameValue2(first, second) {
|
|
3738
|
+
return stableJson(first) === stableJson(second);
|
|
3739
|
+
}
|
|
3740
|
+
function stableJson(value) {
|
|
3741
|
+
return JSON.stringify(value, (_key, raw) => {
|
|
3742
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
3743
|
+
return raw;
|
|
3744
|
+
const record = raw;
|
|
3745
|
+
return Object.fromEntries(Object.keys(record).sort().map((key) => [key, record[key]]));
|
|
3746
|
+
});
|
|
3747
|
+
}
|
|
3748
|
+
var DOCUMENT_METADATA_KEYS = /* @__PURE__ */ new Set(["id", "shopId", "createdAt", "updatedAt", "type"]);
|
|
3749
|
+
function unconfirmedScanFields(readBack, expected) {
|
|
3750
|
+
return Object.keys(expected).filter((key) => !DOCUMENT_METADATA_KEYS.has(key)).filter((key) => !sameValue2(readBack[key], expected[key]));
|
|
3751
|
+
}
|
|
3752
|
+
function setPath(source, path, value) {
|
|
3753
|
+
const [part, ...rest] = path.split(".");
|
|
3754
|
+
if (!part)
|
|
3755
|
+
return source;
|
|
3756
|
+
if (rest.length === 0)
|
|
3757
|
+
return { ...source, [part]: value };
|
|
3758
|
+
const child = asRecord(source[part]);
|
|
3759
|
+
return { ...source, [part]: setPath(child, rest.join("."), value) };
|
|
3760
|
+
}
|
|
3761
|
+
function applyPath(draft, changes, path, value) {
|
|
3762
|
+
const from = getPath(draft, path);
|
|
3763
|
+
if (sameValue2(from, value))
|
|
3764
|
+
return draft;
|
|
3765
|
+
changes.push({ path, from, to: value });
|
|
3766
|
+
return setPath(draft, path, value);
|
|
3767
|
+
}
|
|
3768
|
+
function applyColorRole(draft, changes, role, color) {
|
|
3769
|
+
const paths = COLOR_ROLE_PATHS[role] ?? [];
|
|
3770
|
+
return paths.reduce((next, path) => {
|
|
3771
|
+
const value = path === "theme.buttonStyleMode" ? "custom" : color;
|
|
3772
|
+
return applyPath(next, changes, path, value);
|
|
3773
|
+
}, draft);
|
|
3774
|
+
}
|
|
3775
|
+
function redactCredential(text, credential) {
|
|
3776
|
+
if (!credential)
|
|
3777
|
+
return text;
|
|
3778
|
+
return text.split(credential).join("[REDACTED]");
|
|
3779
|
+
}
|
|
3780
|
+
function errorWithoutCredential(error, credential) {
|
|
3781
|
+
if (!credential)
|
|
3782
|
+
return error;
|
|
3783
|
+
if (error instanceof JoyApiError) {
|
|
3784
|
+
return new JoyApiError(error.kind, redactCredential(error.modelMessage, credential), error.status);
|
|
3785
|
+
}
|
|
3786
|
+
if (error instanceof Error)
|
|
3787
|
+
return new Error(redactCredential(error.message, credential));
|
|
3788
|
+
return new Error(redactCredential(String(error), credential));
|
|
3789
|
+
}
|
|
3790
|
+
function prepareWidgetDraft(current, args) {
|
|
3791
|
+
const changes = [];
|
|
3792
|
+
let draft = current;
|
|
3793
|
+
for (const [role, color] of Object.entries(args.color_overrides ?? {})) {
|
|
3794
|
+
draft = applyColorRole(draft, changes, role, color);
|
|
3795
|
+
}
|
|
3796
|
+
if (args.design_overrides?.corner_radius) {
|
|
3797
|
+
draft = applyPath(draft, changes, "theme.cornerRadius", args.design_overrides.corner_radius);
|
|
3798
|
+
}
|
|
3799
|
+
if (args.design_overrides?.button_style) {
|
|
3800
|
+
draft = applyPath(draft, changes, "theme.buttonStyleMode", args.design_overrides.button_style);
|
|
3801
|
+
}
|
|
3802
|
+
if (args.design_overrides?.content_style) {
|
|
3803
|
+
draft = applyPath(draft, changes, "theme.hideProgramIcons", args.design_overrides.content_style === "text_only");
|
|
3804
|
+
}
|
|
3805
|
+
if (args.design_overrides?.display_type) {
|
|
3806
|
+
draft = applyPath(draft, changes, "display.type", args.design_overrides.display_type);
|
|
3807
|
+
}
|
|
3808
|
+
if (args.font_overrides?.heading) {
|
|
3809
|
+
draft = applyPath(draft, changes, "fonts.heading", args.font_overrides.heading);
|
|
3810
|
+
draft = applyPath(draft, changes, "fonts.headline", args.font_overrides.heading);
|
|
3811
|
+
}
|
|
3812
|
+
if (args.font_overrides?.body) {
|
|
3813
|
+
draft = applyPath(draft, changes, "fonts.body", args.font_overrides.body);
|
|
3814
|
+
draft = applyPath(draft, changes, "fonts.action", args.font_overrides.body);
|
|
3815
|
+
}
|
|
3816
|
+
return { draft, changes };
|
|
3817
|
+
}
|
|
3818
|
+
function unappliedDraftPaths(readBack, changes) {
|
|
3819
|
+
return changes.filter((change) => !sameValue2(getPath(readBack, change.path), change.to)).map((change) => change.path);
|
|
3820
|
+
}
|
|
3821
|
+
function buildWidgetDraftPreview(args, changes) {
|
|
3822
|
+
const target = args.target ?? "draft";
|
|
3823
|
+
const lines = [
|
|
3824
|
+
"DRY RUN \u2014 nothing has been changed yet.",
|
|
3825
|
+
target === "live" ? `Would update the live ${args.version.toUpperCase()} widget configuration.` : `Would update the ${args.version.toUpperCase()} widget draft.`,
|
|
3826
|
+
`Reason given: ${args.reason}`
|
|
3827
|
+
];
|
|
3828
|
+
if (args.apply_scan === true) {
|
|
3829
|
+
lines.push("", "Storefront scan changes:", " Joy\u2019s backend will merge the detected colors, fonts, button style, and corner radius into the widget.");
|
|
3830
|
+
if (args.apply_images === true) {
|
|
3831
|
+
lines.push(" Images will change where detected: the program logo, membership-card hero, REFERRAL section banner, and every VIP tier background.");
|
|
3832
|
+
} else {
|
|
3833
|
+
lines.push(" Images will not change (apply_images defaults to false).");
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
if (changes.length === 0 && args.apply_scan !== true) {
|
|
3837
|
+
lines.push("No supported field would change \u2014 do not report this as a saved design.");
|
|
3838
|
+
} else if (changes.length > 0) {
|
|
3839
|
+
lines.push("", "Changes:");
|
|
3840
|
+
for (const change of changes) {
|
|
3841
|
+
lines.push(` ${change.path}: ${JSON.stringify(change.from)} \u2192 ${JSON.stringify(change.to)}`);
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
if (target === "live") {
|
|
3845
|
+
lines.push("", "WARNING \u2014 saving this changes the storefront immediately. There is NO publish step and NO undo.");
|
|
3846
|
+
} else {
|
|
3847
|
+
lines.push("", "This writes only a non-live draft. The agent cannot see the resulting widget, so the merchant must review it in Joy admin and publish it manually if they approve.");
|
|
3848
|
+
}
|
|
3849
|
+
return lines.join("\n");
|
|
3850
|
+
}
|
|
3851
|
+
async function getBrandScan(ctx, args = {}) {
|
|
3852
|
+
try {
|
|
3853
|
+
if (args.storefront_password !== void 0) {
|
|
3854
|
+
return await ctx.api.mutateData("/shop/branding/scan", {
|
|
3855
|
+
method: "POST",
|
|
3856
|
+
// A storefront scan launches a headless browser and loads a real
|
|
3857
|
+
// commercial page; measured at 33s against arrae.com. The 20s client
|
|
3858
|
+
// default would abort a scan that is working, so this one call gets a
|
|
3859
|
+
// budget sized to the work rather than to a normal API round trip.
|
|
3860
|
+
timeoutMs: 9e4,
|
|
3861
|
+
body: {
|
|
3862
|
+
...args.url === void 0 ? {} : { url: args.url },
|
|
3863
|
+
storefrontPassword: args.storefront_password
|
|
3864
|
+
}
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3867
|
+
const { data } = await ctx.api.getData("/shop/branding/scan", {
|
|
3868
|
+
timeoutMs: 9e4,
|
|
3869
|
+
...args.url === void 0 ? {} : { query: { url: args.url } }
|
|
3870
|
+
});
|
|
3871
|
+
return data;
|
|
3872
|
+
} catch (error) {
|
|
3873
|
+
const guided = brandingErrorGuidance(error, "scan") ?? error;
|
|
3874
|
+
throw errorWithoutCredential(guided, args.storefront_password);
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
function widgetWriteQuery(version2, target) {
|
|
3878
|
+
return target === "live" ? { version: version2, target } : { version: version2 };
|
|
3879
|
+
}
|
|
3880
|
+
function isPasswordFailure(error) {
|
|
3881
|
+
if (!(error instanceof JoyApiError))
|
|
3882
|
+
return false;
|
|
3883
|
+
const errorCode = error.code;
|
|
3884
|
+
return errorCode === "PASSWORD_REQUIRED" || errorCode === "PASSWORD_INCORRECT" || /PASSWORD_REQUIRED|PASSWORD_INCORRECT|STOREFRONT_PASSWORD_ENABLED|storefront password/i.test(error.modelMessage);
|
|
3885
|
+
}
|
|
3886
|
+
function applyScanBody(args) {
|
|
3887
|
+
return {
|
|
3888
|
+
applyScan: {
|
|
3889
|
+
...args.scan_url === void 0 ? {} : { url: args.scan_url },
|
|
3890
|
+
...args.storefront_password === void 0 ? {} : { storefrontPassword: args.storefront_password },
|
|
3891
|
+
colors: true,
|
|
3892
|
+
...args.apply_images === true ? { images: true } : {}
|
|
3893
|
+
}
|
|
3894
|
+
};
|
|
3895
|
+
}
|
|
3896
|
+
async function getWidgetDraft(ctx, version2) {
|
|
3897
|
+
try {
|
|
3898
|
+
const { data } = await ctx.api.getData("/shop/branding/widget/draft", { query: { version: version2 } });
|
|
3899
|
+
return asRecord(data);
|
|
3900
|
+
} catch (error) {
|
|
3901
|
+
throw brandingErrorGuidance(error, "draft") ?? error;
|
|
3902
|
+
}
|
|
3903
|
+
}
|
|
3904
|
+
async function putWidgetDraft(ctx, version2, target, draft) {
|
|
3905
|
+
try {
|
|
3906
|
+
const result = await ctx.api.mutateData("/shop/branding/widget/draft", {
|
|
3907
|
+
method: "PUT",
|
|
3908
|
+
query: widgetWriteQuery(version2, target),
|
|
3909
|
+
body: draft
|
|
3910
|
+
});
|
|
3911
|
+
return asRecord(result);
|
|
3912
|
+
} catch (error) {
|
|
3913
|
+
throw brandingErrorGuidance(error, "draft", target) ?? error;
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
async function putWidgetScan(ctx, args) {
|
|
3917
|
+
try {
|
|
3918
|
+
const result = await ctx.api.mutateData("/shop/branding/widget/draft", {
|
|
3919
|
+
method: "PUT",
|
|
3920
|
+
query: widgetWriteQuery(args.version, args.target),
|
|
3921
|
+
// The SERVER runs the storefront scan inside this request — a headless
|
|
3922
|
+
// browser over a real commercial page, measured at 33-90s. The 20s
|
|
3923
|
+
// default aborts a write that is working, and an aborted write leaves the
|
|
3924
|
+
// caller unable to tell whether it landed. putWidgetDraft, which sends an
|
|
3925
|
+
// explicit document, is an ordinary write and keeps the default.
|
|
3926
|
+
timeoutMs: 12e4,
|
|
3927
|
+
body: applyScanBody(args)
|
|
3928
|
+
});
|
|
3929
|
+
return asRecord(result);
|
|
3930
|
+
} catch (error) {
|
|
3931
|
+
if (isPasswordFailure(error))
|
|
3932
|
+
throw brandingErrorGuidance(error, "scan") ?? error;
|
|
3933
|
+
throw brandingErrorGuidance(error, "draft", args.target) ?? error;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
function registerBrandingReadTools(server, ctx) {
|
|
3937
|
+
registerReadTool(server, {
|
|
3938
|
+
name: "joy_scan_brand_design",
|
|
3939
|
+
title: "Scan a storefront\u2019s design",
|
|
3940
|
+
description: "READ \u2014 scans an optional public HTTPS storefront URL, or this shop\u2019s storefront when omitted, and returns detected colors, heading/body fonts, button style, and whether the result was cached. For a gated storefront, ask the merchant for its password and pass storefront_password; never guess it. The result is a design input, not visual proof of how the widget will look.",
|
|
3941
|
+
inputSchema: brandScanInputSchema,
|
|
3942
|
+
handler: async (args) => {
|
|
3943
|
+
const scan = await getBrandScan(ctx, args);
|
|
3944
|
+
const source = asRecord(scan.source);
|
|
3945
|
+
const cached = source.cached === true;
|
|
3946
|
+
const domain = typeof source.domain === "string" ? source.domain : "the storefront";
|
|
3947
|
+
return redactCredential(summaryWithJson(`Brand scan completed for ${domain}. This is a ${cached ? "cached" : "fresh"} scan result; present it as detected storefront data, not a visual review of the widget.`, {
|
|
3948
|
+
colors: scan.colors,
|
|
3949
|
+
fonts: scan.fonts ?? {},
|
|
3950
|
+
buttons: scan.buttons ?? {},
|
|
3951
|
+
...scan.images === void 0 ? {} : { images: scan.images },
|
|
3952
|
+
source: compact(source, ["domain", "scannedAt", "cached"])
|
|
3953
|
+
}), args.storefront_password);
|
|
3954
|
+
}
|
|
3955
|
+
});
|
|
3956
|
+
}
|
|
3957
|
+
function registerBrandingWriteTools(server, ctx) {
|
|
3958
|
+
registerConfigWriteTool(server, ctx, {
|
|
3959
|
+
name: "joy_configure_widget_draft",
|
|
3960
|
+
title: "Configure widget brand settings",
|
|
3961
|
+
description: "WRITE \u2014 draft by default; live only on explicit target: 'live'. Applies this shop\u2019s or a reference storefront\u2019s scan through Joy\u2019s shared backend design merge, supports limited explicit color, corner-radius, button-style and font overrides, previews by default, and reads the document back after saving. Images change only with apply_images: true. A live save changes the storefront immediately with no publish step and no undo. The agent cannot see the result, so do not claim it looks good.",
|
|
3962
|
+
inputSchema: widgetDraftInputSchema,
|
|
3963
|
+
destructiveHint: true,
|
|
3964
|
+
handler: async (args) => {
|
|
3965
|
+
try {
|
|
3966
|
+
const target = args.target ?? "draft";
|
|
3967
|
+
const current = await getWidgetDraft(ctx, args.version);
|
|
3968
|
+
const validationError = validateColorOverrides(args.color_overrides);
|
|
3969
|
+
if (validationError)
|
|
3970
|
+
throw new JoyApiError("validation", validationError);
|
|
3971
|
+
const prepared = prepareWidgetDraft(current, args);
|
|
3972
|
+
const preview = buildWidgetDraftPreview(args, prepared.changes);
|
|
3973
|
+
if (args.apply !== true)
|
|
3974
|
+
return redactCredential(preview, args.storefront_password);
|
|
3975
|
+
if (args.apply_scan !== true && prepared.changes.length === 0) {
|
|
3976
|
+
return redactCredential(`${preview}
|
|
3977
|
+
|
|
3978
|
+
No write was sent because this would be a no-op.`, args.storefront_password);
|
|
3979
|
+
}
|
|
3980
|
+
let explicitBase = current;
|
|
3981
|
+
let scanApplied = false;
|
|
3982
|
+
if (args.apply_scan === true) {
|
|
3983
|
+
const expected = await putWidgetScan(ctx, args);
|
|
3984
|
+
const readBack = await getWidgetDraft(ctx, args.version);
|
|
3985
|
+
const unconfirmed = unconfirmedScanFields(readBack, expected);
|
|
3986
|
+
if (unconfirmed.length > 0) {
|
|
3987
|
+
const scanFailureGuidance = target === "live" ? "The live scan-based widget write was not confirmed. Do not report the design as saved; the storefront may already have changed, so ask the merchant to inspect the live widget and report this backend failure." : "The scan-based draft write was not confirmed. Do not report the design as saved; ask the merchant to review the draft in Joy admin and report this backend failure.";
|
|
3988
|
+
throw new JoyApiError("server", `${scanFailureGuidance} These fields did not survive the required read-back: ${unconfirmed.join(", ")}.`);
|
|
3989
|
+
}
|
|
3990
|
+
explicitBase = readBack;
|
|
3991
|
+
scanApplied = true;
|
|
3992
|
+
}
|
|
3993
|
+
const explicit = prepareWidgetDraft(explicitBase, args);
|
|
3994
|
+
if (explicit.changes.length > 0) {
|
|
3995
|
+
await putWidgetDraft(ctx, args.version, target, explicit.draft);
|
|
3996
|
+
const readBack = await getWidgetDraft(ctx, args.version);
|
|
3997
|
+
const missing = unappliedDraftPaths(readBack, explicit.changes);
|
|
3998
|
+
if (missing.length > 0) {
|
|
3999
|
+
const failureGuidance = target === "live" ? "The live widget write was not confirmed. Do not report the design as saved; the storefront may already have changed, so ask the merchant to inspect the live widget and report this backend failure." : "The draft write was not confirmed. Do not report the design as saved; ask the merchant to review the draft in Joy admin and report this backend failure.";
|
|
4000
|
+
throw new JoyApiError("server", `${failureGuidance} These fields were unchanged after the required read-back: ${missing.join(", ")}.`);
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
const confirmed = [
|
|
4004
|
+
...scanApplied ? ["the backend storefront-design merge"] : [],
|
|
4005
|
+
...explicit.changes.length > 0 ? [
|
|
4006
|
+
`${explicit.changes.length} explicit field${explicit.changes.length === 1 ? "" : "s"}`
|
|
4007
|
+
] : []
|
|
4008
|
+
].join(" and ");
|
|
4009
|
+
const savedSummary = target === "live" ? `Updated the live ${args.version.toUpperCase()} widget configuration and confirmed ${confirmed} by reading it back. The storefront changed as soon as it saved; there is no publish step and no undo.` : `Updated ${args.version.toUpperCase()} widget draft and confirmed ${confirmed} by reading it back. The agent cannot see the rendered result; review it in Joy admin.`;
|
|
4010
|
+
const result = `${summaryWithJson(savedSummary, {
|
|
4011
|
+
...scanApplied ? { appliedScan: { colors: true, images: args.apply_images === true } } : {},
|
|
4012
|
+
...explicit.changes.length > 0 ? { applied: explicit.changes.map((change) => ({ path: change.path, value: change.to })) } : {}
|
|
4013
|
+
})}${target === "draft" ? `
|
|
4014
|
+
${WIDGET_DRAFT_SAVED_MESSAGE}` : ""}`;
|
|
4015
|
+
return redactCredential(result, args.storefront_password);
|
|
4016
|
+
} catch (error) {
|
|
4017
|
+
throw errorWithoutCredential(error, args.storefront_password);
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
});
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
// ../mcp-core/build/tools/widgetFields.js
|
|
4024
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4025
|
+
import { z as z17 } from "zod";
|
|
4026
|
+
var fieldsPath = "/shop/branding/widget/fields";
|
|
4027
|
+
var versionSchema = z17.literal("v4").default("v4").describe("Widget version. Field discovery currently supports V4.");
|
|
4028
|
+
var widgetFieldsInputSchema = {
|
|
4029
|
+
version: versionSchema,
|
|
4030
|
+
target: z17.enum(["draft", "live"]).default("draft").describe("Draft by default. Use live only when the merchant explicitly requests and approves the immediate storefront change; there is no undo."),
|
|
4031
|
+
fields: z17.array(z17.object({
|
|
4032
|
+
path: z17.string().min(1),
|
|
4033
|
+
value: z17.unknown().refine((value) => value !== void 0, "Each field must include a value; use null to clear it.")
|
|
4034
|
+
})).min(1).describe("Paths and values to set. Discover paths and allowed values with joy_get_widget_fields. The server validates every value."),
|
|
4035
|
+
reason: z17.string().min(3).describe("Why the merchant wants these widget changes."),
|
|
4036
|
+
apply: z17.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
|
|
4037
|
+
};
|
|
4038
|
+
var MAX_FIELD_RESPONSE_CHARS = 12e3;
|
|
4039
|
+
function estimateSize(fields) {
|
|
4040
|
+
return fields.reduce((total, field) => total + field.path.length + field.label.length + 60 + (field.values?.join("").length ?? 0), 0);
|
|
4041
|
+
}
|
|
4042
|
+
async function getWidgetFields(ctx, version2) {
|
|
4043
|
+
const { data } = await ctx.api.getData(fieldsPath, { query: { version: version2 } });
|
|
4044
|
+
if (!Array.isArray(data?.fields)) {
|
|
4045
|
+
throw new JoyApiError("server", "Joy did not return widget field metadata. Field values cannot be confirmed.");
|
|
4046
|
+
}
|
|
4047
|
+
return data.fields;
|
|
4048
|
+
}
|
|
4049
|
+
function registerWidgetFieldReadTools(server, ctx) {
|
|
4050
|
+
registerReadTool(server, {
|
|
4051
|
+
name: "joy_get_widget_fields",
|
|
4052
|
+
title: "Get settable widget fields",
|
|
4053
|
+
description: "READ \u2014 discovers settable V4 widget fields. With no arguments it returns the group index (names, counts, example paths); pass group or search to get those fields with merchant-facing labels, paths, types, current values and allowed enum values. Call before joy_set_widget_fields; never guess a field path or enum value.",
|
|
4054
|
+
inputSchema: {
|
|
4055
|
+
version: versionSchema,
|
|
4056
|
+
group: z17.string().optional().describe("Return only this group. Omit to get the group index first."),
|
|
4057
|
+
search: z17.string().optional().describe('Return only fields whose label or path contains this text, e.g. "corner" or "launcher".')
|
|
4058
|
+
},
|
|
4059
|
+
handler: async (args) => {
|
|
4060
|
+
const fields = await getWidgetFields(ctx, args.version);
|
|
4061
|
+
const term = args.search?.trim().toLocaleLowerCase();
|
|
4062
|
+
const matched = fields.filter((field) => (args.group === void 0 || field.group === args.group) && (term === void 0 || term === "" || field.label.toLocaleLowerCase().includes(term) || field.path.toLocaleLowerCase().includes(term)));
|
|
4063
|
+
const wouldNotFit = estimateSize(matched) > MAX_FIELD_RESPONSE_CHARS;
|
|
4064
|
+
if (args.group === void 0 && !term && wouldNotFit) {
|
|
4065
|
+
const index = {};
|
|
4066
|
+
for (const field of fields) {
|
|
4067
|
+
const entry = index[field.group] ?? { fields: 0, examples: [] };
|
|
4068
|
+
entry.fields += 1;
|
|
4069
|
+
if (entry.examples.length < 2)
|
|
4070
|
+
entry.examples.push(field.path);
|
|
4071
|
+
index[field.group] = entry;
|
|
4072
|
+
}
|
|
4073
|
+
return summaryWithJson(`${fields.length} settable ${args.version.toUpperCase()} widget fields across ${Object.keys(index).length} groups. Call again with group: "<name>" or search: "<text>" to see fields and their allowed values.`, { groups: index });
|
|
4074
|
+
}
|
|
4075
|
+
if (matched.length === 0) {
|
|
4076
|
+
return `No ${args.version.toUpperCase()} widget field matches ${args.group === void 0 ? `search "${args.search}"` : `group "${args.group}"`}. Call joy_get_widget_fields with no arguments to list the groups.`;
|
|
4077
|
+
}
|
|
4078
|
+
const groups = /* @__PURE__ */ new Map();
|
|
4079
|
+
for (const { group, path, label, type, current, values } of matched) {
|
|
4080
|
+
const entries = groups.get(group) ?? [];
|
|
4081
|
+
entries.push({ label, path, type, current, ...values === void 0 ? {} : { values } });
|
|
4082
|
+
groups.set(group, entries);
|
|
4083
|
+
}
|
|
4084
|
+
return summaryWithJson(`${matched.length} of ${fields.length} settable ${args.version.toUpperCase()} widget fields. Use the server-provided paths and values when preparing changes.`, { groups: Object.fromEntries(groups) });
|
|
4085
|
+
}
|
|
4086
|
+
});
|
|
4087
|
+
}
|
|
4088
|
+
function registerWidgetFieldWriteTools(server, ctx) {
|
|
4089
|
+
registerConfigWriteTool(server, ctx, {
|
|
4090
|
+
name: "joy_set_widget_fields",
|
|
4091
|
+
title: "Set widget fields",
|
|
4092
|
+
description: "CONFIG WRITE \u2014 set paths discovered by joy_get_widget_fields; the server validates fields and values. Previews each before \u2192 after by default, surfaces every refusal, and reads back after writing. Draft by default; never switch to live unless the merchant explicitly requests target: 'live'. A live save changes the storefront immediately with no publish step and no undo. Set apply: true only after preview approval; do not claim visual verification.",
|
|
4093
|
+
inputSchema: widgetFieldsInputSchema,
|
|
4094
|
+
strictArguments: true,
|
|
4095
|
+
destructiveHint: true,
|
|
4096
|
+
handler: async (args) => {
|
|
4097
|
+
const current = await getWidgetFields(ctx, args.version);
|
|
4098
|
+
const changes = args.fields.map(({ path, value }) => ({
|
|
4099
|
+
path,
|
|
4100
|
+
from: current.find((field) => field.path === path)?.current,
|
|
4101
|
+
to: value
|
|
4102
|
+
}));
|
|
4103
|
+
if (args.apply !== true) {
|
|
4104
|
+
return `${buildWidgetDraftPreview(args, changes)}
|
|
4105
|
+
|
|
4106
|
+
The server will validate every requested path and value when applied; this preview does not confirm acceptance. Show this preview to the merchant, then call again with apply: true and the same arguments only after approval.`;
|
|
4107
|
+
}
|
|
4108
|
+
let result;
|
|
4109
|
+
try {
|
|
4110
|
+
result = await ctx.api.mutateData(fieldsPath, {
|
|
4111
|
+
method: "PUT",
|
|
4112
|
+
query: { version: args.version, target: args.target },
|
|
4113
|
+
body: { fields: args.fields }
|
|
4114
|
+
});
|
|
4115
|
+
} catch (error) {
|
|
4116
|
+
throw brandingErrorGuidance(error, "draft", args.target) ?? error;
|
|
4117
|
+
}
|
|
4118
|
+
let readBack = [];
|
|
4119
|
+
let readBackFailure;
|
|
4120
|
+
try {
|
|
4121
|
+
readBack = await getWidgetFields(ctx, args.version);
|
|
4122
|
+
} catch (error) {
|
|
4123
|
+
readBackFailure = error instanceof Error ? error.message : String(error);
|
|
4124
|
+
}
|
|
4125
|
+
const refusedPaths = new Set(result.refused.map((field) => field.path));
|
|
4126
|
+
const applied = [];
|
|
4127
|
+
const unconfirmed = [];
|
|
4128
|
+
for (const field of args.fields) {
|
|
4129
|
+
if (refusedPaths.has(field.path))
|
|
4130
|
+
continue;
|
|
4131
|
+
const accepted = result.applied.find((entry) => entry.path === field.path);
|
|
4132
|
+
const observed = readBack.find((entry) => entry.path === field.path);
|
|
4133
|
+
if (!readBackFailure && accepted && observed && isDeepStrictEqual(accepted.value, field.value) && isDeepStrictEqual(observed.current, field.value)) {
|
|
4134
|
+
applied.push({ path: field.path, value: field.value });
|
|
4135
|
+
continue;
|
|
4136
|
+
}
|
|
4137
|
+
unconfirmed.push({ path: field.path, requested: field.value, current: observed?.current });
|
|
4138
|
+
}
|
|
4139
|
+
const summary = `Widget field write: ${applied.length} confirmed by read-back, ${result.refused.length} refused, ${unconfirmed.length} unconfirmed.`;
|
|
4140
|
+
const details = `${summary}
|
|
4141
|
+
${JSON.stringify({ refused: result.refused, unconfirmed, applied }, null, 1)}`;
|
|
4142
|
+
const consequence = args.target === "live" ? "WARNING \u2014 saving this changes the storefront immediately. There is NO publish step and NO undo." : applied.length > 0 ? WIDGET_DRAFT_SAVED_MESSAGE : "Review the V4 draft in Joy admin.";
|
|
4143
|
+
const report = `${details}
|
|
4144
|
+
${consequence}${readBackFailure ? `
|
|
4145
|
+
Read-back failed: ${readBackFailure}` : ""}`;
|
|
4146
|
+
if (result.refused.length > 0 || unconfirmed.length > 0 || readBackFailure) {
|
|
4147
|
+
throw new JoyApiError(unconfirmed.length > 0 || readBackFailure ? "server" : "validation", `${report}
|
|
4148
|
+
Do not report refused or unconfirmed fields as saved. Inspect the current widget in Joy admin before retrying; some fields may already have changed.`);
|
|
4149
|
+
}
|
|
4150
|
+
return report;
|
|
4151
|
+
}
|
|
4152
|
+
});
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
// ../mcp-core/build/tools/referralBrandingWrite.js
|
|
4156
|
+
import { z as z18 } from "zod";
|
|
4157
|
+
var POPUP_DISPLAY_TYPES = ["auto_show_popup", "show_bubble_teaser", "do_not_show"];
|
|
4158
|
+
var REFERRAL_WIDGET_ARGUMENTS = {
|
|
4159
|
+
brand_color: "brandColor",
|
|
4160
|
+
text_color: "textColor",
|
|
4161
|
+
action_bg_color: "actionBgColor",
|
|
4162
|
+
action_text_color: "actionTextColor",
|
|
4163
|
+
color_type: "colorType",
|
|
4164
|
+
custom_solid_color: "customSolidColor",
|
|
4165
|
+
theme_text_font: "themeTextFont",
|
|
4166
|
+
type_show_popup: "typeShowPopup",
|
|
4167
|
+
is_auto_close_pop_up: "isAutoClosePopUp",
|
|
4168
|
+
closing_pop_up_time: "closingPopUpTime",
|
|
4169
|
+
frequency_show_popup: "frequencyShowPopup",
|
|
4170
|
+
is_show_subscribe_newsletter: "isShowSubscribeNewsletter"
|
|
4171
|
+
};
|
|
4172
|
+
var hexColor = (description) => z18.string().refine(isHexColor, { message: "Must be a 3- or 6-digit hex color such as #123 or #112233." }).optional().describe(description);
|
|
4173
|
+
var referralWidgetInputShape = {
|
|
4174
|
+
brand_color: hexColor("Primary accent color for the referral widget."),
|
|
4175
|
+
text_color: hexColor("Main referral-widget text color."),
|
|
4176
|
+
action_bg_color: hexColor("Referral-widget action button background color."),
|
|
4177
|
+
action_text_color: hexColor("Referral-widget action button text color."),
|
|
4178
|
+
color_type: z18.enum(["template", "custom-solid", "custom-gradient"]).optional().describe("Color mode. This endpoint can select an existing template or gradient, but cannot define gradient stops."),
|
|
4179
|
+
custom_solid_color: hexColor("Custom solid background color used when color_type is custom-solid."),
|
|
4180
|
+
theme_text_font: z18.string().trim().min(1).optional().describe("Referral-widget font family, for example Open+Sans."),
|
|
4181
|
+
type_show_popup: z18.enum(POPUP_DISPLAY_TYPES).optional().describe("How the referral popup appears: automatically, as a bubble teaser, or not at all."),
|
|
4182
|
+
is_auto_close_pop_up: z18.boolean().optional().describe("Whether an automatically shown referral popup closes itself."),
|
|
4183
|
+
closing_pop_up_time: z18.number().int().min(1).optional().describe("Auto-close delay in seconds; must be a positive integer."),
|
|
4184
|
+
frequency_show_popup: z18.number().int().min(1).max(10).optional().describe("Number of automatic popup displays, from 1 through 10. The admin UI stores this as an integer."),
|
|
4185
|
+
is_show_subscribe_newsletter: z18.boolean().optional().describe("Show the newsletter subscription option in the referral popup."),
|
|
4186
|
+
reason: z18.string().min(3).describe("Why the merchant wants the live referral widget changed."),
|
|
4187
|
+
apply: z18.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
4188
|
+
};
|
|
4189
|
+
var referralWidgetArgsSchema = z18.object(referralWidgetInputShape).strict();
|
|
4190
|
+
function parseReferralWidgetArgs(input) {
|
|
4191
|
+
const parsed = referralWidgetArgsSchema.safeParse(input);
|
|
4192
|
+
if (parsed.success)
|
|
4193
|
+
return parsed.data;
|
|
4194
|
+
const issue = parsed.error.issues[0];
|
|
4195
|
+
const field = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
|
|
4196
|
+
throw new JoyApiError("validation", `Invalid referral widget configuration \u2014 ${field}${issue.message} Fix the named parameter and call again.`);
|
|
4197
|
+
}
|
|
4198
|
+
function toReferralWidgetBody(args) {
|
|
4199
|
+
const body = {};
|
|
4200
|
+
for (const [argument, field] of Object.entries(REFERRAL_WIDGET_ARGUMENTS)) {
|
|
4201
|
+
const value = args[argument];
|
|
4202
|
+
if (value !== void 0)
|
|
4203
|
+
body[field] = value;
|
|
4204
|
+
}
|
|
4205
|
+
return body;
|
|
4206
|
+
}
|
|
4207
|
+
function buildReferralWidgetPreview(current, args, body = toReferralWidgetBody(args)) {
|
|
4208
|
+
return buildChangePreview({
|
|
4209
|
+
action: "UPDATE",
|
|
4210
|
+
target: "the LIVE referral V3 widget",
|
|
4211
|
+
reason: args.reason,
|
|
4212
|
+
changes: diffFields(current, body),
|
|
4213
|
+
warnings: [
|
|
4214
|
+
"This changes the LIVE referral widget as soon as it saves. There is no draft and no publish step."
|
|
4215
|
+
]
|
|
4216
|
+
});
|
|
4217
|
+
}
|
|
4218
|
+
async function configureReferralWidget(ctx, input) {
|
|
4219
|
+
const args = parseReferralWidgetArgs(input);
|
|
4220
|
+
const body = toReferralWidgetBody(args);
|
|
4221
|
+
if (Object.keys(body).length === 0) {
|
|
4222
|
+
throw new JoyApiError("validation", `At least one referral widget field is required. Valid fields: ${Object.keys(REFERRAL_WIDGET_ARGUMENTS).join(", ")}.`);
|
|
4223
|
+
}
|
|
4224
|
+
const { data } = await ctx.api.getData("/shop/branding/referral");
|
|
4225
|
+
const current = data ?? {};
|
|
4226
|
+
const changes = diffFields(current, body);
|
|
4227
|
+
const preview = buildReferralWidgetPreview(current, args, body);
|
|
4228
|
+
if (changes.length === 0)
|
|
4229
|
+
return preview;
|
|
4230
|
+
const blocked = requireApply(args.apply, preview);
|
|
4231
|
+
if (blocked)
|
|
4232
|
+
return blocked;
|
|
4233
|
+
await ctx.api.mutateData("/shop/branding/referral", { method: "PUT", body });
|
|
4234
|
+
const { data: refreshedData } = await ctx.api.getData("/shop/branding/referral");
|
|
4235
|
+
const refreshed = refreshedData ?? {};
|
|
4236
|
+
const unconfirmed = diffFields(refreshed, body).map((change) => change.field);
|
|
4237
|
+
if (unconfirmed.length > 0) {
|
|
4238
|
+
throw new JoyApiError("server", `The live referral widget write was not confirmed: these requested fields did not match after the required read-back: ${unconfirmed.join(", ")}. Do not report the referral widget as configured; ask the merchant to verify it in Joy admin and report this backend failure. Do not retry blindly.`);
|
|
4239
|
+
}
|
|
4240
|
+
const appliedFields = changes.map((change) => change.field);
|
|
4241
|
+
return summaryWithJson(`Updated the LIVE referral widget and confirmed ${appliedFields.length} field${appliedFields.length === 1 ? "" : "s"} by reading it back. There is no draft and no publish step.`, compact(refreshed, appliedFields));
|
|
4242
|
+
}
|
|
4243
|
+
function registerReferralBrandingWriteTools(server, ctx) {
|
|
4244
|
+
registerConfigWriteTool(server, ctx, {
|
|
4245
|
+
name: "joy_configure_referral_widget",
|
|
4246
|
+
title: "Configure the live referral widget",
|
|
4247
|
+
description: "CONFIGURATION WRITE \u2014 updates the LIVE referral V3 storefront popup immediately. This is not Widget V4: there is no draft and no publish step. It previews by default, requires explicit approval to apply, validates every color as hex before any HTTP call, and reads every requested field back before reporting success.",
|
|
4248
|
+
inputSchema: referralWidgetInputShape,
|
|
4249
|
+
destructiveHint: true,
|
|
4250
|
+
strictArguments: true,
|
|
4251
|
+
handler: (args) => configureReferralWidget(ctx, args)
|
|
4252
|
+
});
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
// ../mcp-core/build/tools/themeSetup.js
|
|
4256
|
+
function loyaltyWidget(embeds) {
|
|
4257
|
+
return embeds.find((embed) => embed.name === "Joy Loyalty Widget" || embed.handle === "app-embed");
|
|
4258
|
+
}
|
|
4259
|
+
function formatThemeSetup(themeSetup) {
|
|
4260
|
+
const embeds = Array.isArray(themeSetup.embeds) ? themeSetup.embeds : [];
|
|
4261
|
+
const blocks = Array.isArray(themeSetup.blocks) ? themeSetup.blocks : [];
|
|
4262
|
+
const widget = loyaltyWidget(embeds);
|
|
4263
|
+
const inactiveEmbeds = embeds.filter((embed) => !embed.active);
|
|
4264
|
+
const actions = [];
|
|
4265
|
+
if (widget && !widget.active) {
|
|
4266
|
+
actions.push({
|
|
4267
|
+
item: widget.name,
|
|
4268
|
+
instruction: "The merchant must open this link, switch it on, and save in the theme editor.",
|
|
4269
|
+
url: widget.activateUrl
|
|
4270
|
+
});
|
|
4271
|
+
}
|
|
4272
|
+
for (const embed of inactiveEmbeds) {
|
|
4273
|
+
if (embed === widget)
|
|
4274
|
+
continue;
|
|
4275
|
+
actions.push({
|
|
4276
|
+
item: embed.name,
|
|
4277
|
+
instruction: "The merchant must open this link, switch it on, and save in the theme editor.",
|
|
4278
|
+
url: embed.activateUrl
|
|
4279
|
+
});
|
|
4280
|
+
}
|
|
4281
|
+
for (const block of blocks) {
|
|
4282
|
+
actions.push({
|
|
4283
|
+
item: block.name,
|
|
4284
|
+
instruction: "The merchant can add this missing block from the theme editor and save.",
|
|
4285
|
+
url: block.addUrl
|
|
4286
|
+
});
|
|
4287
|
+
}
|
|
4288
|
+
const passwordWarning = themeSetup.passwordEnabled === true ? " Storefront password protection is enabled, so the merchant cannot visually verify the widget until it is removed." : "";
|
|
4289
|
+
if (widget?.active && actions.length === 0 && !themeSetup.passwordEnabled) {
|
|
4290
|
+
return "Loyalty widget is active. Theme setup is complete \u2014 nothing to do.";
|
|
4291
|
+
}
|
|
4292
|
+
if (!widget) {
|
|
4293
|
+
return summaryWithJson(`The loyalty widget was not included in the theme audit.${passwordWarning} This read-only tool cannot change the theme.`, { next_steps: actions });
|
|
4294
|
+
}
|
|
4295
|
+
const widgetStatus = widget.active ? "active" : "OFF";
|
|
4296
|
+
return summaryWithJson(`Loyalty widget is ${widgetStatus}.${passwordWarning} This read-only tool cannot switch anything on; only the merchant can use the server-provided links and save the change.`, { next_steps: actions });
|
|
4297
|
+
}
|
|
4298
|
+
function registerThemeSetupTools(server, ctx) {
|
|
4299
|
+
registerReadTool(server, {
|
|
4300
|
+
name: "joy_check_theme_setup",
|
|
4301
|
+
title: "Check storefront theme setup",
|
|
4302
|
+
description: "Read-only diagnosis for the Joy loyalty widget, related theme embeds, and missing blocks. It cannot switch anything on or add anything: only the merchant can open the server-provided Shopify editor link, make the change, and save. Use it to answer why the widget is not showing and to get the exact setup links; never claim this tool enabled anything.",
|
|
4303
|
+
inputSchema: {},
|
|
4304
|
+
handler: async () => {
|
|
4305
|
+
const { data } = await ctx.api.getData("/shop/theme/extensions");
|
|
4306
|
+
return formatThemeSetup(data ?? {});
|
|
4307
|
+
}
|
|
4308
|
+
});
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
// ../mcp-core/build/tools/imageUpload.js
|
|
4312
|
+
import { z as z19 } from "zod";
|
|
4313
|
+
var imageUploadInputSchema = {
|
|
4314
|
+
source_url: z19.string().url().optional().describe("Public URL of an already hosted image. Provide exactly one of source_url or data_url."),
|
|
4315
|
+
data_url: z19.string().optional().describe("Image bytes as a data URL, for example an image the agent generated. Provide exactly one of data_url or source_url."),
|
|
4316
|
+
filename: z19.string().min(1).max(200).optional().describe("Filename to use for the uploaded image."),
|
|
4317
|
+
alt: z19.string().max(512).optional().describe("Merchant-provided alternative text for the image."),
|
|
4318
|
+
reason: z19.string().min(3).describe("Why the merchant wants this image uploaded."),
|
|
4319
|
+
apply: z19.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
|
|
4320
|
+
};
|
|
4321
|
+
function imageUploadErrorGuidance(error) {
|
|
4322
|
+
if (!(error instanceof JoyApiError))
|
|
4323
|
+
return void 0;
|
|
4324
|
+
if (error.status === 400) {
|
|
4325
|
+
return new JoyApiError("validation", "The image source was rejected (not an image, unsupported type, or a blocked URL). Do not retry the same input.", error.status);
|
|
4326
|
+
}
|
|
4327
|
+
if (error.status === 413) {
|
|
4328
|
+
return new JoyApiError("validation", "The image is too large; the upload cap is 20 MB. Resize or compress it before trying again.", error.status);
|
|
4329
|
+
}
|
|
4330
|
+
if (error.status === 502) {
|
|
4331
|
+
return new JoyApiError("server", "Shopify rejected the upload or the file never went live. Safe to retry ONCE; if it fails again, stop and report the failure to the merchant.", error.status);
|
|
4332
|
+
}
|
|
4333
|
+
return void 0;
|
|
4334
|
+
}
|
|
4335
|
+
function registerImageUploadTools(server, ctx) {
|
|
4336
|
+
registerConfigWriteTool(server, ctx, {
|
|
4337
|
+
name: "joy_upload_image",
|
|
4338
|
+
title: "Upload image to Shopify Files",
|
|
4339
|
+
description: "CONFIG WRITE \u2014 uploads an image to the merchant's Shopify Files CDN and returns a permanent URL. Two-step flow: first call joy_upload_image, then pass its returned url to joy_set_widget_fields for any image field (sections.<ID>.bannerImage, membershipCard.*.bgImage, header.bannerImage, program.logo). Use data_url for image bytes the agent generated, or source_url for an image already hosted somewhere public; provide exactly one. The upload cap is 20 MB; the server validates the image type and source. This tool cannot see the image: do not describe, judge, or vouch for what was uploaded. The merchant reviews it in Joy admin. This writes into the merchant's store and is not undone by discarding a widget draft: the file stays in Shopify Files until the merchant deletes it. Dry run by default; set apply: true only after the merchant approves the preview.",
|
|
4340
|
+
inputSchema: imageUploadInputSchema,
|
|
4341
|
+
strictArguments: true,
|
|
4342
|
+
destructiveHint: false,
|
|
4343
|
+
handler: async (args) => {
|
|
4344
|
+
if (args.source_url !== void 0 === (args.data_url !== void 0)) {
|
|
4345
|
+
throw new JoyApiError("validation", "Provide exactly one of source_url or data_url, never both. No image was uploaded.");
|
|
4346
|
+
}
|
|
4347
|
+
const { source_url, data_url, filename, alt } = args;
|
|
4348
|
+
const body = { source_url, data_url, filename, alt };
|
|
4349
|
+
if (args.apply !== true) {
|
|
4350
|
+
return summaryWithJson("DRY RUN \u2014 no image was uploaded. Applying writes a permanent file into the merchant's Shopify Files; discarding a widget draft does not remove it. The file stays until the merchant deletes it. This tool cannot see the image; the merchant reviews it in Joy admin. Show this preview to the merchant, then call again with apply: true and the same arguments only after approval.", { ...body, data_url: data_url === void 0 ? void 0 : "[provided image bytes; not inspected]", reason: args.reason });
|
|
4351
|
+
}
|
|
4352
|
+
let result;
|
|
4353
|
+
try {
|
|
4354
|
+
result = await ctx.api.mutateData("/files/images", { method: "POST", body });
|
|
4355
|
+
} catch (error) {
|
|
4356
|
+
throw imageUploadErrorGuidance(error) ?? error;
|
|
4357
|
+
}
|
|
4358
|
+
return summaryWithJson("Image uploaded to the merchant's Shopify Files CDN. Pass the returned url to joy_set_widget_fields for the desired image field. This tool cannot see or vouch for the image; the merchant reviews it in Joy admin. The file remains in Shopify Files until the merchant deletes it, even if the widget draft is discarded.", result);
|
|
4359
|
+
}
|
|
4360
|
+
});
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
// ../mcp-core/build/prompts.js
|
|
4364
|
+
import { z as z20 } from "zod";
|
|
4365
|
+
function registerAllPrompts(server) {
|
|
4366
|
+
server.registerPrompt("support_missing_points", {
|
|
4367
|
+
title: "Investigate missing points for an order",
|
|
4368
|
+
description: "Support workflow: verify whether a customer really is missing points for an order, explain why, and prepare a compensation proposal with a proper audit reason.",
|
|
4369
|
+
argsSchema: {
|
|
4370
|
+
email: z20.string().describe("Customer's email address"),
|
|
4371
|
+
order_number: z20.string().optional().describe("Order name/number the customer says earned nothing, e.g. #1234")
|
|
4372
|
+
}
|
|
4373
|
+
}, ({ email, order_number }) => ({
|
|
4374
|
+
messages: [
|
|
4375
|
+
{
|
|
4376
|
+
role: "user",
|
|
4377
|
+
content: {
|
|
4378
|
+
type: "text",
|
|
4379
|
+
text: [
|
|
4380
|
+
`A customer (${email}) reports they did not receive loyalty points${order_number ? ` for order ${order_number}` : ""}. Investigate before changing anything:`,
|
|
4381
|
+
"",
|
|
4382
|
+
"1. Look up the member with joy_find_member \u2014 flag anything unusual (disabled state, guest, recently created).",
|
|
4383
|
+
`2. Check joy_get_member_activity for an earn entry matching the order${order_number ? "" : " they describe (ask me for the order number if you cannot identify it)"}.`,
|
|
4384
|
+
order_number ? `3. Run joy_explain_order_points for ${order_number} \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.` : "3. Once the order is identified, run joy_explain_order_points on it \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.",
|
|
4385
|
+
"4. Conclude with exactly one of: (a) points were already awarded (show the activity entry), (b) points were correctly not awarded (show the blocking gate/rule \u2014 cite its configured threshold or condition, do not paraphrase from memory), or (c) points are genuinely missing.",
|
|
4386
|
+
"5. Only for case (c): state the exact number of points owed and propose a joy_award_points call with a reason that names the order and the root cause. Wait for my explicit confirmation before executing \u2014 never award in the same turn as the diagnosis.",
|
|
4387
|
+
"",
|
|
4388
|
+
"Caveat to include when you rely on joy_explain_order_points: it recomputes against the order as it exists now \u2014 if the order changed after the original webhook (e.g. partial refund), the explanation reflects the current state, not the historical moment."
|
|
4389
|
+
].join("\n")
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
]
|
|
4393
|
+
}));
|
|
4394
|
+
server.registerPrompt("program_review", {
|
|
4395
|
+
title: "Loyalty program review (health + revenue + tiers + referral)",
|
|
4396
|
+
description: "Periodic business review: program health check, revenue impact, tier performance, and referral results \u2014 with data-source rules so numbers from different systems are never mixed.",
|
|
4397
|
+
argsSchema: {
|
|
4398
|
+
period: z20.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
|
|
4399
|
+
}
|
|
4400
|
+
}, ({ period }) => ({
|
|
4401
|
+
messages: [
|
|
4402
|
+
{
|
|
4403
|
+
role: "user",
|
|
4404
|
+
content: {
|
|
4405
|
+
type: "text",
|
|
4406
|
+
text: [
|
|
4407
|
+
`Review my loyalty program for ${period ?? "the last 30 days"}. Run, in parallel where possible:`,
|
|
4408
|
+
"",
|
|
4409
|
+
"1. joy_get_loyalty_health_check \u2014 overall score and flags.",
|
|
4410
|
+
"2. joy_get_loyalty_revenue_report \u2014 revenue impact (Shopify sales data).",
|
|
4411
|
+
"3. joy_get_tier_performance_breakdown \u2014 which VIP tier earns its keep.",
|
|
4412
|
+
"4. joy_get_analytics_report with report_type referral_summary, and points_movement for the same period.",
|
|
4413
|
+
"",
|
|
4414
|
+
"Data-source rules (important):",
|
|
4415
|
+
"- joy_get_loyalty_revenue_report and joy_get_tier_performance_breakdown read Shopify sales data (ShopifyQL); joy_get_analytics_report reads the Joy event log (BigQuery). These measure differently \u2014 label the source of every figure and never combine numbers across the two sources into one derived metric.",
|
|
4416
|
+
"- If a ShopifyQL tool returns a 403 about the read_reports scope, relay its grant instructions instead of substituting numbers from the other source.",
|
|
4417
|
+
"- If any response carries a backfill WARNING, repeat that caveat next to the affected numbers \u2014 do not present them as final.",
|
|
4418
|
+
"",
|
|
4419
|
+
"Format: a 3-sentence executive summary first, then anomalies worth my attention (with the flag or figure that triggered each), then exactly one concrete recommended action. Skip metrics that are unremarkable."
|
|
4420
|
+
].join("\n")
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
4423
|
+
]
|
|
4424
|
+
}));
|
|
4425
|
+
server.registerPrompt("investigate_member", {
|
|
4426
|
+
title: "Investigate a member for abuse risk",
|
|
4427
|
+
description: "Fraud/abuse triage for one member: risk flags, point activity, and referral history \u2014 with the mandatory caveat about signal limitations and no punitive action.",
|
|
4428
|
+
argsSchema: {
|
|
4429
|
+
email: z20.string().describe("Email of the member to investigate")
|
|
4430
|
+
}
|
|
4431
|
+
}, ({ email }) => ({
|
|
4432
|
+
messages: [
|
|
4433
|
+
{
|
|
4434
|
+
role: "user",
|
|
4435
|
+
content: {
|
|
4436
|
+
type: "text",
|
|
4437
|
+
text: [
|
|
4438
|
+
`I suspect member ${email} may be abusing the loyalty program (referral self-dealing, unusual redemptions, or similar). Investigate:`,
|
|
4439
|
+
"",
|
|
4440
|
+
"1. joy_find_member \u2014 profile basics and state.",
|
|
4441
|
+
"2. joy_investigate_member_risk \u2014 the pre-defined abuse flags. For every flag that fires, show the concrete supporting data the tool returns; do not add suspicions of your own beyond the flags.",
|
|
4442
|
+
"3. joy_get_member_activity and joy_get_member_referrals \u2014 recent point movements and referral results, to give the flags context.",
|
|
4443
|
+
"",
|
|
4444
|
+
"Mandatory caveat in your answer: device/IP matching compares the MOST RECENT values only \u2014 there is no full history over time \u2014 so these flags are advisory signals to support my judgement, not proof of abuse.",
|
|
4445
|
+
"",
|
|
4446
|
+
"End with: which flags fired (if any), what specific records I should open in the Joy admin to verify each one, and your read on severity. Do NOT deduct points, refund coupons, or take any corrective action \u2014 this session is investigation only; I will decide separately."
|
|
4447
|
+
].join("\n")
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
]
|
|
4451
|
+
}));
|
|
4452
|
+
server.registerPrompt("setup_loyalty_program", {
|
|
4453
|
+
title: "Set up a loyalty program safely",
|
|
4454
|
+
description: "Guided loyalty-program setup: inspect the current configuration, propose a minimal program, and make each approved configuration change one at a time.",
|
|
4455
|
+
argsSchema: {
|
|
4456
|
+
business_type: z20.string().optional().describe("Business type, e.g. 'fashion' or 'coffee shop'."),
|
|
4457
|
+
goal: z20.string().optional().describe("Primary loyalty goal, e.g. 'increase repeat purchases'.")
|
|
4458
|
+
}
|
|
4459
|
+
}, ({ business_type, goal }) => ({
|
|
4460
|
+
messages: [
|
|
4461
|
+
{
|
|
4462
|
+
role: "user",
|
|
4463
|
+
content: {
|
|
4464
|
+
type: "text",
|
|
4465
|
+
text: [
|
|
4466
|
+
`Help me set up a loyalty program${business_type ? ` for a ${business_type} business` : ""}${goal ? ` with the goal of ${goal}` : ""}. Follow this workflow exactly:`,
|
|
4467
|
+
"",
|
|
4468
|
+
"1. Read the current state first: use joy_get_loyalty_overview and the earning, redemption, and tier read tools. Never create or change anything before knowing what already exists.",
|
|
4469
|
+
"2. Propose the smallest useful program set, then get my confirmation item by item before preparing each change.",
|
|
4470
|
+
"3. For each approved configuration change, call the relevant joy_configure_* tool in DRY-RUN mode first, show me the diff, and wait for my approval.",
|
|
4471
|
+
"4. Only after that approval, call the same tool with apply: true \u2014 one tool at a time. Never batch applies in one turn.",
|
|
4472
|
+
"5. Close by listing exactly what changed and what MCP cannot do: widget branding, launching or relaunching tiers, and deleting anything."
|
|
4473
|
+
].join("\n")
|
|
4474
|
+
}
|
|
4475
|
+
}
|
|
4476
|
+
]
|
|
4477
|
+
}));
|
|
4478
|
+
server.registerPrompt("apply_brand_to_widget", {
|
|
4479
|
+
title: "Apply storefront branding to a widget draft",
|
|
4480
|
+
description: "Guided widget-branding workflow: scan the storefront, explain the detected design, get approval, and save the approved changes to the V4 draft for the merchant to review and publish manually.",
|
|
4481
|
+
argsSchema: {
|
|
4482
|
+
preferences: z20.string().optional().describe("Optional brand preferences or constraints, e.g. 'keep the current heading font' or 'use a softer button color'.")
|
|
4483
|
+
}
|
|
4484
|
+
}, ({ preferences }) => ({
|
|
4485
|
+
messages: [
|
|
4486
|
+
{
|
|
4487
|
+
role: "user",
|
|
4488
|
+
content: {
|
|
4489
|
+
type: "text",
|
|
4490
|
+
text: [
|
|
4491
|
+
`Help me apply my storefront brand to the loyalty widget${preferences ? ` while following these preferences: ${preferences}` : ""}. Follow this workflow exactly:`,
|
|
4492
|
+
"",
|
|
4493
|
+
"1. Run joy_scan_brand_design. If it reports that the storefront is password-protected, stop and ask me to temporarily disable the password before trying again. If the scan fails, do not invent colors or fonts.",
|
|
4494
|
+
"2. Describe the detected colors, fonts, and button treatment in plain language. Make clear that they are estimates derived from a storefront screenshot and that you cannot visually judge the finished widget.",
|
|
4495
|
+
"3. Propose the smallest useful set of branding changes and wait for my confirmation before preparing them.",
|
|
4496
|
+
"4. Run joy_configure_widget_draft in DRY-RUN mode first, show me the diff, and wait for my approval. Only after that approval, call it again with apply: true.",
|
|
4497
|
+
"5. State the boundary plainly: joy_configure_widget_draft writes only the V4 draft, never the live widget version. The storefront remains unchanged, and publishing is a manual human step that I must complete in the Joy admin after reviewing the draft.",
|
|
4498
|
+
"",
|
|
4499
|
+
"Do not claim that you published, can publish, or visually verified the widget."
|
|
4500
|
+
].join("\n")
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
]
|
|
4504
|
+
}));
|
|
4505
|
+
server.registerPrompt("set_up_storefront_widget", {
|
|
4506
|
+
title: "Set up the storefront loyalty widget",
|
|
4507
|
+
description: "Guided theme-setup workflow: audit the live theme, explain each missing Joy embed or block, and hand the merchant server-provided deep links to finish setup manually.",
|
|
4508
|
+
argsSchema: {
|
|
4509
|
+
focus: z20.string().optional().describe("Optional setup focus, e.g. 'loyalty widget', 'points calculator', or 'loyalty page'.")
|
|
4510
|
+
}
|
|
4511
|
+
}, ({ focus }) => ({
|
|
4512
|
+
messages: [
|
|
4513
|
+
{
|
|
4514
|
+
role: "user",
|
|
4515
|
+
content: {
|
|
4516
|
+
type: "text",
|
|
4517
|
+
text: [
|
|
4518
|
+
`Help me set up Joy on my storefront${focus ? `, focusing on ${focus}` : ""}. Follow this workflow exactly:`,
|
|
4519
|
+
"",
|
|
4520
|
+
"1. Run joy_check_theme_setup against the live theme. If storefront password protection is enabled, warn me that the result cannot be visually verified while the password is active.",
|
|
4521
|
+
"2. For every inactive or missing embed or block, explain what it does for the storefront before giving me its deep link. Copy each link verbatim from joy_check_theme_setup; never construct one yourself.",
|
|
4522
|
+
"3. You cannot install, enable, or save a theme app embed or block for me. Ask me to open each deep link, enable or add the item, click Save in the Shopify theme editor, and then return here.",
|
|
4523
|
+
"4. After I confirm that I saved the theme, run joy_check_theme_setup again and report only what the second audit verifies. Do not claim success from my confirmation alone.",
|
|
4524
|
+
"5. Keep widget publishing separate from theme setup: joy_configure_widget_draft, if used in a separate branding workflow, writes only the V4 draft and never the live widget version. Publishing remains a manual human step in the Joy admin.",
|
|
4525
|
+
"",
|
|
4526
|
+
"Do not claim that you installed a theme embed or published a widget version."
|
|
4527
|
+
].join("\n")
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4530
|
+
]
|
|
4531
|
+
}));
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4534
|
+
// ../mcp-core/build/registry.js
|
|
4535
|
+
function registerAllTools(server, ctx) {
|
|
1912
4536
|
registerOverviewTools(server, ctx);
|
|
1913
4537
|
registerAnalyticsTools(server, ctx);
|
|
1914
4538
|
registerMemberTools(server, ctx);
|
|
@@ -1920,6 +4544,9 @@ function registerAllTools(server, ctx) {
|
|
|
1920
4544
|
registerOrderExplainTools(server, ctx);
|
|
1921
4545
|
registerActivityAuditTools(server, ctx);
|
|
1922
4546
|
registerTierUpgradeTools(server, ctx);
|
|
4547
|
+
registerThemeSetupTools(server, ctx);
|
|
4548
|
+
registerBrandingReadTools(server, ctx);
|
|
4549
|
+
registerWidgetFieldReadTools(server, ctx);
|
|
1923
4550
|
if (ctx.config.writesEnabled) {
|
|
1924
4551
|
registerPointWriteTools(server, ctx);
|
|
1925
4552
|
registerTierWriteTools(server, ctx);
|
|
@@ -1928,6 +4555,17 @@ function registerAllTools(server, ctx) {
|
|
|
1928
4555
|
registerAnalyticsWriteTools(server, ctx);
|
|
1929
4556
|
log.info("write tools ENABLED (award/deduct/adjust/redeem/set-tier/refund-coupon/create-referral-invite/enable-analytics-v2). Requires the deployed REST v2 write-path (reason/source/Idempotency-Key support) \u2014 against an older backend, reason/source are silently dropped and idempotency is not enforced.");
|
|
1930
4557
|
}
|
|
4558
|
+
if (ctx.config.configWritesEnabled) {
|
|
4559
|
+
registerProgramConfigTools(server, ctx);
|
|
4560
|
+
registerTierConfigTools(server, ctx);
|
|
4561
|
+
registerShopConfigTools(server, ctx);
|
|
4562
|
+
registerReferralConfigTools(server, ctx);
|
|
4563
|
+
registerBrandingWriteTools(server, ctx);
|
|
4564
|
+
registerWidgetFieldWriteTools(server, ctx);
|
|
4565
|
+
registerReferralBrandingWriteTools(server, ctx);
|
|
4566
|
+
registerImageUploadTools(server, ctx);
|
|
4567
|
+
log.info("CONFIG write tools ENABLED (configure earning/redemption/referral program, tier, tier reward, tier settings, shop settings, referral widget, add translation language). These change behaviour for every customer at once and every one of them previews by default \u2014 an apply requires a second call. No backend audit trail records who changed configuration or why, and the endpoints accept no Idempotency-Key, so a repeated create makes a second live record.");
|
|
4568
|
+
}
|
|
1931
4569
|
}
|
|
1932
4570
|
|
|
1933
4571
|
// src/index.ts
|