@adsim/wordpress-mcp-server 4.6.0 → 5.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/.env.example +18 -0
- package/README.md +851 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +353 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
// src/tools/woocommerce.js — woocommerce tools (20)
|
|
2
|
+
// Definitions + handlers (v5.0.0 refactor Step B+C)
|
|
3
|
+
|
|
4
|
+
import { json, strip } from '../shared/utils.js';
|
|
5
|
+
import { validateInput } from '../shared/governance.js';
|
|
6
|
+
import { rt } from '../shared/context.js';
|
|
7
|
+
|
|
8
|
+
export const definitions = [
|
|
9
|
+
{ name: 'wc_list_products', _category: 'woocommerce', description: 'Use to browse WooCommerce products. Filter by status, category, or search. Read-only.',
|
|
10
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'any', description: 'any, draft, pending, private, publish' }, search: { type: 'string' }, category: { type: 'number' }, orderby: { type: 'string', default: 'date', description: 'date, id, title, price, popularity' }, order: { type: 'string', default: 'desc' } }}},
|
|
11
|
+
{ name: 'wc_get_product', _category: 'woocommerce', description: 'Use to get full product details including variations summary. Read-only.',
|
|
12
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] }},
|
|
13
|
+
{ name: 'wc_list_orders', _category: 'woocommerce', description: 'Use to browse WooCommerce orders. Filter by status or customer. Read-only.',
|
|
14
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'any', description: 'any, pending, processing, on-hold, completed, cancelled, refunded, failed' }, customer: { type: 'number' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' } }}},
|
|
15
|
+
{ name: 'wc_get_order', _category: 'woocommerce', description: 'Use to get order details with line items, shipping, billing, and payment info. Read-only.',
|
|
16
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] }},
|
|
17
|
+
{ name: 'wc_list_customers', _category: 'woocommerce', description: 'Use to list WooCommerce customers with search and role filtering. Read-only.',
|
|
18
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, search: { type: 'string' }, orderby: { type: 'string', default: 'date_created' }, order: { type: 'string', default: 'desc' }, role: { type: 'string', default: 'customer' } }}},
|
|
19
|
+
{ name: 'wc_price_guardrail', _category: 'woocommerce', description: 'Use BEFORE changing a product price. Analyzes safety of proposed price change. Read-only, always allowed.',
|
|
20
|
+
inputSchema: { type: 'object', properties: { product_id: { type: 'number' }, new_price: { type: 'number' }, threshold_percent: { type: 'number', default: 20, description: 'default 20' } }, required: ['product_id', 'new_price'] }},
|
|
21
|
+
{ name: 'wc_inventory_alert', _category: 'woocommerce', description: 'Use to identify low-stock and out-of-stock products below a threshold, sorted by urgency. Read-only.',
|
|
22
|
+
inputSchema: { type: 'object', properties: { threshold: { type: 'number', default: 5, description: 'default 5' }, per_page: { type: 'number', default: 50 }, include_out_of_stock: { type: 'boolean', default: true, description: 'include out-of-stock' } }}},
|
|
23
|
+
{ name: 'wc_order_intelligence', _category: 'woocommerce', description: 'Use to analyze a customer\'s purchase history: lifetime value, average order, favorite products, frequency. Read-only.',
|
|
24
|
+
inputSchema: { type: 'object', properties: { customer_id: { type: 'number' } }, required: ['customer_id'] }},
|
|
25
|
+
{ name: 'wc_seo_product_audit', _category: 'woocommerce', description: 'Use to audit WooCommerce product listings for SEO issues (missing descriptions, images, alt text, generic slugs). Read-only.',
|
|
26
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 20 }, page: { type: 'number', default: 1 } }}},
|
|
27
|
+
{ name: 'wc_suggest_product_links', _category: 'woocommerce', description: 'Use to suggest WooCommerce products to link from a blog post based on keyword relevance. Read-only.',
|
|
28
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number' }, max_suggestions: { type: 'number', default: 3, description: 'Maximum suggestions (1-5)' } }, required: ['post_id'] }},
|
|
29
|
+
{ name: 'wc_audit_product_seo', _category: 'woocommerce', description: 'Use to audit WooCommerce product SEO: title length, description, slug quality, image alt, schema presence. Scoring 0-100. Read-only.',
|
|
30
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'default 100, max 500' }, category_id: { type: 'number' }, min_score: { type: 'number', description: 'default 0 (all)' } }}},
|
|
31
|
+
{ name: 'wc_find_abandoned_carts_pattern', _category: 'woocommerce', description: 'Use to analyze abandoned cart patterns: hourly/daily trends, top abandoned products, estimated revenue loss. Requires Abandoned Cart Lite, CartFlows, or WC sessions. Read-only.',
|
|
32
|
+
inputSchema: { type: 'object', properties: { days: { type: 'number', description: 'default 30 days' }, min_cart_value: { type: 'number', description: 'default 0' } }}},
|
|
33
|
+
{ name: 'wc_audit_checkout_friction', _category: 'woocommerce', description: 'Use to audit checkout friction: guest checkout, required fields count, coupon fields, multi-step, post-purchase redirect. Scores 0-10. Read-only.',
|
|
34
|
+
inputSchema: { type: 'object', properties: {} }},
|
|
35
|
+
{ name: 'wc_get_product_performance', _category: 'woocommerce', description: 'Use to get product performance metrics with period comparison: units sold, revenue, refund rate, trend direction. Read-only.',
|
|
36
|
+
inputSchema: { type: 'object', properties: { product_id: { type: 'number', description: 'Product ID' }, period: { type: 'string', enum: ['30d', '90d', 'year'], description: 'Analysis period (default "30d")' } }, required: ['product_id'] }},
|
|
37
|
+
{ name: 'wc_audit_stock_alerts', _category: 'woocommerce', description: 'Use to audit stock levels: out-of-stock, low-stock with last sale date. Includes variations. Read-only.',
|
|
38
|
+
inputSchema: { type: 'object', properties: { threshold: { type: 'number', description: 'default 5' }, include_zero_stock: { type: 'boolean', description: 'default true' }, include_variations: { type: 'boolean', description: 'default true' }, post_types: { type: 'array', items: { type: 'string' }, description: 'Product types (default ["simple","variable"])' } }}},
|
|
39
|
+
{ name: 'wc_find_duplicate_products', _category: 'woocommerce', description: 'Use to find duplicate products by SKU, title similarity (Levenshtein), or slug similarity. Read-only.',
|
|
40
|
+
inputSchema: { type: 'object', properties: { similarity_threshold: { type: 'number', description: 'default 0.8 (0-1)' }, check_sku: { type: 'boolean', description: 'default true' }, check_title: { type: 'boolean', description: 'default true' }, check_slug: { type: 'boolean', description: 'default true' }, limit: { type: 'number', description: 'default 200' } }}},
|
|
41
|
+
{ name: 'wc_audit_pricing_consistency', _category: 'woocommerce', description: 'Use to find pricing inconsistencies: sale >= regular, empty prices, expired sales, suspicious discounts. Read-only.',
|
|
42
|
+
inputSchema: { type: 'object', properties: { include_drafts: { type: 'boolean', description: 'Include draft products (default false)' }, include_variations: { type: 'boolean', description: 'Include variations (default true)' } }}},
|
|
43
|
+
{ name: 'wc_update_product', _category: 'woocommerce', description: 'Use to update product fields (title, description, price, stock, status). Write — blocked by WP_READ_ONLY. Hint: call wc_price_guardrail first for price changes.',
|
|
44
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' }, name: { type: 'string' }, description: { type: 'string' }, short_description: { type: 'string' }, regular_price: { type: 'string', description: 'Format "19.99"' }, sale_price: { type: 'string' }, status: { type: 'string', description: 'publish, draft, or private' }, price_guardrail_confirmed: { type: 'boolean', default: false, description: 'Set true to bypass price guardrail after calling wc_price_guardrail' } }, required: ['id'] }},
|
|
45
|
+
{ name: 'wc_update_stock', _category: 'woocommerce', description: 'Use to update stock quantity of a product or variation. Write — blocked by WP_READ_ONLY.',
|
|
46
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' }, stock_quantity: { type: 'number' }, variation_id: { type: 'number', description: 'Variation ID (for variable products)' } }, required: ['id', 'stock_quantity'] }},
|
|
47
|
+
{ name: 'wc_update_order_status', _category: 'woocommerce', description: 'Use to transition order status (e.g. processing → completed). Write — blocked by WP_READ_ONLY.',
|
|
48
|
+
inputSchema: { type: 'object', properties: { id: { type: 'number' }, status: { type: 'string', description: 'processing, completed, cancelled, refunded, on-hold, failed' }, note: { type: 'string' } }, required: ['id', 'status'] }}
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export const handlers = {};
|
|
52
|
+
|
|
53
|
+
handlers['wc_list_products'] = async (args) => {
|
|
54
|
+
const t0 = Date.now();
|
|
55
|
+
let result;
|
|
56
|
+
const { wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
57
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 } });
|
|
58
|
+
const { per_page = 10, page = 1, status = 'any', search, category, orderby = 'date', order = 'desc' } = args;
|
|
59
|
+
const { url: baseUrl } = getActiveAuth();
|
|
60
|
+
let ep = `/products?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}`;
|
|
61
|
+
if (status && status !== 'any') ep += `&status=${status}`;
|
|
62
|
+
if (search) ep += `&search=${encodeURIComponent(search)}`;
|
|
63
|
+
if (category) ep += `&category=${category}`;
|
|
64
|
+
const products = await wcApiCall(ep, {}, baseUrl);
|
|
65
|
+
result = json({ total: products.length, page, products: products.map(p => ({ id: p.id, name: p.name, slug: p.slug, status: p.status, price: p.price, regular_price: p.regular_price, sale_price: p.sale_price, stock_status: p.stock_status, stock_quantity: p.stock_quantity, categories: (p.categories || []).map(c => ({ id: c.id, name: c.name })), image: p.images?.[0]?.src || null, permalink: p.permalink })) });
|
|
66
|
+
auditLog({ tool: name, action: 'list', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
67
|
+
return result;
|
|
68
|
+
};
|
|
69
|
+
handlers['wc_get_product'] = async (args) => {
|
|
70
|
+
const t0 = Date.now();
|
|
71
|
+
let result;
|
|
72
|
+
const { wcApiCall, getActiveAuth, auditLog, name } = rt;
|
|
73
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
74
|
+
const { url: baseUrl } = getActiveAuth();
|
|
75
|
+
const p = await wcApiCall(`/products/${args.id}`, {}, baseUrl);
|
|
76
|
+
const productData = { ...p };
|
|
77
|
+
if (p.type === 'variable' && p.variations && p.variations.length > 0) {
|
|
78
|
+
try {
|
|
79
|
+
const vars = await wcApiCall(`/products/${args.id}/variations?per_page=100`, {}, baseUrl);
|
|
80
|
+
productData.variations_detail = vars.map(v => ({ id: v.id, sku: v.sku, price: v.price, regular_price: v.regular_price, sale_price: v.sale_price, stock_status: v.stock_status, stock_quantity: v.stock_quantity, attributes: v.attributes }));
|
|
81
|
+
} catch { productData.variations_detail = []; }
|
|
82
|
+
}
|
|
83
|
+
result = json(productData);
|
|
84
|
+
auditLog({ tool: name, target: args.id, target_type: 'product', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
handlers['wc_list_orders'] = async (args) => {
|
|
88
|
+
const t0 = Date.now();
|
|
89
|
+
let result;
|
|
90
|
+
const { wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
91
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 } });
|
|
92
|
+
const { per_page = 10, page = 1, status = 'any', customer, orderby = 'date', order = 'desc' } = args;
|
|
93
|
+
const { url: baseUrl } = getActiveAuth();
|
|
94
|
+
let ep = `/orders?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}`;
|
|
95
|
+
if (status && status !== 'any') ep += `&status=${status}`;
|
|
96
|
+
if (customer) ep += `&customer=${customer}`;
|
|
97
|
+
const orders = await wcApiCall(ep, {}, baseUrl);
|
|
98
|
+
result = json({ total: orders.length, page, orders: orders.map(o => ({ id: o.id, number: o.number, status: o.status, date_created: o.date_created, customer_id: o.customer_id, billing_name: `${o.billing?.first_name || ''} ${o.billing?.last_name || ''}`.trim(), billing_email: o.billing?.email || '', total: o.total, currency: o.currency, line_items: (o.line_items || []).map(li => ({ name: li.name, quantity: li.quantity, total: li.total })) })) });
|
|
99
|
+
auditLog({ tool: name, action: 'list', target_type: 'order', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
100
|
+
return result;
|
|
101
|
+
};
|
|
102
|
+
handlers['wc_get_order'] = async (args) => {
|
|
103
|
+
const t0 = Date.now();
|
|
104
|
+
let result;
|
|
105
|
+
const { wcApiCall, getActiveAuth, auditLog, name } = rt;
|
|
106
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
107
|
+
const { url: baseUrl } = getActiveAuth();
|
|
108
|
+
const o = await wcApiCall(`/orders/${args.id}`, {}, baseUrl);
|
|
109
|
+
result = json(o);
|
|
110
|
+
auditLog({ tool: name, target: args.id, target_type: 'order', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
|
|
111
|
+
return result;
|
|
112
|
+
};
|
|
113
|
+
handlers['wc_list_customers'] = async (args) => {
|
|
114
|
+
const t0 = Date.now();
|
|
115
|
+
let result;
|
|
116
|
+
const { wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
117
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 } });
|
|
118
|
+
const { per_page = 10, page = 1, search, orderby = 'date_created', order = 'desc', role = 'customer' } = args;
|
|
119
|
+
const { url: baseUrl } = getActiveAuth();
|
|
120
|
+
let ep = `/customers?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}&role=${role}`;
|
|
121
|
+
if (search) ep += `&search=${encodeURIComponent(search)}`;
|
|
122
|
+
const customers = await wcApiCall(ep, {}, baseUrl);
|
|
123
|
+
result = json({ total: customers.length, page, customers: customers.map(c => ({ id: c.id, first_name: c.first_name, last_name: c.last_name, email: c.email, date_created: c.date_created, orders_count: c.orders_count, total_spent: c.total_spent, username: c.username })) });
|
|
124
|
+
auditLog({ tool: name, action: 'list', target_type: 'customer', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
125
|
+
return result;
|
|
126
|
+
};
|
|
127
|
+
handlers['wc_price_guardrail'] = async (args) => {
|
|
128
|
+
const t0 = Date.now();
|
|
129
|
+
let result;
|
|
130
|
+
const { wcApiCall, getActiveAuth, auditLog, name } = rt;
|
|
131
|
+
validateInput(args, { product_id: { type: 'number', required: true, min: 1 }, new_price: { type: 'number', required: true }, threshold_percent: { type: 'number', min: 0 } });
|
|
132
|
+
const { product_id, new_price, threshold_percent = 20 } = args;
|
|
133
|
+
const { url: baseUrl } = getActiveAuth();
|
|
134
|
+
const p = await wcApiCall(`/products/${product_id}`, {}, baseUrl);
|
|
135
|
+
const current_price = parseFloat(p.price);
|
|
136
|
+
if (isNaN(current_price) || current_price <= 0) {
|
|
137
|
+
result = json({ safe: false, product_id, product_name: p.name, current_price: p.price, new_price, change_percent: null, message: 'Cannot evaluate: current price is zero or invalid' });
|
|
138
|
+
auditLog({ tool: name, target: product_id, target_type: 'product', action: 'price_check', status: 'success', latency_ms: Date.now() - t0, params: { new_price, threshold_percent } });
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
const change_percent = Math.round(Math.abs(new_price - current_price) / current_price * 10000) / 100;
|
|
142
|
+
const safe = change_percent <= threshold_percent;
|
|
143
|
+
result = json({
|
|
144
|
+
safe, product_id, product_name: p.name, current_price, new_price, change_percent,
|
|
145
|
+
...(safe
|
|
146
|
+
? { message: 'Price change within threshold' }
|
|
147
|
+
: { requires_confirmation: true, message: `Price change of ${change_percent}% exceeds threshold of ${threshold_percent}%. Use wp_update_product with confirm=true to proceed.` })
|
|
148
|
+
});
|
|
149
|
+
auditLog({ tool: name, target: product_id, target_type: 'product', action: 'price_check', status: 'success', latency_ms: Date.now() - t0, params: { current_price, new_price, change_percent, threshold_percent, safe } });
|
|
150
|
+
return result;
|
|
151
|
+
};
|
|
152
|
+
handlers['wc_inventory_alert'] = async (args) => {
|
|
153
|
+
const t0 = Date.now();
|
|
154
|
+
let result;
|
|
155
|
+
const { wcApiCall, getActiveAuth, auditLog, name } = rt;
|
|
156
|
+
validateInput(args, { threshold: { type: 'number', min: 0 }, per_page: { type: 'number', min: 1, max: 100 } });
|
|
157
|
+
const { threshold = 5, per_page = 50, include_out_of_stock = true } = args;
|
|
158
|
+
const { url: baseUrl } = getActiveAuth();
|
|
159
|
+
const fetchSize = Math.min(per_page * 2, 100);
|
|
160
|
+
let allProducts = [];
|
|
161
|
+
const instock = await wcApiCall(`/products?stock_status=instock&per_page=${fetchSize}`, {}, baseUrl);
|
|
162
|
+
allProducts.push(...instock);
|
|
163
|
+
if (include_out_of_stock) {
|
|
164
|
+
const oos = await wcApiCall(`/products?stock_status=outofstock&per_page=${fetchSize}`, {}, baseUrl);
|
|
165
|
+
allProducts.push(...oos);
|
|
166
|
+
}
|
|
167
|
+
const alerts = allProducts.filter(p => {
|
|
168
|
+
if (p.stock_status === 'outofstock') return true;
|
|
169
|
+
if (p.stock_quantity !== null && p.stock_quantity !== undefined && p.stock_quantity <= threshold) return true;
|
|
170
|
+
return false;
|
|
171
|
+
});
|
|
172
|
+
alerts.sort((a, b) => (a.stock_quantity ?? -1) - (b.stock_quantity ?? -1));
|
|
173
|
+
const out_of_stock_count = alerts.filter(p => p.stock_status === 'outofstock').length;
|
|
174
|
+
const low_stock_count = alerts.length - out_of_stock_count;
|
|
175
|
+
result = json({
|
|
176
|
+
products: alerts.map(p => ({ id: p.id, name: p.name, sku: p.sku || '', stock_quantity: p.stock_quantity, stock_status: p.stock_status, price: p.price, permalink: p.permalink })),
|
|
177
|
+
summary: { total_alerts: alerts.length, out_of_stock_count, low_stock_count, threshold_used: threshold }
|
|
178
|
+
});
|
|
179
|
+
auditLog({ tool: name, action: 'inventory_alert', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: { threshold, include_out_of_stock } });
|
|
180
|
+
return result;
|
|
181
|
+
};
|
|
182
|
+
handlers['wc_order_intelligence'] = async (args) => {
|
|
183
|
+
const t0 = Date.now();
|
|
184
|
+
let result;
|
|
185
|
+
const { wcApiCall, getActiveAuth, auditLog, name } = rt;
|
|
186
|
+
validateInput(args, { customer_id: { type: 'number', required: true, min: 1 } });
|
|
187
|
+
const { customer_id } = args;
|
|
188
|
+
const { url: baseUrl } = getActiveAuth();
|
|
189
|
+
const orders = await wcApiCall(`/orders?customer=${customer_id}&per_page=100&orderby=date&order=desc`, {}, baseUrl);
|
|
190
|
+
const total_orders = orders.length;
|
|
191
|
+
if (total_orders === 0) {
|
|
192
|
+
result = json({ customer_id, total_orders: 0, total_spent: 0, average_order_value: 0, first_order_date: null, last_order_date: null, order_frequency_days: null, favourite_products: [], statuses_breakdown: {}, recent_orders: [] });
|
|
193
|
+
auditLog({ tool: name, target: customer_id, target_type: 'customer', action: 'order_intelligence', status: 'success', latency_ms: Date.now() - t0 });
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
const total_spent = Math.round(orders.reduce((sum, o) => sum + parseFloat(o.total || '0'), 0) * 100) / 100;
|
|
197
|
+
const average_order_value = Math.round(total_spent / total_orders * 100) / 100;
|
|
198
|
+
const last_order_date = orders[0].date_created;
|
|
199
|
+
const first_order_date = orders[orders.length - 1].date_created;
|
|
200
|
+
let order_frequency_days = null;
|
|
201
|
+
if (total_orders > 1) {
|
|
202
|
+
const firstMs = new Date(first_order_date).getTime();
|
|
203
|
+
const lastMs = new Date(last_order_date).getTime();
|
|
204
|
+
order_frequency_days = Math.round((lastMs - firstMs) / (1000 * 60 * 60 * 24) / total_orders);
|
|
205
|
+
}
|
|
206
|
+
const productFreq = {};
|
|
207
|
+
for (const o of orders) {
|
|
208
|
+
for (const li of (o.line_items || [])) {
|
|
209
|
+
productFreq[li.name] = (productFreq[li.name] || 0) + li.quantity;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const favourite_products = Object.entries(productFreq).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([pname, quantity]) => ({ name: pname, quantity }));
|
|
213
|
+
const statuses_breakdown = {};
|
|
214
|
+
for (const o of orders) { statuses_breakdown[o.status] = (statuses_breakdown[o.status] || 0) + 1; }
|
|
215
|
+
const recent_orders = orders.slice(0, 5).map(o => ({ id: o.id, date: o.date_created, total: o.total, status: o.status }));
|
|
216
|
+
result = json({ customer_id, total_orders, total_spent, average_order_value, first_order_date, last_order_date, order_frequency_days, favourite_products, statuses_breakdown, recent_orders });
|
|
217
|
+
auditLog({ tool: name, target: customer_id, target_type: 'customer', action: 'order_intelligence', status: 'success', latency_ms: Date.now() - t0 });
|
|
218
|
+
return result;
|
|
219
|
+
};
|
|
220
|
+
handlers['wc_seo_product_audit'] = async (args) => {
|
|
221
|
+
const t0 = Date.now();
|
|
222
|
+
let result;
|
|
223
|
+
const { wcApiCall, getActiveAuth, auditLog, name } = rt;
|
|
224
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 } });
|
|
225
|
+
const { per_page = 20, page = 1 } = args;
|
|
226
|
+
const { url: baseUrl } = getActiveAuth();
|
|
227
|
+
const products = await wcApiCall(`/products?per_page=${per_page}&page=${page}&status=publish`, {}, baseUrl);
|
|
228
|
+
let totalScore = 0;
|
|
229
|
+
const audited = products.map(p => {
|
|
230
|
+
const issues = [];
|
|
231
|
+
const descText = strip(p.description || '');
|
|
232
|
+
if (descText.length === 0) issues.push('missing_description');
|
|
233
|
+
else if (descText.length < 50) issues.push('description_too_short');
|
|
234
|
+
if (!p.short_description || strip(p.short_description).length === 0) issues.push('missing_short_description');
|
|
235
|
+
if (p.slug && p.slug.includes('product-')) issues.push('generic_slug');
|
|
236
|
+
if (!p.images || p.images.length === 0 || !p.images[0]?.src) issues.push('missing_image');
|
|
237
|
+
else if (!p.images[0]?.alt || p.images[0].alt.trim() === '') issues.push('missing_image_alt');
|
|
238
|
+
if (!p.price || p.price === '') issues.push('missing_price');
|
|
239
|
+
const score = Math.max(0, 100 - issues.length * 15);
|
|
240
|
+
totalScore += score;
|
|
241
|
+
return { id: p.id, name: p.name, score, issues, permalink: p.permalink };
|
|
242
|
+
});
|
|
243
|
+
const average_score = audited.length > 0 ? Math.round(totalScore / audited.length) : 0;
|
|
244
|
+
result = json({
|
|
245
|
+
average_score, total_audited: audited.length, products: audited,
|
|
246
|
+
summary: { perfect_score_count: audited.filter(p => p.score === 100).length, needs_attention_count: audited.filter(p => p.score < 70).length }
|
|
247
|
+
});
|
|
248
|
+
auditLog({ tool: name, action: 'seo_product_audit', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: { per_page, page, avg_score: average_score } });
|
|
249
|
+
return result;
|
|
250
|
+
};
|
|
251
|
+
handlers['wc_suggest_product_links'] = async (args) => {
|
|
252
|
+
const t0 = Date.now();
|
|
253
|
+
let result;
|
|
254
|
+
const { wpApiCall, wcApiCall, getActiveAuth, auditLog, name, extractFocusKeyword } = rt;
|
|
255
|
+
validateInput(args, { post_id: { type: 'number', required: true, min: 1 }, max_suggestions: { type: 'number', min: 1, max: 5 } });
|
|
256
|
+
const { post_id, max_suggestions = 3 } = args;
|
|
257
|
+
const { url: baseUrl } = getActiveAuth();
|
|
258
|
+
const post = await wpApiCall(`/posts/${post_id}`);
|
|
259
|
+
const postTitle = strip(post.title?.rendered || '');
|
|
260
|
+
const postMeta = post.meta || {};
|
|
261
|
+
const focusKw = extractFocusKeyword(postMeta);
|
|
262
|
+
const keywords = [];
|
|
263
|
+
if (focusKw) keywords.push(focusKw);
|
|
264
|
+
const titleWords = postTitle.split(/\s+/).filter(w => w.length > 3).slice(0, 3);
|
|
265
|
+
for (const w of titleWords) {
|
|
266
|
+
if (!keywords.some(k => k.toLowerCase() === w.toLowerCase())) keywords.push(w);
|
|
267
|
+
}
|
|
268
|
+
const candidateMap = new Map();
|
|
269
|
+
for (const kw of keywords.slice(0, 2)) {
|
|
270
|
+
try {
|
|
271
|
+
const prods = await wcApiCall(`/products?search=${encodeURIComponent(kw)}&per_page=10&status=publish`, {}, baseUrl);
|
|
272
|
+
if (Array.isArray(prods)) {
|
|
273
|
+
for (const p of prods) { if (!candidateMap.has(p.id)) candidateMap.set(p.id, p); }
|
|
274
|
+
}
|
|
275
|
+
} catch { /* search failed */ }
|
|
276
|
+
}
|
|
277
|
+
const scored = [];
|
|
278
|
+
for (const [, p] of candidateMap) {
|
|
279
|
+
let relevance_score = 0;
|
|
280
|
+
const nameLower = (p.name || '').toLowerCase();
|
|
281
|
+
const descLower = strip(p.description || '').toLowerCase();
|
|
282
|
+
for (const kw of keywords) {
|
|
283
|
+
const kwLower = kw.toLowerCase();
|
|
284
|
+
if (nameLower.includes(kwLower)) relevance_score += 3;
|
|
285
|
+
if (descLower.includes(kwLower)) relevance_score += 2;
|
|
286
|
+
}
|
|
287
|
+
if (p.stock_status === 'instock') relevance_score += 1;
|
|
288
|
+
if (p.images && p.images.length > 0 && p.images[0]?.src) relevance_score += 1;
|
|
289
|
+
scored.push({ product_id: p.id, product_name: p.name, product_url: p.permalink, anchor_text: (p.name || '').substring(0, 50), price: p.price, relevance_score, in_stock: p.stock_status === 'instock' });
|
|
290
|
+
}
|
|
291
|
+
scored.sort((a, b) => b.relevance_score - a.relevance_score);
|
|
292
|
+
const suggestions = scored.slice(0, max_suggestions);
|
|
293
|
+
result = json({ post_id, post_title: postTitle, keywords_used: keywords, suggestions });
|
|
294
|
+
auditLog({ tool: name, target: post_id, target_type: 'post', action: 'suggest_product_links', status: 'success', latency_ms: Date.now() - t0, params: { post_id, suggestion_count: suggestions.length } });
|
|
295
|
+
return result;
|
|
296
|
+
};
|
|
297
|
+
handlers['wc_audit_product_seo'] = async (args) => {
|
|
298
|
+
const t0 = Date.now();
|
|
299
|
+
let result;
|
|
300
|
+
const { wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
301
|
+
const seoLimit = Math.min(args.limit || 100, 500);
|
|
302
|
+
const seoCatId = args.category_id || null;
|
|
303
|
+
const seoMinScore = args.min_score || 0;
|
|
304
|
+
const { url: baseUrl } = getActiveAuth();
|
|
305
|
+
|
|
306
|
+
let allProducts = [];
|
|
307
|
+
let seoPage = 1;
|
|
308
|
+
while (allProducts.length < seoLimit) {
|
|
309
|
+
const perPage = Math.min(100, seoLimit - allProducts.length);
|
|
310
|
+
let ep = `/products?per_page=${perPage}&page=${seoPage}&status=publish`;
|
|
311
|
+
if (seoCatId) ep += `&category=${seoCatId}`;
|
|
312
|
+
const batch = await wcApiCall(ep, {}, baseUrl);
|
|
313
|
+
if (!Array.isArray(batch) || batch.length === 0) break;
|
|
314
|
+
allProducts.push(...batch);
|
|
315
|
+
if (batch.length < perPage) break;
|
|
316
|
+
seoPage++;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let totalScore = 0;
|
|
320
|
+
const seoProducts = allProducts.map(p => {
|
|
321
|
+
let score = 100;
|
|
322
|
+
const issues = [];
|
|
323
|
+
const nameLen = (p.name || '').length;
|
|
324
|
+
if (nameLen < 30) { score -= 15; issues.push({ field: 'name', severity: 'medium', detail: `Title too short (${nameLen} chars)`, recommendation: 'Aim for 30-60 characters with primary keyword' }); }
|
|
325
|
+
else if (nameLen > 60) { score -= 10; issues.push({ field: 'name', severity: 'low', detail: `Title too long (${nameLen} chars)`, recommendation: 'Keep title under 60 characters for search results' }); }
|
|
326
|
+
|
|
327
|
+
const descLen = strip(p.description || '').length;
|
|
328
|
+
if (descLen < 50) { score -= 25; issues.push({ field: 'description', severity: 'high', detail: `Description too short (${descLen} chars)`, recommendation: 'Write at least 150 characters of unique product description' }); }
|
|
329
|
+
else if (descLen < 150) { score -= 10; issues.push({ field: 'description', severity: 'medium', detail: `Description could be longer (${descLen} chars)`, recommendation: 'Expand to 150+ characters for better SEO' }); }
|
|
330
|
+
|
|
331
|
+
if (p.slug && (/product-\d/.test(p.slug) || /-\d+$/.test(p.slug))) { score -= 20; issues.push({ field: 'slug', severity: 'high', detail: `Generic slug "${p.slug}"`, recommendation: 'Use descriptive keyword-rich slug' }); }
|
|
332
|
+
|
|
333
|
+
const mainImg = p.images?.[0];
|
|
334
|
+
if (!mainImg || !mainImg.src) { score -= 15; issues.push({ field: 'image', severity: 'high', detail: 'No product image', recommendation: 'Add a main product image with descriptive alt text' }); }
|
|
335
|
+
else if (!mainImg.alt || mainImg.alt.trim() === '') { score -= 15; issues.push({ field: 'image_alt', severity: 'high', detail: 'Main image missing alt text', recommendation: 'Add descriptive alt text to main product image' }); }
|
|
336
|
+
|
|
337
|
+
const meta = p.meta_data || p.meta || {};
|
|
338
|
+
const metaObj = Array.isArray(meta) ? meta.reduce((acc, m) => { acc[m.key] = m.value; return acc; }, {}) : meta;
|
|
339
|
+
let hasSchema = false;
|
|
340
|
+
if (metaObj._custom_schema_jsonld) hasSchema = true;
|
|
341
|
+
else if (metaObj.rank_math_schema) {
|
|
342
|
+
try { const s = typeof metaObj.rank_math_schema === 'string' ? JSON.parse(metaObj.rank_math_schema) : metaObj.rank_math_schema; if (JSON.stringify(s).includes('Product')) hasSchema = true; } catch {}
|
|
343
|
+
}
|
|
344
|
+
else if (metaObj._yoast_wpseo_schema_page_type === 'Product' || metaObj.wpseo_schema_page_type === 'Product') hasSchema = true;
|
|
345
|
+
if (!hasSchema) { score -= 15; issues.push({ field: 'schema', severity: 'medium', detail: 'No Product schema detected', recommendation: 'Add Product schema via RankMath, Yoast, or wp_inject_schema' }); }
|
|
346
|
+
|
|
347
|
+
score = Math.max(0, score);
|
|
348
|
+
totalScore += score;
|
|
349
|
+
return { id: p.id, name: p.name, slug: p.slug, score, issues };
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const filtered = seoMinScore > 0 ? seoProducts.filter(p => p.score < seoMinScore) : seoProducts;
|
|
353
|
+
const avgScore = seoProducts.length > 0 ? Math.round(totalScore / seoProducts.length) : 0;
|
|
354
|
+
const byRange = { '90-100': 0, '70-89': 0, '50-69': 0, '0-49': 0 };
|
|
355
|
+
seoProducts.forEach(p => { if (p.score >= 90) byRange['90-100']++; else if (p.score >= 70) byRange['70-89']++; else if (p.score >= 50) byRange['50-69']++; else byRange['0-49']++; });
|
|
356
|
+
|
|
357
|
+
result = json({ products: filtered, summary: { total_audited: seoProducts.length, avg_score: avgScore, critical_count: seoProducts.filter(p => p.score < 50).length, by_score_range: byRange }, audit_config: { schema_sources_checked: ['custom_jsonld', 'rankmath', 'yoast', 'wc_native'] } });
|
|
358
|
+
auditLog({ tool: name, action: 'audit_product_seo', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
359
|
+
return result;
|
|
360
|
+
};
|
|
361
|
+
handlers['wc_find_abandoned_carts_pattern'] = async (args) => {
|
|
362
|
+
const t0 = Date.now();
|
|
363
|
+
let result;
|
|
364
|
+
const { wpApiCall, wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
365
|
+
const acDays = args.days || 30;
|
|
366
|
+
const acMinValue = args.min_cart_value || 0;
|
|
367
|
+
|
|
368
|
+
let acData;
|
|
369
|
+
try {
|
|
370
|
+
acData = await wpApiCall(`/wc-abandoned-carts?days=${acDays}&min_value=${acMinValue}`, { basePath: '/wp-json/mcp-diagnostics/v1' });
|
|
371
|
+
} catch (e) {
|
|
372
|
+
result = json({ available: false, source: null, message: 'Companion mu-plugin not installed or endpoint unavailable. Copy mcp-diagnostics.php to wp-content/mu-plugins/', setup_options: ['Install WooCommerce Abandoned Cart Lite (free)', 'Install CartFlows', 'Install companion mu-plugin for WC session analysis'] });
|
|
373
|
+
auditLog({ tool: name, action: 'find_abandoned_carts', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!acData.available) {
|
|
378
|
+
result = json({ available: false, source: null, period_days: acDays, message: 'No abandoned cart data source found', setup_options: ['Install WooCommerce Abandoned Cart Lite (free)', 'Install CartFlows', 'Companion mu-plugin installed but no cart data tables detected'] });
|
|
379
|
+
auditLog({ tool: name, action: 'find_abandoned_carts', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const carts = acData.carts || [];
|
|
384
|
+
const totalAbandoned = carts.length;
|
|
385
|
+
const totalValue = carts.reduce((s, c) => s + (c.cart_value || 0), 0);
|
|
386
|
+
const avgValue = totalAbandoned > 0 ? Math.round(totalValue / totalAbandoned * 100) / 100 : 0;
|
|
387
|
+
|
|
388
|
+
// Patterns by hour and weekday
|
|
389
|
+
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, count: 0 }));
|
|
390
|
+
const byWeekday = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map(d => ({ day: d, count: 0 }));
|
|
391
|
+
const productFreq = {};
|
|
392
|
+
for (const c of carts) {
|
|
393
|
+
if (c.abandoned_at) {
|
|
394
|
+
const d = new Date(c.abandoned_at);
|
|
395
|
+
byHour[d.getHours()].count++;
|
|
396
|
+
byWeekday[d.getDay()].count++;
|
|
397
|
+
}
|
|
398
|
+
for (const item of (c.products || [])) {
|
|
399
|
+
const key = item.product_id || item.name || 'unknown';
|
|
400
|
+
productFreq[key] = productFreq[key] || { id: item.product_id, name: item.name || `Product ${item.product_id}`, frequency: 0 };
|
|
401
|
+
productFreq[key].frequency++;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
byHour.forEach(h => { h.percentage = totalAbandoned > 0 ? Math.round(h.count / totalAbandoned * 10000) / 100 : 0; });
|
|
405
|
+
const topProducts = Object.values(productFreq).sort((a, b) => b.frequency - a.frequency).slice(0, 5);
|
|
406
|
+
|
|
407
|
+
// Estimate abandonment rate
|
|
408
|
+
let completedOrders = 0;
|
|
409
|
+
try {
|
|
410
|
+
const { url: baseUrl } = getActiveAuth();
|
|
411
|
+
const dateMin = new Date(Date.now() - acDays * 86400000).toISOString().split('T')[0];
|
|
412
|
+
const orders = await wcApiCall(`/orders?status=completed&after=${dateMin}T00:00:00&per_page=1`, {}, baseUrl);
|
|
413
|
+
// WC API returns total in headers, but we just count what we get
|
|
414
|
+
completedOrders = Array.isArray(orders) ? orders.length : 0;
|
|
415
|
+
// Try to get a better count
|
|
416
|
+
try {
|
|
417
|
+
const report = await wcApiCall(`/reports/sales?date_min=${dateMin}`, {}, baseUrl);
|
|
418
|
+
if (Array.isArray(report) && report[0]?.total_orders) completedOrders = parseInt(report[0].total_orders, 10);
|
|
419
|
+
} catch {}
|
|
420
|
+
} catch {}
|
|
421
|
+
|
|
422
|
+
const recommendations = [];
|
|
423
|
+
if (avgValue > 50) recommendations.push(`High average cart value (${avgValue}). Consider exit-intent popup with discount.`);
|
|
424
|
+
const peakHour = byHour.reduce((max, h) => h.count > max.count ? h : max, byHour[0]);
|
|
425
|
+
if (peakHour.count > 0) recommendations.push(`Peak abandonment at ${peakHour.hour}:00. Schedule recovery emails for ${(peakHour.hour + 1) % 24}:00.`);
|
|
426
|
+
if (topProducts.length > 0) recommendations.push(`Most abandoned product: "${topProducts[0].name}". Review its pricing, shipping, and checkout experience.`);
|
|
427
|
+
recommendations.push('Enable guest checkout to reduce friction.');
|
|
428
|
+
recommendations.push('Add trust badges near checkout button.');
|
|
429
|
+
|
|
430
|
+
result = json({ available: true, source: acData.source, period_days: acDays, total_abandoned: totalAbandoned, avg_cart_value: avgValue, estimated_revenue_loss: Math.round(totalValue * 100) / 100, patterns: { by_hour: byHour.filter(h => h.count > 0), by_weekday: byWeekday, top_abandoned_products: topProducts }, recommendations: recommendations.slice(0, 5) });
|
|
431
|
+
auditLog({ tool: name, action: 'find_abandoned_carts', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
432
|
+
return result;
|
|
433
|
+
};
|
|
434
|
+
handlers['wc_audit_checkout_friction'] = async (args) => {
|
|
435
|
+
const t0 = Date.now();
|
|
436
|
+
let result;
|
|
437
|
+
const { wpApiCall, getActiveAuth, VERSION, fetch, auditLog, sanitizeParams, name } = rt;
|
|
438
|
+
const { url: siteBaseUrl } = getActiveAuth();
|
|
439
|
+
let frictionScore = 0;
|
|
440
|
+
const frictionChecks = [];
|
|
441
|
+
const dataSources = ['wc_options'];
|
|
442
|
+
|
|
443
|
+
// Layer 1: WooCommerce options
|
|
444
|
+
let guestCheckout = 'yes';
|
|
445
|
+
let couponCart = 'yes';
|
|
446
|
+
let couponCheckout = 'yes';
|
|
447
|
+
let checkoutPageId = null;
|
|
448
|
+
try {
|
|
449
|
+
const opts = await wpApiCall('/settings/general', { basePath: '/wp-json/wc/v3' });
|
|
450
|
+
if (Array.isArray(opts)) {
|
|
451
|
+
for (const o of opts) {
|
|
452
|
+
if (o.id === 'woocommerce_enable_guest_checkout') guestCheckout = o.value || 'yes';
|
|
453
|
+
if (o.id === 'woocommerce_enable_coupons') { couponCart = o.value || 'yes'; couponCheckout = o.value || 'yes'; }
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} catch { /* settings not accessible, use defaults */ }
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const checkoutOpts = await wpApiCall('/settings/advanced', { basePath: '/wp-json/wc/v3' });
|
|
460
|
+
if (Array.isArray(checkoutOpts)) {
|
|
461
|
+
for (const o of checkoutOpts) {
|
|
462
|
+
if (o.id === 'woocommerce_checkout_page_id') checkoutPageId = o.value;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} catch {}
|
|
466
|
+
|
|
467
|
+
// Guest checkout check
|
|
468
|
+
if (guestCheckout !== 'yes') {
|
|
469
|
+
frictionScore += 1.5;
|
|
470
|
+
frictionChecks.push({ name: 'guest_checkout', value: 'disabled', friction_points: 1.5, recommendation: 'Enable guest checkout to reduce cart abandonment' });
|
|
471
|
+
} else {
|
|
472
|
+
frictionChecks.push({ name: 'guest_checkout', value: 'enabled', friction_points: 0, recommendation: null });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Coupon fields
|
|
476
|
+
if (couponCart !== 'yes' && couponCheckout !== 'yes') {
|
|
477
|
+
frictionScore += 1.0;
|
|
478
|
+
frictionChecks.push({ name: 'coupon_fields', value: 'disabled', friction_points: 1.0, recommendation: 'Enable coupon field to improve conversion for returning customers' });
|
|
479
|
+
} else {
|
|
480
|
+
frictionChecks.push({ name: 'coupon_fields', value: 'enabled', friction_points: 0, recommendation: null });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Layer 2: HTML analysis (if guest checkout enabled and page accessible)
|
|
484
|
+
let requiredFieldCount = 0;
|
|
485
|
+
let hasMultiStep = false;
|
|
486
|
+
let hasPhoneRequired = false;
|
|
487
|
+
let hasCompanyRequired = false;
|
|
488
|
+
let htmlAnalyzed = false;
|
|
489
|
+
|
|
490
|
+
if (guestCheckout === 'yes') {
|
|
491
|
+
try {
|
|
492
|
+
const checkoutUrl = checkoutPageId ? `${siteBaseUrl.replace(/\/wp-json.*$/, '')}/?page_id=${checkoutPageId}` : `${siteBaseUrl.replace(/\/wp-json.*$/, '')}/checkout/`;
|
|
493
|
+
const controller = new AbortController();
|
|
494
|
+
const tid = setTimeout(() => controller.abort(), 10000);
|
|
495
|
+
const resp = await fetch(checkoutUrl, { headers: { 'User-Agent': `WordPress-MCP-Server/${VERSION}` }, signal: controller.signal, redirect: 'follow' });
|
|
496
|
+
clearTimeout(tid);
|
|
497
|
+
if (resp.ok) {
|
|
498
|
+
const html = await resp.text();
|
|
499
|
+
if (!html.includes('my-account') || html.includes('woocommerce-checkout')) {
|
|
500
|
+
dataSources.push('html_analysis');
|
|
501
|
+
htmlAnalyzed = true;
|
|
502
|
+
const requiredMatches = html.match(/<input[^>]*required[^>]*>/gi) || [];
|
|
503
|
+
const selectRequired = html.match(/<select[^>]*required[^>]*>/gi) || [];
|
|
504
|
+
requiredFieldCount = requiredMatches.length + selectRequired.length;
|
|
505
|
+
hasMultiStep = /wc-checkout-step|checkout-step|multi-step/i.test(html);
|
|
506
|
+
hasPhoneRequired = /name=["']billing_phone["'][^>]*required/i.test(html);
|
|
507
|
+
hasCompanyRequired = /name=["']billing_company["'][^>]*required/i.test(html);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
} catch { /* checkout not accessible */ }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (requiredFieldCount > 10) {
|
|
514
|
+
frictionScore += 1.5;
|
|
515
|
+
frictionChecks.push({ name: 'required_fields', value: requiredFieldCount, friction_points: 1.5, recommendation: `Reduce required fields from ${requiredFieldCount} to under 10` });
|
|
516
|
+
} else if (requiredFieldCount > 6) {
|
|
517
|
+
frictionScore += 1.0;
|
|
518
|
+
frictionChecks.push({ name: 'required_fields', value: requiredFieldCount, friction_points: 1.0, recommendation: 'Consider reducing required fields' });
|
|
519
|
+
} else {
|
|
520
|
+
frictionChecks.push({ name: 'required_fields', value: htmlAnalyzed ? requiredFieldCount : 'unknown', friction_points: 0, recommendation: null });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (hasMultiStep) {
|
|
524
|
+
frictionScore += 1.0;
|
|
525
|
+
frictionChecks.push({ name: 'multi_step_checkout', value: true, friction_points: 1.0, recommendation: 'Consider single-page checkout for fewer steps' });
|
|
526
|
+
} else {
|
|
527
|
+
frictionChecks.push({ name: 'multi_step_checkout', value: false, friction_points: 0, recommendation: null });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (hasPhoneRequired) {
|
|
531
|
+
frictionScore += 0.5;
|
|
532
|
+
frictionChecks.push({ name: 'phone_required', value: true, friction_points: 0.5, recommendation: 'Make phone field optional unless required for shipping' });
|
|
533
|
+
}
|
|
534
|
+
if (hasCompanyRequired) {
|
|
535
|
+
frictionScore += 0.5;
|
|
536
|
+
frictionChecks.push({ name: 'company_required', value: true, friction_points: 0.5, recommendation: 'Make company field optional for B2C stores' });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
frictionScore = Math.round(frictionScore * 10) / 10;
|
|
540
|
+
const frictionGrade = frictionScore <= 3 ? 'Low' : frictionScore <= 6 ? 'Medium' : 'High';
|
|
541
|
+
const quickWins = frictionChecks.filter(c => c.friction_points > 0).sort((a, b) => b.friction_points - a.friction_points).map(c => c.recommendation).filter(Boolean);
|
|
542
|
+
|
|
543
|
+
result = json({ friction_score: frictionScore, friction_grade: frictionGrade, data_sources: dataSources, checks: frictionChecks, quick_wins: quickWins });
|
|
544
|
+
auditLog({ tool: name, action: 'audit_checkout_friction', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
545
|
+
return result;
|
|
546
|
+
};
|
|
547
|
+
handlers['wc_get_product_performance'] = async (args) => {
|
|
548
|
+
const t0 = Date.now();
|
|
549
|
+
let result;
|
|
550
|
+
const { wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
551
|
+
validateInput(args, { product_id: { type: 'number', required: true, min: 1 } });
|
|
552
|
+
const { product_id: perfProdId, period: perfPeriod = '30d' } = args;
|
|
553
|
+
const { url: baseUrl } = getActiveAuth();
|
|
554
|
+
|
|
555
|
+
const product = await wcApiCall(`/products/${perfProdId}`, {}, baseUrl);
|
|
556
|
+
|
|
557
|
+
const periodDays = perfPeriod === 'year' ? 365 : perfPeriod === '90d' ? 90 : 30;
|
|
558
|
+
const now = new Date();
|
|
559
|
+
const dateMax = now.toISOString().split('T')[0];
|
|
560
|
+
const dateMin = new Date(now.getTime() - periodDays * 86400000).toISOString().split('T')[0];
|
|
561
|
+
const prevDateMax = new Date(now.getTime() - periodDays * 86400000).toISOString().split('T')[0];
|
|
562
|
+
const prevDateMin = new Date(now.getTime() - periodDays * 2 * 86400000).toISOString().split('T')[0];
|
|
563
|
+
|
|
564
|
+
// Current period sales
|
|
565
|
+
let unitsSold = 0, revenue = 0, ordersCount = 0, refundsCount = 0;
|
|
566
|
+
try {
|
|
567
|
+
const orders = await wcApiCall(`/orders?product=${perfProdId}&after=${dateMin}T00:00:00&per_page=100&status=completed,processing,refunded`, {}, baseUrl);
|
|
568
|
+
if (Array.isArray(orders)) {
|
|
569
|
+
for (const o of orders) {
|
|
570
|
+
if (o.status === 'refunded') { refundsCount++; continue; }
|
|
571
|
+
ordersCount++;
|
|
572
|
+
for (const li of (o.line_items || [])) {
|
|
573
|
+
if (li.product_id === perfProdId || li.variation_id === perfProdId) {
|
|
574
|
+
unitsSold += li.quantity;
|
|
575
|
+
revenue += parseFloat(li.total || '0');
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} catch {}
|
|
581
|
+
|
|
582
|
+
// Previous period for trend
|
|
583
|
+
let prevUnitsSold = 0, prevRevenue = 0;
|
|
584
|
+
try {
|
|
585
|
+
const prevOrders = await wcApiCall(`/orders?product=${perfProdId}&after=${prevDateMin}T00:00:00&before=${prevDateMax}T23:59:59&per_page=100&status=completed,processing`, {}, baseUrl);
|
|
586
|
+
if (Array.isArray(prevOrders)) {
|
|
587
|
+
for (const o of prevOrders) {
|
|
588
|
+
for (const li of (o.line_items || [])) {
|
|
589
|
+
if (li.product_id === perfProdId || li.variation_id === perfProdId) {
|
|
590
|
+
prevUnitsSold += li.quantity;
|
|
591
|
+
prevRevenue += parseFloat(li.total || '0');
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
} catch {}
|
|
597
|
+
|
|
598
|
+
const unitsDelta = prevUnitsSold > 0 ? Math.round((unitsSold - prevUnitsSold) / prevUnitsSold * 10000) / 100 : (unitsSold > 0 ? 100 : 0);
|
|
599
|
+
const revenueDelta = prevRevenue > 0 ? Math.round((revenue - prevRevenue) / prevRevenue * 10000) / 100 : (revenue > 0 ? 100 : 0);
|
|
600
|
+
const direction = Math.abs(unitsDelta) <= 5 ? 'stable' : unitsDelta > 0 ? 'up' : 'down';
|
|
601
|
+
|
|
602
|
+
// Top sellers rank
|
|
603
|
+
let rank = null;
|
|
604
|
+
try {
|
|
605
|
+
const topSellers = await wcApiCall(`/reports/top_sellers?period=month`, {}, baseUrl);
|
|
606
|
+
if (Array.isArray(topSellers)) {
|
|
607
|
+
const idx = topSellers.findIndex(t => t.product_id === perfProdId);
|
|
608
|
+
if (idx >= 0) rank = idx + 1;
|
|
609
|
+
}
|
|
610
|
+
} catch {}
|
|
611
|
+
|
|
612
|
+
const avgOrderValue = ordersCount > 0 ? Math.round(revenue / ordersCount * 100) / 100 : 0;
|
|
613
|
+
const refundRate = (ordersCount + refundsCount) > 0 ? Math.round(refundsCount / (ordersCount + refundsCount) * 10000) / 100 : 0;
|
|
614
|
+
|
|
615
|
+
result = json({
|
|
616
|
+
product: { id: product.id, name: product.name, sku: product.sku || '', status: product.status, stock_quantity: product.stock_quantity, price: product.price },
|
|
617
|
+
period: { start: dateMin, end: dateMax, days: periodDays },
|
|
618
|
+
metrics: { units_sold: unitsSold, revenue: Math.round(revenue * 100) / 100, orders_count: ordersCount, avg_order_value: avgOrderValue, refunds_count: refundsCount, refund_rate: refundRate },
|
|
619
|
+
trend: { units_sold_delta_pct: unitsDelta, revenue_delta_pct: revenueDelta, direction },
|
|
620
|
+
rank_in_top_sellers: rank,
|
|
621
|
+
ga4_note: 'Connect GA4 for view-to-purchase conversion rate'
|
|
622
|
+
});
|
|
623
|
+
auditLog({ tool: name, target: perfProdId, target_type: 'product', action: 'get_product_performance', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
624
|
+
return result;
|
|
625
|
+
};
|
|
626
|
+
handlers['wc_audit_stock_alerts'] = async (args) => {
|
|
627
|
+
const t0 = Date.now();
|
|
628
|
+
let result;
|
|
629
|
+
const { wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
630
|
+
const stockThreshold = args.threshold || 5;
|
|
631
|
+
const stockIncludeZero = args.include_zero_stock !== false;
|
|
632
|
+
const stockIncludeVars = args.include_variations !== false;
|
|
633
|
+
const { url: baseUrl } = getActiveAuth();
|
|
634
|
+
|
|
635
|
+
const outOfStock = [];
|
|
636
|
+
const lowStock = [];
|
|
637
|
+
|
|
638
|
+
// Fetch products page by page
|
|
639
|
+
let stockPage = 1;
|
|
640
|
+
let hasMore = true;
|
|
641
|
+
while (hasMore) {
|
|
642
|
+
const products = await wcApiCall(`/products?per_page=100&page=${stockPage}&status=publish`, {}, baseUrl);
|
|
643
|
+
if (!Array.isArray(products) || products.length === 0) break;
|
|
644
|
+
|
|
645
|
+
for (const p of products) {
|
|
646
|
+
if (p.manage_stock) {
|
|
647
|
+
if (p.stock_quantity === 0 || p.stock_status === 'outofstock') {
|
|
648
|
+
if (stockIncludeZero) {
|
|
649
|
+
let lastSaleDate = null;
|
|
650
|
+
try {
|
|
651
|
+
const recentOrders = await wcApiCall(`/orders?product=${p.id}&per_page=1&orderby=date&order=desc`, {}, baseUrl);
|
|
652
|
+
if (Array.isArray(recentOrders) && recentOrders[0]) lastSaleDate = recentOrders[0].date_created;
|
|
653
|
+
} catch {}
|
|
654
|
+
outOfStock.push({ id: p.id, name: p.name, sku: p.sku || '', type: p.type, parent_id: null, last_sale_date: lastSaleDate, admin_url: `${baseUrl.replace('/wp-json', '')}/wp-admin/post.php?post=${p.id}&action=edit` });
|
|
655
|
+
}
|
|
656
|
+
} else if (p.stock_quantity !== null && p.stock_quantity <= stockThreshold) {
|
|
657
|
+
let lastSaleDate = null;
|
|
658
|
+
try {
|
|
659
|
+
const recentOrders = await wcApiCall(`/orders?product=${p.id}&per_page=1&orderby=date&order=desc`, {}, baseUrl);
|
|
660
|
+
if (Array.isArray(recentOrders) && recentOrders[0]) lastSaleDate = recentOrders[0].date_created;
|
|
661
|
+
} catch {}
|
|
662
|
+
lowStock.push({ id: p.id, name: p.name, sku: p.sku || '', type: p.type, stock_quantity: p.stock_quantity, threshold: stockThreshold, last_sale_date: lastSaleDate, admin_url: `${baseUrl.replace('/wp-json', '')}/wp-admin/post.php?post=${p.id}&action=edit` });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Variations
|
|
667
|
+
if (stockIncludeVars && p.type === 'variable' && p.variations && p.variations.length > 0) {
|
|
668
|
+
try {
|
|
669
|
+
const vars = await wcApiCall(`/products/${p.id}/variations?per_page=100`, {}, baseUrl);
|
|
670
|
+
if (Array.isArray(vars)) {
|
|
671
|
+
for (const v of vars) {
|
|
672
|
+
if (!v.manage_stock) continue;
|
|
673
|
+
if (v.stock_quantity === 0 || v.stock_status === 'outofstock') {
|
|
674
|
+
if (stockIncludeZero) outOfStock.push({ id: v.id, name: `${p.name} - ${(v.attributes || []).map(a => a.option).join(', ')}`, sku: v.sku || '', type: 'variation', parent_id: p.id, last_sale_date: null, admin_url: `${baseUrl.replace('/wp-json', '')}/wp-admin/post.php?post=${p.id}&action=edit` });
|
|
675
|
+
} else if (v.stock_quantity !== null && v.stock_quantity <= stockThreshold) {
|
|
676
|
+
lowStock.push({ id: v.id, name: `${p.name} - ${(v.attributes || []).map(a => a.option).join(', ')}`, sku: v.sku || '', type: 'variation', stock_quantity: v.stock_quantity, threshold: stockThreshold, last_sale_date: null, admin_url: `${baseUrl.replace('/wp-json', '')}/wp-admin/post.php?post=${p.id}&action=edit` });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
} catch {}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (products.length < 100) break;
|
|
685
|
+
stockPage++;
|
|
686
|
+
if (stockPage > 5) break; // safety limit
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
result = json({ out_of_stock: outOfStock, low_stock: lowStock, summary: { out_of_stock_count: outOfStock.length, low_stock_count: lowStock.length, estimated_lost_revenue: 'N/A (connect GA4 for traffic data)' }, alert: outOfStock.length > 0 });
|
|
690
|
+
auditLog({ tool: name, action: 'audit_stock_alerts', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
691
|
+
return result;
|
|
692
|
+
};
|
|
693
|
+
handlers['wc_find_duplicate_products'] = async (args) => {
|
|
694
|
+
const t0 = Date.now();
|
|
695
|
+
let result;
|
|
696
|
+
const { wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
697
|
+
const dupThreshold = args.similarity_threshold || 0.8;
|
|
698
|
+
const dupCheckSku = args.check_sku !== false;
|
|
699
|
+
const dupCheckTitle = args.check_title !== false;
|
|
700
|
+
const dupCheckSlug = args.check_slug !== false;
|
|
701
|
+
const dupLimit = Math.min(args.limit || 200, 500);
|
|
702
|
+
const { url: baseUrl } = getActiveAuth();
|
|
703
|
+
|
|
704
|
+
let dupProducts = [];
|
|
705
|
+
let dupPage = 1;
|
|
706
|
+
while (dupProducts.length < dupLimit) {
|
|
707
|
+
const perPage = Math.min(100, dupLimit - dupProducts.length);
|
|
708
|
+
const batch = await wcApiCall(`/products?per_page=${perPage}&page=${dupPage}`, {}, baseUrl);
|
|
709
|
+
if (!Array.isArray(batch) || batch.length === 0) break;
|
|
710
|
+
dupProducts.push(...batch);
|
|
711
|
+
if (batch.length < perPage) break;
|
|
712
|
+
dupPage++;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Levenshtein distance
|
|
716
|
+
function levenshtein(a, b) {
|
|
717
|
+
const m = a.length, n = b.length;
|
|
718
|
+
if (m === 0) return n;
|
|
719
|
+
if (n === 0) return m;
|
|
720
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
721
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
722
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
723
|
+
for (let i = 1; i <= m; i++) {
|
|
724
|
+
for (let j = 1; j <= n; j++) {
|
|
725
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return dp[m][n];
|
|
729
|
+
}
|
|
730
|
+
function similarity(a, b) {
|
|
731
|
+
if (!a || !b) return 0;
|
|
732
|
+
const al = a.toLowerCase(), bl = b.toLowerCase();
|
|
733
|
+
if (al === bl) return 1;
|
|
734
|
+
return 1 - levenshtein(al, bl) / Math.max(al.length, bl.length);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Union-Find
|
|
738
|
+
const parent = {};
|
|
739
|
+
function find(x) { if (parent[x] !== x) parent[x] = find(parent[x]); return parent[x]; }
|
|
740
|
+
function union(x, y) { parent[find(x)] = find(y); }
|
|
741
|
+
dupProducts.forEach(p => { parent[p.id] = p.id; });
|
|
742
|
+
|
|
743
|
+
const matchInfo = {};
|
|
744
|
+
|
|
745
|
+
// SKU exact matches
|
|
746
|
+
if (dupCheckSku) {
|
|
747
|
+
const skuMap = {};
|
|
748
|
+
for (const p of dupProducts) {
|
|
749
|
+
if (p.sku && p.sku.trim()) {
|
|
750
|
+
if (skuMap[p.sku]) { union(p.id, skuMap[p.sku].id); matchInfo[`${p.id}-${skuMap[p.sku].id}`] = { type: 'sku_exact', score: 1.0 }; }
|
|
751
|
+
else skuMap[p.sku] = p;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Title and slug similarity
|
|
757
|
+
for (let i = 0; i < dupProducts.length; i++) {
|
|
758
|
+
for (let j = i + 1; j < dupProducts.length; j++) {
|
|
759
|
+
const a = dupProducts[i], b = dupProducts[j];
|
|
760
|
+
if (dupCheckTitle) {
|
|
761
|
+
const titleSim = similarity(a.name, b.name);
|
|
762
|
+
if (titleSim >= dupThreshold) { union(a.id, b.id); matchInfo[`${a.id}-${b.id}`] = { type: 'title_similar', score: titleSim }; }
|
|
763
|
+
}
|
|
764
|
+
if (dupCheckSlug) {
|
|
765
|
+
const slugSim = similarity(a.slug, b.slug);
|
|
766
|
+
if (slugSim >= dupThreshold) { union(a.id, b.id); if (!matchInfo[`${a.id}-${b.id}`]) matchInfo[`${a.id}-${b.id}`] = { type: 'slug_similar', score: slugSim }; }
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Group by root
|
|
772
|
+
const groups = {};
|
|
773
|
+
for (const p of dupProducts) {
|
|
774
|
+
const root = find(p.id);
|
|
775
|
+
if (!groups[root]) groups[root] = [];
|
|
776
|
+
groups[root].push(p);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const dupGroups = [];
|
|
780
|
+
let groupId = 1;
|
|
781
|
+
for (const [root, members] of Object.entries(groups)) {
|
|
782
|
+
if (members.length < 2) continue;
|
|
783
|
+
const types = new Set();
|
|
784
|
+
let maxScore = 0;
|
|
785
|
+
for (const [key, info] of Object.entries(matchInfo)) {
|
|
786
|
+
const ids = key.split('-').map(Number);
|
|
787
|
+
if (members.some(m => ids.includes(m.id))) { types.add(info.type); maxScore = Math.max(maxScore, info.score); }
|
|
788
|
+
}
|
|
789
|
+
const matchType = types.size > 1 ? 'multiple' : [...types][0] || 'unknown';
|
|
790
|
+
dupGroups.push({
|
|
791
|
+
group_id: groupId++,
|
|
792
|
+
match_type: matchType,
|
|
793
|
+
similarity_score: Math.round(maxScore * 100) / 100,
|
|
794
|
+
products: members.map(p => ({ id: p.id, name: p.name, sku: p.sku || '', slug: p.slug, status: p.status, price: p.price, admin_url: `${baseUrl.replace('/wp-json', '')}/wp-admin/post.php?post=${p.id}&action=edit` })),
|
|
795
|
+
recommendation: `Keep ID ${members[0].id}, review/delete others`
|
|
796
|
+
});
|
|
797
|
+
if (dupGroups.length >= 50) break;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const totalDups = dupGroups.reduce((s, g) => s + g.products.length, 0);
|
|
801
|
+
result = json({ duplicate_groups: dupGroups, summary: { total_groups: dupGroups.length, total_duplicates: totalDups, products_analyzed: dupProducts.length }, ...(dupGroups.length >= 50 ? { note: 'Showing top 50 groups' } : {}) });
|
|
802
|
+
auditLog({ tool: name, action: 'find_duplicate_products', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
803
|
+
return result;
|
|
804
|
+
};
|
|
805
|
+
handlers['wc_audit_pricing_consistency'] = async (args) => {
|
|
806
|
+
const t0 = Date.now();
|
|
807
|
+
let result;
|
|
808
|
+
const { wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
809
|
+
const priceIncludeDrafts = args.include_drafts || false;
|
|
810
|
+
const priceIncludeVars = args.include_variations !== false;
|
|
811
|
+
const { url: baseUrl } = getActiveAuth();
|
|
812
|
+
|
|
813
|
+
const inconsistencies = [];
|
|
814
|
+
let totalChecked = 0;
|
|
815
|
+
let cleanCount = 0;
|
|
816
|
+
const summary = { critical: 0, high: 0, medium: 0, low: 0, products_with_issues: 0, total_checked: 0 };
|
|
817
|
+
|
|
818
|
+
let pricePage = 1;
|
|
819
|
+
let priceHasMore = true;
|
|
820
|
+
while (priceHasMore) {
|
|
821
|
+
const status = priceIncludeDrafts ? 'any' : 'publish';
|
|
822
|
+
const products = await wcApiCall(`/products?per_page=100&page=${pricePage}&status=${status}`, {}, baseUrl);
|
|
823
|
+
if (!Array.isArray(products) || products.length === 0) break;
|
|
824
|
+
|
|
825
|
+
for (const p of products) {
|
|
826
|
+
totalChecked++;
|
|
827
|
+
let hasIssue = false;
|
|
828
|
+
|
|
829
|
+
const checkPricing = (id, name, varId, regular, sale, onSale, dateSaleEnd) => {
|
|
830
|
+
const rp = parseFloat(regular);
|
|
831
|
+
const sp = parseFloat(sale);
|
|
832
|
+
|
|
833
|
+
if (p.type === 'simple' || varId) {
|
|
834
|
+
if ((!regular || regular === '') && p.status === 'publish') {
|
|
835
|
+
inconsistencies.push({ product_id: id, product_name: name, variation_id: varId, type: 'empty_price', severity: 'critical', detail: 'Published product with empty price', current_values: { regular_price: regular, sale_price: sale, on_sale: onSale }, recommendation: 'Set a regular price or move to draft' });
|
|
836
|
+
summary.critical++; hasIssue = true;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (sale && sale !== '' && !isNaN(sp)) {
|
|
841
|
+
if (!isNaN(rp) && sp >= rp) {
|
|
842
|
+
inconsistencies.push({ product_id: id, product_name: name, variation_id: varId, type: 'sale_gte_regular', severity: 'critical', detail: `Sale price (${sp}) >= regular price (${rp})`, current_values: { regular_price: regular, sale_price: sale, on_sale: onSale }, recommendation: 'Sale price should be lower than regular price' });
|
|
843
|
+
summary.critical++; hasIssue = true;
|
|
844
|
+
} else if (sp === 0 && onSale) {
|
|
845
|
+
inconsistencies.push({ product_id: id, product_name: name, variation_id: varId, type: 'zero_sale_price', severity: 'critical', detail: 'Sale price is 0 on a product marked on sale', current_values: { regular_price: regular, sale_price: sale, on_sale: onSale }, recommendation: 'Set a valid sale price or remove the sale' });
|
|
846
|
+
summary.critical++; hasIssue = true;
|
|
847
|
+
} else if (!isNaN(rp) && rp > 0 && sp > rp * 0.9) {
|
|
848
|
+
inconsistencies.push({ product_id: id, product_name: name, variation_id: varId, type: 'minimal_discount', severity: 'high', detail: `Discount less than 10% (sale: ${sp}, regular: ${rp})`, current_values: { regular_price: regular, sale_price: sale, on_sale: onSale }, recommendation: 'Review — minimal discounts may confuse customers' });
|
|
849
|
+
summary.high++; hasIssue = true;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (rp === 0 && p.price !== '0' && p.price !== '') {
|
|
854
|
+
inconsistencies.push({ product_id: id, product_name: name, variation_id: varId, type: 'zero_regular_price', severity: 'medium', detail: 'Regular price is 0', current_values: { regular_price: regular, sale_price: sale, on_sale: onSale }, recommendation: 'Verify this is intentionally free' });
|
|
855
|
+
summary.medium++; hasIssue = true;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (dateSaleEnd) {
|
|
859
|
+
const endDate = new Date(dateSaleEnd);
|
|
860
|
+
if (endDate < new Date() && onSale) {
|
|
861
|
+
inconsistencies.push({ product_id: id, product_name: name, variation_id: varId, type: 'expired_sale', severity: 'low', detail: `Sale ended ${dateSaleEnd} but still marked on sale`, current_values: { regular_price: regular, sale_price: sale, on_sale: onSale }, recommendation: 'Remove expired sale or update end date' });
|
|
862
|
+
summary.low++; hasIssue = true;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
checkPricing(p.id, p.name, null, p.regular_price, p.sale_price, p.on_sale, p.date_on_sale_to);
|
|
868
|
+
|
|
869
|
+
// Variations
|
|
870
|
+
if (priceIncludeVars && p.type === 'variable' && p.variations && p.variations.length > 0) {
|
|
871
|
+
try {
|
|
872
|
+
const vars = await wcApiCall(`/products/${p.id}/variations?per_page=100`, {}, baseUrl);
|
|
873
|
+
if (Array.isArray(vars)) {
|
|
874
|
+
for (const v of vars) {
|
|
875
|
+
totalChecked++;
|
|
876
|
+
checkPricing(p.id, p.name, v.id, v.regular_price, v.sale_price, v.on_sale, v.date_on_sale_to);
|
|
877
|
+
if (!v.regular_price && !v.sale_price) {
|
|
878
|
+
inconsistencies.push({ product_id: p.id, product_name: p.name, variation_id: v.id, type: 'variation_no_price', severity: 'high', detail: 'Variation has no price set', current_values: { regular_price: v.regular_price, sale_price: v.sale_price, on_sale: v.on_sale }, recommendation: 'Set a price for this variation' });
|
|
879
|
+
summary.high++; hasIssue = true;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
} catch {}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (!hasIssue) cleanCount++;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (products.length < 100) break;
|
|
890
|
+
pricePage++;
|
|
891
|
+
if (pricePage > 5) break;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
summary.total_checked = totalChecked;
|
|
895
|
+
summary.products_with_issues = totalChecked - cleanCount;
|
|
896
|
+
result = json({ inconsistencies, summary, clean_count: cleanCount });
|
|
897
|
+
auditLog({ tool: name, action: 'audit_pricing_consistency', target_type: 'product', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
898
|
+
return result;
|
|
899
|
+
};
|
|
900
|
+
handlers['wc_update_product'] = async (args) => {
|
|
901
|
+
const t0 = Date.now();
|
|
902
|
+
let result;
|
|
903
|
+
const { wcApiCall, getActiveAuth, auditLog, sanitizeParams, name } = rt;
|
|
904
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 }, status: { type: 'string', enum: ['publish', 'draft', 'private'] } });
|
|
905
|
+
const { id, name: prodName, description, short_description, regular_price, sale_price, status: prodStatus, price_guardrail_confirmed = false } = args;
|
|
906
|
+
const { url: baseUrl } = getActiveAuth();
|
|
907
|
+
|
|
908
|
+
// Price guardrail check
|
|
909
|
+
if (regular_price !== undefined || sale_price !== undefined) {
|
|
910
|
+
const current = await wcApiCall(`/products/${id}`, {}, baseUrl);
|
|
911
|
+
const checks = [];
|
|
912
|
+
if (regular_price !== undefined) checks.push({ label: 'regular_price', current: parseFloat(current.regular_price), proposed: parseFloat(regular_price) });
|
|
913
|
+
if (sale_price !== undefined && sale_price !== '') checks.push({ label: 'sale_price', current: parseFloat(current.sale_price || '0'), proposed: parseFloat(sale_price) });
|
|
914
|
+
|
|
915
|
+
for (const chk of checks) {
|
|
916
|
+
if (!isNaN(chk.current) && chk.current > 0) {
|
|
917
|
+
const changePct = Math.round(Math.abs(chk.proposed - chk.current) / chk.current * 10000) / 100;
|
|
918
|
+
if (changePct > 20 && !price_guardrail_confirmed) {
|
|
919
|
+
auditLog({ tool: name, target: id, target_type: 'product', action: 'update_product', status: 'blocked', latency_ms: Date.now() - t0, params: sanitizeParams(args), error: 'PRICE_GUARDRAIL_TRIGGERED' });
|
|
920
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'PRICE_GUARDRAIL_TRIGGERED', message: `Price change of ${changePct}% exceeds 20% threshold. Call wc_price_guardrail first, then retry with price_guardrail_confirmed: true`, current_price: chk.current, new_price: chk.proposed, change_percent: changePct }, null, 2) }], isError: true };
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const updateData = {};
|
|
927
|
+
if (prodName !== undefined) updateData.name = prodName;
|
|
928
|
+
if (description !== undefined) updateData.description = description;
|
|
929
|
+
if (short_description !== undefined) updateData.short_description = short_description;
|
|
930
|
+
if (regular_price !== undefined) updateData.regular_price = regular_price;
|
|
931
|
+
if (sale_price !== undefined) updateData.sale_price = sale_price;
|
|
932
|
+
if (prodStatus !== undefined) updateData.status = prodStatus;
|
|
933
|
+
|
|
934
|
+
const updated = await wcApiCall(`/products/${id}`, { method: 'PUT', body: JSON.stringify(updateData) }, baseUrl);
|
|
935
|
+
const auditParams = { id, ...updateData };
|
|
936
|
+
if (regular_price !== undefined || sale_price !== undefined) auditParams.price_change = true;
|
|
937
|
+
result = json({ success: true, message: `Product ${id} updated`, product: { id: updated.id, name: updated.name, status: updated.status, regular_price: updated.regular_price, sale_price: updated.sale_price, permalink: updated.permalink } });
|
|
938
|
+
auditLog({ tool: name, target: id, target_type: 'product', action: 'update_product', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(auditParams) });
|
|
939
|
+
return result;
|
|
940
|
+
};
|
|
941
|
+
handlers['wc_update_stock'] = async (args) => {
|
|
942
|
+
const t0 = Date.now();
|
|
943
|
+
let result;
|
|
944
|
+
const { wcApiCall, getActiveAuth, auditLog, name } = rt;
|
|
945
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 }, stock_quantity: { type: 'number', required: true, min: 0 }, variation_id: { type: 'number', min: 1 } });
|
|
946
|
+
const { id, stock_quantity, variation_id } = args;
|
|
947
|
+
const { url: baseUrl } = getActiveAuth();
|
|
948
|
+
|
|
949
|
+
// Fetch current product/variation for audit
|
|
950
|
+
let previousStock;
|
|
951
|
+
if (variation_id) {
|
|
952
|
+
const currentVar = await wcApiCall(`/products/${id}/variations/${variation_id}`, {}, baseUrl);
|
|
953
|
+
previousStock = currentVar.stock_quantity;
|
|
954
|
+
await wcApiCall(`/products/${id}/variations/${variation_id}`, { method: 'PUT', body: JSON.stringify({ stock_quantity, manage_stock: true }) }, baseUrl);
|
|
955
|
+
} else {
|
|
956
|
+
const currentProd = await wcApiCall(`/products/${id}`, {}, baseUrl);
|
|
957
|
+
previousStock = currentProd.stock_quantity;
|
|
958
|
+
await wcApiCall(`/products/${id}`, { method: 'PUT', body: JSON.stringify({ stock_quantity, manage_stock: true }) }, baseUrl);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
result = json({ success: true, message: variation_id ? `Variation ${variation_id} stock updated` : `Product ${id} stock updated`, product_id: id, variation_id: variation_id || null, previous_stock: previousStock, new_stock: stock_quantity });
|
|
962
|
+
auditLog({ tool: name, target: id, target_type: 'product', action: 'update_stock', status: 'success', latency_ms: Date.now() - t0, params: { id, variation_id: variation_id || null, previous_stock: previousStock, new_stock: stock_quantity } });
|
|
963
|
+
return result;
|
|
964
|
+
};
|
|
965
|
+
handlers['wc_update_order_status'] = async (args) => {
|
|
966
|
+
const t0 = Date.now();
|
|
967
|
+
let result;
|
|
968
|
+
const { wcApiCall, getActiveAuth, auditLog, name } = rt;
|
|
969
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 }, status: { type: 'string', required: true } });
|
|
970
|
+
const { id, status: newStatus, note } = args;
|
|
971
|
+
const { url: baseUrl } = getActiveAuth();
|
|
972
|
+
|
|
973
|
+
const VALID_TRANSITIONS = {
|
|
974
|
+
'pending': ['processing', 'cancelled', 'on-hold'],
|
|
975
|
+
'processing': ['completed', 'cancelled', 'refunded', 'on-hold'],
|
|
976
|
+
'on-hold': ['processing', 'cancelled'],
|
|
977
|
+
'completed': ['refunded'],
|
|
978
|
+
'cancelled': [],
|
|
979
|
+
'refunded': [],
|
|
980
|
+
'failed': ['processing', 'cancelled']
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const order = await wcApiCall(`/orders/${id}`, {}, baseUrl);
|
|
984
|
+
const currentStatus = order.status;
|
|
985
|
+
const validNext = VALID_TRANSITIONS[currentStatus];
|
|
986
|
+
|
|
987
|
+
if (validNext === undefined) {
|
|
988
|
+
auditLog({ tool: name, target: id, target_type: 'order', action: 'update_order_status', status: 'error', latency_ms: Date.now() - t0, params: { current_status: currentStatus, requested_status: newStatus }, error: 'UNKNOWN_STATUS' });
|
|
989
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'UNKNOWN_STATUS', current_status: currentStatus, requested_status: newStatus, message: `Unknown current status "${currentStatus}"` }, null, 2) }], isError: true };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (!validNext.includes(newStatus)) {
|
|
993
|
+
auditLog({ tool: name, target: id, target_type: 'order', action: 'update_order_status', status: 'error', latency_ms: Date.now() - t0, params: { current_status: currentStatus, requested_status: newStatus }, error: 'INVALID_TRANSITION' });
|
|
994
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_TRANSITION', current_status: currentStatus, requested_status: newStatus, valid_transitions: validNext, message: validNext.length === 0 ? `Order status "${currentStatus}" is terminal — no transitions allowed` : `Cannot transition from "${currentStatus}" to "${newStatus}". Valid: ${validNext.join(', ')}` }, null, 2) }], isError: true };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
await wcApiCall(`/orders/${id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) }, baseUrl);
|
|
998
|
+
|
|
999
|
+
let noteAdded = false;
|
|
1000
|
+
if (note) {
|
|
1001
|
+
await wcApiCall(`/orders/${id}/notes`, { method: 'POST', body: JSON.stringify({ note, customer_note: false }) }, baseUrl);
|
|
1002
|
+
noteAdded = true;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
result = json({ success: true, message: `Order ${id} status updated: ${currentStatus} → ${newStatus}`, order_id: id, previous_status: currentStatus, new_status: newStatus, note_added: noteAdded });
|
|
1006
|
+
auditLog({ tool: name, target: id, target_type: 'order', action: 'update_order_status', status: 'success', latency_ms: Date.now() - t0, params: { previous_status: currentStatus, new_status: newStatus, note_added: noteAdded } });
|
|
1007
|
+
return result;
|
|
1008
|
+
};
|