@fluentcommerce/fluent-mcp-extn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +818 -0
- package/dist/config.js +195 -0
- package/dist/entity-registry.js +418 -0
- package/dist/entity-tools.js +414 -0
- package/dist/environment-tools.js +573 -0
- package/dist/errors.js +150 -0
- package/dist/event-payload.js +22 -0
- package/dist/fluent-client.js +229 -0
- package/dist/index.js +47 -0
- package/dist/resilience.js +52 -0
- package/dist/response-shaper.js +361 -0
- package/dist/sdk-client.js +237 -0
- package/dist/settings-tools.js +348 -0
- package/dist/test-tools.js +388 -0
- package/dist/tools.js +3254 -0
- package/dist/workflow-tools.js +752 -0
- package/docs/CONTRIBUTING.md +100 -0
- package/docs/E2E_TESTING.md +739 -0
- package/docs/HANDOVER_COPILOT_SETUP_STEPS.example.yml +35 -0
- package/docs/HANDOVER_ENV.example +29 -0
- package/docs/HANDOVER_GITHUB_COPILOT.md +165 -0
- package/docs/HANDOVER_GITHUB_REPO_MCP_CONFIG.example.json +31 -0
- package/docs/HANDOVER_VSCODE_MCP_JSON.example.json +10 -0
- package/docs/IMPLEMENTATION_GUIDE.md +299 -0
- package/docs/RUNBOOK.md +312 -0
- package/docs/TOOL_REFERENCE.md +1810 -0
- package/package.json +68 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime configuration loading and validation.
|
|
3
|
+
*
|
|
4
|
+
* FLUENT_PROFILE integration now delegates to the SDK's profile loader
|
|
5
|
+
* (`loadFluentProfile`) so this package does not need to duplicate profile
|
|
6
|
+
* parsing semantics.
|
|
7
|
+
*/
|
|
8
|
+
import { loadFluentProfile, } from "@fluentcommerce/fc-connect-sdk";
|
|
9
|
+
const PLACEHOLDER_VALUES = new Set([
|
|
10
|
+
"https://your_account.sandbox.api.fluentretail.com",
|
|
11
|
+
"your_retailer",
|
|
12
|
+
"your_retailer_id",
|
|
13
|
+
"your_client_id",
|
|
14
|
+
"your_client_secret",
|
|
15
|
+
"your_username",
|
|
16
|
+
"your_password",
|
|
17
|
+
"your_bearer_token",
|
|
18
|
+
"your_profile",
|
|
19
|
+
"your-client-id",
|
|
20
|
+
"your-client-secret",
|
|
21
|
+
"your-username",
|
|
22
|
+
"your-password",
|
|
23
|
+
].map((value) => value.toLowerCase()));
|
|
24
|
+
function pick(name) {
|
|
25
|
+
const raw = process.env[name];
|
|
26
|
+
if (!raw)
|
|
27
|
+
return null;
|
|
28
|
+
const trimmed = raw.trim();
|
|
29
|
+
if (trimmed.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
if (PLACEHOLDER_VALUES.has(trimmed.toLowerCase()))
|
|
32
|
+
return null;
|
|
33
|
+
return trimmed;
|
|
34
|
+
}
|
|
35
|
+
function pickNumber(name, fallback, min) {
|
|
36
|
+
const value = pick(name);
|
|
37
|
+
if (!value)
|
|
38
|
+
return fallback;
|
|
39
|
+
const parsed = Number(value);
|
|
40
|
+
if (!Number.isFinite(parsed) || parsed < min)
|
|
41
|
+
return fallback;
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
function pickWithFallback(envName, fallbackValue) {
|
|
45
|
+
return pick(envName) ?? fallbackValue;
|
|
46
|
+
}
|
|
47
|
+
function toNullableString(value) {
|
|
48
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
49
|
+
return value.trim();
|
|
50
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
51
|
+
return String(value);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function asRecord(value) {
|
|
55
|
+
if (!value || typeof value !== "object")
|
|
56
|
+
return null;
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Returns true when required OAuth credential pair exists.
|
|
61
|
+
*/
|
|
62
|
+
export function hasOAuthConfig(config) {
|
|
63
|
+
return Boolean(config.clientId && config.clientSecret);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Determines which auth path will be selected at runtime.
|
|
67
|
+
*/
|
|
68
|
+
export function detectAuthStrategy(config) {
|
|
69
|
+
if (config.useProfileClientFactory)
|
|
70
|
+
return "profile";
|
|
71
|
+
if (hasOAuthConfig(config))
|
|
72
|
+
return "oauth";
|
|
73
|
+
if (config.tokenCommand)
|
|
74
|
+
return "tokenCommand";
|
|
75
|
+
if (config.envAccessToken)
|
|
76
|
+
return "staticToken";
|
|
77
|
+
return "none";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Reads env vars, optionally loads a Fluent CLI profile via SDK, and merges
|
|
81
|
+
* values. Explicit env vars always override profile-derived values.
|
|
82
|
+
*/
|
|
83
|
+
export async function loadConfig() {
|
|
84
|
+
const profileName = pick("FLUENT_PROFILE");
|
|
85
|
+
const profileRetailer = pick("FLUENT_PROFILE_RETAILER");
|
|
86
|
+
const explicitBaseUrl = Boolean(pick("FLUENT_BASE_URL"));
|
|
87
|
+
const explicitOAuthEnv = Boolean(pick("FLUENT_CLIENT_ID") ||
|
|
88
|
+
pick("FLUENT_CLIENT_SECRET") ||
|
|
89
|
+
pick("FLUENT_USERNAME") ||
|
|
90
|
+
pick("FLUENT_PASSWORD"));
|
|
91
|
+
let profileConfig = null;
|
|
92
|
+
let profileLoadError = null;
|
|
93
|
+
if (profileName) {
|
|
94
|
+
try {
|
|
95
|
+
profileConfig = loadFluentProfile(profileName, {
|
|
96
|
+
retailer: profileRetailer ?? undefined,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
profileLoadError = error instanceof Error ? error.message : String(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const profileRecord = asRecord(profileConfig);
|
|
104
|
+
const profileBaseUrl = toNullableString(profileRecord?.baseUrl);
|
|
105
|
+
const profileRetailerId = toNullableString(profileRecord?.retailerId);
|
|
106
|
+
const profileClientId = toNullableString(profileRecord?.clientId);
|
|
107
|
+
const profileClientSecret = toNullableString(profileRecord?.clientSecret);
|
|
108
|
+
const profileUsername = toNullableString(profileRecord?.username);
|
|
109
|
+
const profilePassword = toNullableString(profileRecord?.password);
|
|
110
|
+
return {
|
|
111
|
+
profileName,
|
|
112
|
+
profileRetailer,
|
|
113
|
+
profileLoaded: Boolean(profileConfig),
|
|
114
|
+
profileLoadError,
|
|
115
|
+
useProfileClientFactory: Boolean(profileName && profileConfig && !explicitBaseUrl && !explicitOAuthEnv),
|
|
116
|
+
baseUrl: pickWithFallback("FLUENT_BASE_URL", profileBaseUrl),
|
|
117
|
+
retailerId: pickWithFallback("FLUENT_RETAILER_ID", profileRetailerId),
|
|
118
|
+
accountId: pick("FLUENT_ACCOUNT_ID"),
|
|
119
|
+
// SDK OAuth — env vars override profile values.
|
|
120
|
+
clientId: pickWithFallback("FLUENT_CLIENT_ID", profileClientId),
|
|
121
|
+
clientSecret: pickWithFallback("FLUENT_CLIENT_SECRET", profileClientSecret),
|
|
122
|
+
username: pickWithFallback("FLUENT_USERNAME", profileUsername),
|
|
123
|
+
password: pickWithFallback("FLUENT_PASSWORD", profilePassword),
|
|
124
|
+
// Credential fallback (env-only).
|
|
125
|
+
tokenCommand: pick("TOKEN_COMMAND"),
|
|
126
|
+
envAccessToken: pick("FLUENT_ACCESS_TOKEN"),
|
|
127
|
+
// Resilience (FLUENT_RETRY_ATTEMPTS=0 disables retries)
|
|
128
|
+
tokenCommandTimeoutMs: pickNumber("TOKEN_COMMAND_TIMEOUT_MS", 10000, 1000),
|
|
129
|
+
requestTimeoutMs: pickNumber("FLUENT_REQUEST_TIMEOUT_MS", 30000, 1000),
|
|
130
|
+
retryAttempts: pickNumber("FLUENT_RETRY_ATTEMPTS", 3, 0),
|
|
131
|
+
retryInitialDelayMs: pickNumber("FLUENT_RETRY_INITIAL_DELAY_MS", 300, 50),
|
|
132
|
+
retryMaxDelayMs: pickNumber("FLUENT_RETRY_MAX_DELAY_MS", 5000, 100),
|
|
133
|
+
retryFactor: pickNumber("FLUENT_RETRY_FACTOR", 2, 1),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Validates readiness for API-backed tools and returns actionable messages.
|
|
138
|
+
*/
|
|
139
|
+
export function validateConfig(config) {
|
|
140
|
+
const errors = [];
|
|
141
|
+
const warnings = [];
|
|
142
|
+
const authStrategy = detectAuthStrategy(config);
|
|
143
|
+
if (!config.baseUrl) {
|
|
144
|
+
errors.push("FLUENT_BASE_URL is required for GraphQL, batch, and event APIs.");
|
|
145
|
+
}
|
|
146
|
+
if (config.profileName && config.profileLoadError) {
|
|
147
|
+
errors.push(`FLUENT_PROFILE="${config.profileName}" could not be loaded: ${config.profileLoadError}`);
|
|
148
|
+
}
|
|
149
|
+
if (authStrategy === "none") {
|
|
150
|
+
errors.push("Authentication is not configured. Set FLUENT_PROFILE, OAuth env vars, TOKEN_COMMAND, or FLUENT_ACCESS_TOKEN.");
|
|
151
|
+
}
|
|
152
|
+
if (authStrategy === "profile" && !config.retailerId) {
|
|
153
|
+
warnings.push("Profile auth is active without retailer scope. Set FLUENT_PROFILE_RETAILER or FLUENT_RETAILER_ID for event and batch APIs.");
|
|
154
|
+
}
|
|
155
|
+
if (hasOAuthConfig(config) && !config.username && !config.password) {
|
|
156
|
+
warnings.push("OAuth credentials are configured without username/password. This is fine for client-credentials flow.");
|
|
157
|
+
}
|
|
158
|
+
if (config.tokenCommand && config.envAccessToken) {
|
|
159
|
+
warnings.push("Both TOKEN_COMMAND and FLUENT_ACCESS_TOKEN are set; TOKEN_COMMAND takes precedence.");
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
isReadyForApiCalls: errors.length === 0,
|
|
163
|
+
authStrategy,
|
|
164
|
+
errors,
|
|
165
|
+
warnings,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Returns a redacted summary suitable for logs and MCP responses.
|
|
170
|
+
*/
|
|
171
|
+
export function toSafeConfigSummary(config) {
|
|
172
|
+
return {
|
|
173
|
+
profileName: config.profileName,
|
|
174
|
+
profileRetailer: config.profileRetailer,
|
|
175
|
+
profileLoaded: config.profileLoaded,
|
|
176
|
+
hasProfileLoadError: Boolean(config.profileLoadError),
|
|
177
|
+
useProfileClientFactory: config.useProfileClientFactory,
|
|
178
|
+
baseUrl: config.baseUrl,
|
|
179
|
+
retailerId: config.retailerId,
|
|
180
|
+
accountId: config.accountId,
|
|
181
|
+
authStrategy: detectAuthStrategy(config),
|
|
182
|
+
hasClientId: Boolean(config.clientId),
|
|
183
|
+
hasClientSecret: Boolean(config.clientSecret),
|
|
184
|
+
hasUsername: Boolean(config.username),
|
|
185
|
+
hasPassword: Boolean(config.password),
|
|
186
|
+
hasTokenCommand: Boolean(config.tokenCommand),
|
|
187
|
+
hasEnvAccessToken: Boolean(config.envAccessToken),
|
|
188
|
+
tokenCommandTimeoutMs: config.tokenCommandTimeoutMs,
|
|
189
|
+
requestTimeoutMs: config.requestTimeoutMs,
|
|
190
|
+
retryAttempts: config.retryAttempts,
|
|
191
|
+
retryInitialDelayMs: config.retryInitialDelayMs,
|
|
192
|
+
retryMaxDelayMs: config.retryMaxDelayMs,
|
|
193
|
+
retryFactor: config.retryFactor,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity metadata registry for type-safe entity operations.
|
|
3
|
+
*
|
|
4
|
+
* Encodes Fluent Commerce entity-specific knowledge:
|
|
5
|
+
* - Required fields per create mutation
|
|
6
|
+
* - Compound key structures
|
|
7
|
+
* - Known gotchas (e.g., Location needs openingSchedule)
|
|
8
|
+
* - GraphQL mutation/query names
|
|
9
|
+
* - Default field selections for queries
|
|
10
|
+
*/
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Registry
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const ENTITY_REGISTRY = {
|
|
15
|
+
ORDER: {
|
|
16
|
+
createMutation: "createOrder",
|
|
17
|
+
createInputType: "CreateOrderInput",
|
|
18
|
+
updateMutation: "updateOrder",
|
|
19
|
+
updateInputType: "UpdateOrderInput",
|
|
20
|
+
queryRoot: "orders",
|
|
21
|
+
queryById: "orderById",
|
|
22
|
+
requiredCreateFields: ["ref", "type", "retailer", "customer", "items"],
|
|
23
|
+
defaultFields: [
|
|
24
|
+
"id",
|
|
25
|
+
"ref",
|
|
26
|
+
"status",
|
|
27
|
+
"type",
|
|
28
|
+
"createdOn",
|
|
29
|
+
"updatedOn",
|
|
30
|
+
],
|
|
31
|
+
edges: [
|
|
32
|
+
"fulfilmentChoice",
|
|
33
|
+
"fulfilments",
|
|
34
|
+
"items",
|
|
35
|
+
"customer",
|
|
36
|
+
"attributes",
|
|
37
|
+
],
|
|
38
|
+
gotchas: [
|
|
39
|
+
'retailer must be { id: N } not { ref: "..." }',
|
|
40
|
+
"customer must be { id: N } (CustomerId only accepts id, NOT username)",
|
|
41
|
+
"items is required: [{ ref, productRef, quantity, ... }]",
|
|
42
|
+
"status is auto-set to CREATED; do not pass status in create",
|
|
43
|
+
"type is the order type (e.g., HD, CC)",
|
|
44
|
+
],
|
|
45
|
+
hasRef: true,
|
|
46
|
+
retailerScoped: true,
|
|
47
|
+
},
|
|
48
|
+
FULFILMENT: {
|
|
49
|
+
createMutation: "createFulfilment",
|
|
50
|
+
createInputType: "CreateFulfilmentInput",
|
|
51
|
+
updateMutation: "updateFulfilment",
|
|
52
|
+
updateInputType: "UpdateFulfilmentInput",
|
|
53
|
+
queryRoot: "fulfilments",
|
|
54
|
+
queryById: "fulfilmentById",
|
|
55
|
+
requiredCreateFields: ["ref", "type", "order"],
|
|
56
|
+
defaultFields: [
|
|
57
|
+
"id",
|
|
58
|
+
"ref",
|
|
59
|
+
"status",
|
|
60
|
+
"type",
|
|
61
|
+
"createdOn",
|
|
62
|
+
"updatedOn",
|
|
63
|
+
],
|
|
64
|
+
edges: ["order", "items", "articles", "fromLocation", "attributes"],
|
|
65
|
+
gotchas: [
|
|
66
|
+
'order must be { id: N } or { ref: "..." } (OrderId!)',
|
|
67
|
+
"no retailer field — inherited from the order",
|
|
68
|
+
"status auto-set to CREATED",
|
|
69
|
+
],
|
|
70
|
+
hasRef: true,
|
|
71
|
+
retailerScoped: true,
|
|
72
|
+
},
|
|
73
|
+
LOCATION: {
|
|
74
|
+
createMutation: "createLocation",
|
|
75
|
+
createInputType: "CreateLocationInput",
|
|
76
|
+
updateMutation: "updateLocation",
|
|
77
|
+
updateInputType: "UpdateLocationInput",
|
|
78
|
+
queryRoot: "locations",
|
|
79
|
+
queryById: "locationById",
|
|
80
|
+
requiredCreateFields: ["ref", "type", "name", "openingSchedule"],
|
|
81
|
+
defaultFields: [
|
|
82
|
+
"id",
|
|
83
|
+
"ref",
|
|
84
|
+
"status",
|
|
85
|
+
"type",
|
|
86
|
+
"name",
|
|
87
|
+
"createdOn",
|
|
88
|
+
"updatedOn",
|
|
89
|
+
],
|
|
90
|
+
edges: ["networks", "storageAreas", "attributes"],
|
|
91
|
+
gotchas: [
|
|
92
|
+
"openingSchedule is ALWAYS required (even for 24/7 warehouses, use allHours: true)",
|
|
93
|
+
'retailer must be { id: N }',
|
|
94
|
+
"status auto-set to CREATED",
|
|
95
|
+
],
|
|
96
|
+
hasRef: true,
|
|
97
|
+
retailerScoped: true,
|
|
98
|
+
},
|
|
99
|
+
NETWORK: {
|
|
100
|
+
createMutation: "createNetwork",
|
|
101
|
+
createInputType: "CreateNetworkInput",
|
|
102
|
+
updateMutation: "updateNetwork",
|
|
103
|
+
updateInputType: "UpdateNetworkInput",
|
|
104
|
+
queryRoot: "networks",
|
|
105
|
+
queryById: "networkById",
|
|
106
|
+
requiredCreateFields: ["name", "type", "retailers"],
|
|
107
|
+
defaultFields: ["id", "ref", "status", "type", "name", "createdOn"],
|
|
108
|
+
edges: ["locations", "retailers"],
|
|
109
|
+
gotchas: [
|
|
110
|
+
"Uses 'name' not 'ref' in create; name becomes the ref",
|
|
111
|
+
"retailers is a plural ARRAY, not a single retailer",
|
|
112
|
+
],
|
|
113
|
+
hasRef: true,
|
|
114
|
+
retailerScoped: false,
|
|
115
|
+
},
|
|
116
|
+
CUSTOMER: {
|
|
117
|
+
createMutation: "createCustomer",
|
|
118
|
+
createInputType: "CreateCustomerInput",
|
|
119
|
+
updateMutation: "updateCustomer",
|
|
120
|
+
updateInputType: "UpdateCustomerInput",
|
|
121
|
+
queryRoot: "customers",
|
|
122
|
+
queryById: "customerById",
|
|
123
|
+
requiredCreateFields: [
|
|
124
|
+
"username",
|
|
125
|
+
"firstName",
|
|
126
|
+
"lastName",
|
|
127
|
+
"retailer",
|
|
128
|
+
"promotionOptIn",
|
|
129
|
+
],
|
|
130
|
+
defaultFields: [
|
|
131
|
+
"id",
|
|
132
|
+
"username",
|
|
133
|
+
"status",
|
|
134
|
+
"firstName",
|
|
135
|
+
"lastName",
|
|
136
|
+
"primaryEmail",
|
|
137
|
+
"createdOn",
|
|
138
|
+
],
|
|
139
|
+
edges: ["retailer", "attributes"],
|
|
140
|
+
gotchas: [
|
|
141
|
+
"NO ref field; username is the identifier",
|
|
142
|
+
"promotionOptIn (Boolean!) is REQUIRED",
|
|
143
|
+
'retailer must be { id: N }',
|
|
144
|
+
],
|
|
145
|
+
hasRef: false,
|
|
146
|
+
retailerScoped: true,
|
|
147
|
+
},
|
|
148
|
+
PRODUCT: {
|
|
149
|
+
createMutation: "createProduct",
|
|
150
|
+
createInputType: "CreateProductInput",
|
|
151
|
+
updateMutation: "updateProduct",
|
|
152
|
+
updateInputType: "UpdateProductInput",
|
|
153
|
+
queryRoot: "products",
|
|
154
|
+
queryById: "productById",
|
|
155
|
+
requiredCreateFields: ["ref", "type", "name", "catalogue"],
|
|
156
|
+
defaultFields: [
|
|
157
|
+
"id",
|
|
158
|
+
"ref",
|
|
159
|
+
"status",
|
|
160
|
+
"type",
|
|
161
|
+
"name",
|
|
162
|
+
"gtin",
|
|
163
|
+
"createdOn",
|
|
164
|
+
],
|
|
165
|
+
edges: ["categories", "attributes", "catalogue"],
|
|
166
|
+
gotchas: [
|
|
167
|
+
'catalogue must be { ref: "PC:MASTER:N" } (ProductCatalogueKey)',
|
|
168
|
+
"gtin has 20-char max; do not use long refs as gtin",
|
|
169
|
+
"status auto-set to CREATED",
|
|
170
|
+
'For variant→parent link, use product: { ref, catalogue: { ref } } (ProductKey requires BOTH)',
|
|
171
|
+
],
|
|
172
|
+
hasRef: true,
|
|
173
|
+
retailerScoped: false,
|
|
174
|
+
},
|
|
175
|
+
INVENTORY_POSITION: {
|
|
176
|
+
createMutation: "createInventoryPosition",
|
|
177
|
+
createInputType: "CreateInventoryPositionInput",
|
|
178
|
+
updateMutation: "updateInventoryPosition",
|
|
179
|
+
updateInputType: "UpdateInventoryPositionInput",
|
|
180
|
+
queryRoot: "inventoryPositions",
|
|
181
|
+
queryById: "inventoryPositionById",
|
|
182
|
+
requiredCreateFields: ["ref", "type", "catalogue", "locationRef", "productRef"],
|
|
183
|
+
defaultFields: [
|
|
184
|
+
"id",
|
|
185
|
+
"ref",
|
|
186
|
+
"status",
|
|
187
|
+
"type",
|
|
188
|
+
"onHand",
|
|
189
|
+
"createdOn",
|
|
190
|
+
"updatedOn",
|
|
191
|
+
],
|
|
192
|
+
edges: ["location", "product", "catalogue", "attributes"],
|
|
193
|
+
gotchas: [
|
|
194
|
+
'catalogue must be { ref: "DEFAULT:N" } (InventoryCatalogueKey)',
|
|
195
|
+
"locationRef and productRef are String fields, not object keys",
|
|
196
|
+
"status auto-set to CREATED",
|
|
197
|
+
],
|
|
198
|
+
hasRef: true,
|
|
199
|
+
retailerScoped: false,
|
|
200
|
+
},
|
|
201
|
+
VIRTUAL_CATALOGUE: {
|
|
202
|
+
createMutation: "createVirtualCatalogue",
|
|
203
|
+
createInputType: "CreateVirtualCatalogueInput",
|
|
204
|
+
updateMutation: "updateVirtualCatalogue",
|
|
205
|
+
updateInputType: "UpdateVirtualCatalogueInput",
|
|
206
|
+
queryRoot: "virtualCatalogues",
|
|
207
|
+
queryById: "virtualCatalogueById",
|
|
208
|
+
requiredCreateFields: [
|
|
209
|
+
"ref",
|
|
210
|
+
"type",
|
|
211
|
+
"inventoryCatalogueRef",
|
|
212
|
+
"productCatalogueRef",
|
|
213
|
+
],
|
|
214
|
+
defaultFields: ["id", "ref", "status", "type", "createdOn"],
|
|
215
|
+
edges: ["virtualPositions"],
|
|
216
|
+
gotchas: [
|
|
217
|
+
"inventoryCatalogueRef and productCatalogueRef are both String! (not object keys)",
|
|
218
|
+
],
|
|
219
|
+
hasRef: true,
|
|
220
|
+
retailerScoped: false,
|
|
221
|
+
},
|
|
222
|
+
VIRTUAL_POSITION: {
|
|
223
|
+
createMutation: "createVirtualPosition",
|
|
224
|
+
createInputType: "CreateVirtualPositionInput",
|
|
225
|
+
updateMutation: "updateVirtualPosition",
|
|
226
|
+
updateInputType: "UpdateVirtualPositionInput",
|
|
227
|
+
queryRoot: "virtualPositions",
|
|
228
|
+
queryById: "virtualPositionById",
|
|
229
|
+
requiredCreateFields: ["ref", "type", "catalogue", "productRef", "quantity"],
|
|
230
|
+
defaultFields: ["id", "ref", "status", "type", "quantity", "createdOn"],
|
|
231
|
+
edges: ["catalogue", "product"],
|
|
232
|
+
gotchas: ["quantity (Int!) is REQUIRED"],
|
|
233
|
+
hasRef: true,
|
|
234
|
+
retailerScoped: false,
|
|
235
|
+
},
|
|
236
|
+
CATEGORY: {
|
|
237
|
+
createMutation: "createCategory",
|
|
238
|
+
createInputType: "CreateCategoryInput",
|
|
239
|
+
updateMutation: "updateCategory",
|
|
240
|
+
updateInputType: "UpdateCategoryInput",
|
|
241
|
+
queryRoot: "categories",
|
|
242
|
+
queryById: "categoryById",
|
|
243
|
+
requiredCreateFields: ["ref", "type", "name", "catalogue"],
|
|
244
|
+
defaultFields: ["id", "ref", "status", "type", "name", "createdOn"],
|
|
245
|
+
edges: ["products", "childCategories", "parentCategory", "attributes"],
|
|
246
|
+
gotchas: ['catalogue must be { ref: "..." } (ProductCatalogueKey)'],
|
|
247
|
+
hasRef: true,
|
|
248
|
+
retailerScoped: false,
|
|
249
|
+
},
|
|
250
|
+
CARRIER: {
|
|
251
|
+
createMutation: "createCarrier",
|
|
252
|
+
createInputType: "CreateCarrierInput",
|
|
253
|
+
updateMutation: "updateCarrier",
|
|
254
|
+
updateInputType: "UpdateCarrierInput",
|
|
255
|
+
queryRoot: "carriers",
|
|
256
|
+
queryById: "carrierById",
|
|
257
|
+
requiredCreateFields: ["name", "type", "retailer"],
|
|
258
|
+
defaultFields: ["id", "ref", "status", "type", "name", "createdOn"],
|
|
259
|
+
edges: ["retailer", "consignmentArticles", "attributes"],
|
|
260
|
+
gotchas: [
|
|
261
|
+
"name is required in create",
|
|
262
|
+
'retailer must be { id: N }',
|
|
263
|
+
],
|
|
264
|
+
hasRef: true,
|
|
265
|
+
retailerScoped: true,
|
|
266
|
+
},
|
|
267
|
+
SETTING: {
|
|
268
|
+
createMutation: "createSetting",
|
|
269
|
+
createInputType: "CreateSettingInput",
|
|
270
|
+
updateMutation: "updateSetting",
|
|
271
|
+
updateInputType: "UpdateSettingInput",
|
|
272
|
+
queryRoot: "settings",
|
|
273
|
+
queryById: "settingById",
|
|
274
|
+
requiredCreateFields: ["name", "value", "context", "contextId"],
|
|
275
|
+
defaultFields: [
|
|
276
|
+
"id",
|
|
277
|
+
"name",
|
|
278
|
+
"value",
|
|
279
|
+
"valueType",
|
|
280
|
+
"context",
|
|
281
|
+
"contextId",
|
|
282
|
+
"lobValue",
|
|
283
|
+
],
|
|
284
|
+
edges: [],
|
|
285
|
+
gotchas: [
|
|
286
|
+
'context is a plain String ("RETAILER"), NOT { contextType: "RETAILER" }',
|
|
287
|
+
"contextId is a separate Int field, not combined with context",
|
|
288
|
+
"For large JSON values, use lobValue instead of value (lobType: LOB)",
|
|
289
|
+
],
|
|
290
|
+
hasRef: false,
|
|
291
|
+
retailerScoped: false,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Public API
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
/** All supported entity types */
|
|
298
|
+
export const SUPPORTED_ENTITY_TYPES = Object.keys(ENTITY_REGISTRY);
|
|
299
|
+
/**
|
|
300
|
+
* Look up entity metadata. Returns null for unsupported types.
|
|
301
|
+
* Accepts case-insensitive input and normalizes underscores.
|
|
302
|
+
*/
|
|
303
|
+
export function getEntityMeta(entityType) {
|
|
304
|
+
const normalized = entityType.toUpperCase().replace(/-/g, "_");
|
|
305
|
+
return ENTITY_REGISTRY[normalized] ?? null;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Build a GraphQL create mutation string for an entity type.
|
|
309
|
+
* Returns the mutation string and the input type for variable binding.
|
|
310
|
+
*/
|
|
311
|
+
export function buildCreateMutation(entityType, returnFields) {
|
|
312
|
+
const meta = getEntityMeta(entityType);
|
|
313
|
+
if (!meta)
|
|
314
|
+
return null;
|
|
315
|
+
const fields = returnFields ?? meta.defaultFields;
|
|
316
|
+
const fieldSelection = fields.join(" ");
|
|
317
|
+
return {
|
|
318
|
+
mutation: `mutation Create($input: ${meta.createInputType}!) {
|
|
319
|
+
${meta.createMutation}(input: $input) {
|
|
320
|
+
${fieldSelection}
|
|
321
|
+
}
|
|
322
|
+
}`,
|
|
323
|
+
inputType: meta.createInputType,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Build a GraphQL update mutation string for an entity type.
|
|
328
|
+
*/
|
|
329
|
+
export function buildUpdateMutation(entityType, returnFields) {
|
|
330
|
+
const meta = getEntityMeta(entityType);
|
|
331
|
+
if (!meta)
|
|
332
|
+
return null;
|
|
333
|
+
const fields = returnFields ?? meta.defaultFields;
|
|
334
|
+
const fieldSelection = fields.join(" ");
|
|
335
|
+
return {
|
|
336
|
+
mutation: `mutation Update($input: ${meta.updateInputType}!) {
|
|
337
|
+
${meta.updateMutation}(input: $input) {
|
|
338
|
+
${fieldSelection}
|
|
339
|
+
}
|
|
340
|
+
}`,
|
|
341
|
+
inputType: meta.updateInputType,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Build a GraphQL query to look up an entity by ID.
|
|
346
|
+
*/
|
|
347
|
+
export function buildGetByIdQuery(entityType, includeEdges) {
|
|
348
|
+
const meta = getEntityMeta(entityType);
|
|
349
|
+
if (!meta)
|
|
350
|
+
return null;
|
|
351
|
+
const fields = [...meta.defaultFields];
|
|
352
|
+
if (includeEdges) {
|
|
353
|
+
for (const edge of includeEdges) {
|
|
354
|
+
if (meta.edges.includes(edge)) {
|
|
355
|
+
// For connection edges, include basic node fields
|
|
356
|
+
fields.push(`${edge} { edges { node { id ref status } } }`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const fieldSelection = fields.join("\n ");
|
|
361
|
+
return {
|
|
362
|
+
query: `query GetById($id: ID!) {
|
|
363
|
+
${meta.queryById}(id: $id) {
|
|
364
|
+
${fieldSelection}
|
|
365
|
+
}
|
|
366
|
+
}`,
|
|
367
|
+
queryRoot: meta.queryById,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Build a GraphQL query to search entities by ref.
|
|
372
|
+
*/
|
|
373
|
+
export function buildSearchByRefQuery(entityType, includeEdges) {
|
|
374
|
+
const meta = getEntityMeta(entityType);
|
|
375
|
+
if (!meta)
|
|
376
|
+
return null;
|
|
377
|
+
if (!meta.hasRef) {
|
|
378
|
+
// Entities without ref (like CUSTOMER) need different lookup
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
const fields = [...meta.defaultFields];
|
|
382
|
+
if (includeEdges) {
|
|
383
|
+
for (const edge of includeEdges) {
|
|
384
|
+
if (meta.edges.includes(edge)) {
|
|
385
|
+
fields.push(`${edge} { edges { node { id ref status } } }`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const fieldSelection = fields.join("\n ");
|
|
390
|
+
return {
|
|
391
|
+
query: `query SearchByRef($ref: [String]) {
|
|
392
|
+
${meta.queryRoot}(ref: $ref, first: 1) {
|
|
393
|
+
edges {
|
|
394
|
+
node {
|
|
395
|
+
${fieldSelection}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}`,
|
|
400
|
+
queryRoot: meta.queryRoot,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Validate that required create fields are present in the input data.
|
|
405
|
+
* Returns an array of missing field names.
|
|
406
|
+
*/
|
|
407
|
+
export function validateCreateInput(entityType, data) {
|
|
408
|
+
const meta = getEntityMeta(entityType);
|
|
409
|
+
if (!meta)
|
|
410
|
+
return [];
|
|
411
|
+
const missing = [];
|
|
412
|
+
for (const field of meta.requiredCreateFields) {
|
|
413
|
+
if (data[field] === undefined || data[field] === null) {
|
|
414
|
+
missing.push(field);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return missing;
|
|
418
|
+
}
|