swagmanager-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/bin/swagmanager-mcp.js +25 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +141 -0
- package/dist/services/tool-registry.d.ts +37 -0
- package/dist/services/tool-registry.js +49 -0
- package/dist/tools/executor.d.ts +67 -0
- package/dist/tools/executor.js +2479 -0
- package/package.json +43 -0
|
@@ -0,0 +1,2479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consolidated Tool Executor
|
|
3
|
+
*
|
|
4
|
+
* Following Anthropic's best practices for tool design:
|
|
5
|
+
* - "More tools don't always lead to better outcomes"
|
|
6
|
+
* - "Claude Code uses about a dozen tools"
|
|
7
|
+
* - "Consolidate multi-step operations into single tool calls"
|
|
8
|
+
*
|
|
9
|
+
* 39 tools → 12 consolidated tools:
|
|
10
|
+
*
|
|
11
|
+
* 1. inventory - manage inventory (adjust, set, transfer, bulk operations)
|
|
12
|
+
* 2. inventory_query - query inventory (summary, velocity, by_location, in_stock)
|
|
13
|
+
* 3. inventory_audit - audit workflow (start, count, complete, summary)
|
|
14
|
+
* 4. collections - manage collections (find, create, get_theme, set_theme, set_icon)
|
|
15
|
+
* 5. customers - manage customers (find, create, update)
|
|
16
|
+
* 6. products - manage products (find, create, update, pricing)
|
|
17
|
+
* 7. analytics - analytics & intelligence (summary, by_location, detailed, discover, employee, customers, products, inventory_intelligence, marketing, fraud, employee_performance, behavior, full)
|
|
18
|
+
* 8. locations - find/list locations
|
|
19
|
+
* 9. orders - manage orders (find, get, create)
|
|
20
|
+
* 10. suppliers - find suppliers
|
|
21
|
+
* 11. email - unified email (send, send_template, list, get, templates)
|
|
22
|
+
* 12. documents - document generation
|
|
23
|
+
* 13. alerts - system alerts
|
|
24
|
+
* 14. audit_trail - audit logs
|
|
25
|
+
*/
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// ERROR CLASSIFICATION (Anthropic best practice: granular error types for retry logic)
|
|
28
|
+
// ============================================================================
|
|
29
|
+
export var ToolErrorType;
|
|
30
|
+
(function (ToolErrorType) {
|
|
31
|
+
ToolErrorType["RECOVERABLE"] = "recoverable";
|
|
32
|
+
ToolErrorType["PERMANENT"] = "permanent";
|
|
33
|
+
ToolErrorType["RATE_LIMIT"] = "rate_limit";
|
|
34
|
+
ToolErrorType["AUTH"] = "auth";
|
|
35
|
+
ToolErrorType["VALIDATION"] = "validation";
|
|
36
|
+
ToolErrorType["NOT_FOUND"] = "not_found"; // Resource doesn't exist
|
|
37
|
+
})(ToolErrorType || (ToolErrorType = {}));
|
|
38
|
+
// Classify errors based on message patterns
|
|
39
|
+
function classifyError(error) {
|
|
40
|
+
const message = error?.message || String(error);
|
|
41
|
+
const code = error?.code;
|
|
42
|
+
// Connection/network errors - recoverable
|
|
43
|
+
if (message.includes("connection") || message.includes("ECONNREFUSED") ||
|
|
44
|
+
message.includes("timeout") || message.includes("network")) {
|
|
45
|
+
return { errorType: ToolErrorType.RECOVERABLE, retryable: true };
|
|
46
|
+
}
|
|
47
|
+
// Rate limiting
|
|
48
|
+
if (message.includes("429") || message.includes("rate") || message.includes("too many")) {
|
|
49
|
+
return { errorType: ToolErrorType.RATE_LIMIT, retryable: true };
|
|
50
|
+
}
|
|
51
|
+
// Auth errors
|
|
52
|
+
if (message.includes("401") || message.includes("403") ||
|
|
53
|
+
message.includes("unauthorized") || message.includes("forbidden") ||
|
|
54
|
+
code === "42501" /* PostgreSQL insufficient_privilege */) {
|
|
55
|
+
return { errorType: ToolErrorType.AUTH, retryable: false };
|
|
56
|
+
}
|
|
57
|
+
// Not found
|
|
58
|
+
if (message.includes("not found") || message.includes("does not exist") ||
|
|
59
|
+
code === "PGRST116" /* PostgREST not found */) {
|
|
60
|
+
return { errorType: ToolErrorType.NOT_FOUND, retryable: false };
|
|
61
|
+
}
|
|
62
|
+
// Validation errors (usually from bad AI input)
|
|
63
|
+
if (message.includes("required") || message.includes("invalid") ||
|
|
64
|
+
message.includes("must be") || message.includes("expected")) {
|
|
65
|
+
return { errorType: ToolErrorType.VALIDATION, retryable: false };
|
|
66
|
+
}
|
|
67
|
+
// Default to permanent (don't retry unknown errors)
|
|
68
|
+
return { errorType: ToolErrorType.PERMANENT, retryable: false };
|
|
69
|
+
}
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// CONSOLIDATED TOOL HANDLERS
|
|
72
|
+
// ============================================================================
|
|
73
|
+
const handlers = {
|
|
74
|
+
// ===========================================================================
|
|
75
|
+
// 1. INVENTORY - Unified inventory management
|
|
76
|
+
// Actions: adjust, set, transfer, bulk_adjust, bulk_set, bulk_clear
|
|
77
|
+
// ===========================================================================
|
|
78
|
+
inventory: async (supabase, args, storeId) => {
|
|
79
|
+
const action = args.action;
|
|
80
|
+
if (!action) {
|
|
81
|
+
return { success: false, error: "action required: adjust, set, transfer, bulk_adjust, bulk_set, bulk_clear" };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
switch (action) {
|
|
85
|
+
case "adjust": {
|
|
86
|
+
const inventoryId = args.inventory_id;
|
|
87
|
+
const productId = args.product_id;
|
|
88
|
+
const locationId = args.location_id;
|
|
89
|
+
const adjustment = args.adjustment || args.quantity_change;
|
|
90
|
+
const reason = args.reason || "manual adjustment";
|
|
91
|
+
let id = inventoryId;
|
|
92
|
+
if (!id && productId && locationId) {
|
|
93
|
+
const { data: inv } = await supabase
|
|
94
|
+
.from("inventory")
|
|
95
|
+
.select("id, quantity")
|
|
96
|
+
.eq("product_id", productId)
|
|
97
|
+
.eq("location_id", locationId)
|
|
98
|
+
.single();
|
|
99
|
+
if (inv)
|
|
100
|
+
id = inv.id;
|
|
101
|
+
}
|
|
102
|
+
if (!id || adjustment === undefined) {
|
|
103
|
+
return { success: false, error: "inventory_id (or product_id+location_id) and adjustment required" };
|
|
104
|
+
}
|
|
105
|
+
const { data: current, error: fetchError } = await supabase
|
|
106
|
+
.from("inventory")
|
|
107
|
+
.select("quantity")
|
|
108
|
+
.eq("id", id)
|
|
109
|
+
.single();
|
|
110
|
+
if (fetchError)
|
|
111
|
+
return { success: false, error: fetchError.message };
|
|
112
|
+
const newQuantity = (current?.quantity || 0) + adjustment;
|
|
113
|
+
const { data, error } = await supabase
|
|
114
|
+
.from("inventory")
|
|
115
|
+
.update({ quantity: newQuantity, updated_at: new Date().toISOString() })
|
|
116
|
+
.eq("id", id)
|
|
117
|
+
.select()
|
|
118
|
+
.single();
|
|
119
|
+
if (error)
|
|
120
|
+
return { success: false, error: error.message };
|
|
121
|
+
return { success: true, data: { ...data, adjustment, reason } };
|
|
122
|
+
}
|
|
123
|
+
case "set": {
|
|
124
|
+
const productId = args.product_id;
|
|
125
|
+
const locationId = args.location_id;
|
|
126
|
+
const quantity = args.quantity;
|
|
127
|
+
const { data, error } = await supabase
|
|
128
|
+
.from("inventory")
|
|
129
|
+
.upsert({ product_id: productId, location_id: locationId, quantity })
|
|
130
|
+
.select()
|
|
131
|
+
.single();
|
|
132
|
+
if (error)
|
|
133
|
+
return { success: false, error: error.message };
|
|
134
|
+
return { success: true, data };
|
|
135
|
+
}
|
|
136
|
+
case "transfer": {
|
|
137
|
+
const productId = args.product_id;
|
|
138
|
+
const fromLocationId = args.from_location_id;
|
|
139
|
+
const toLocationId = args.to_location_id;
|
|
140
|
+
const quantity = args.quantity;
|
|
141
|
+
// Deduct from source
|
|
142
|
+
const { data: fromInv } = await supabase
|
|
143
|
+
.from("inventory")
|
|
144
|
+
.select("id, quantity")
|
|
145
|
+
.eq("product_id", productId)
|
|
146
|
+
.eq("location_id", fromLocationId)
|
|
147
|
+
.single();
|
|
148
|
+
if (!fromInv)
|
|
149
|
+
return { success: false, error: "Source inventory not found" };
|
|
150
|
+
if (fromInv.quantity < quantity)
|
|
151
|
+
return { success: false, error: `Insufficient stock: have ${fromInv.quantity}, need ${quantity}` };
|
|
152
|
+
await supabase
|
|
153
|
+
.from("inventory")
|
|
154
|
+
.update({ quantity: fromInv.quantity - quantity })
|
|
155
|
+
.eq("id", fromInv.id);
|
|
156
|
+
// Add to destination (upsert)
|
|
157
|
+
const { data: toInv } = await supabase
|
|
158
|
+
.from("inventory")
|
|
159
|
+
.select("id, quantity")
|
|
160
|
+
.eq("product_id", productId)
|
|
161
|
+
.eq("location_id", toLocationId)
|
|
162
|
+
.single();
|
|
163
|
+
if (toInv) {
|
|
164
|
+
await supabase
|
|
165
|
+
.from("inventory")
|
|
166
|
+
.update({ quantity: toInv.quantity + quantity })
|
|
167
|
+
.eq("id", toInv.id);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
await supabase
|
|
171
|
+
.from("inventory")
|
|
172
|
+
.insert({ product_id: productId, location_id: toLocationId, quantity });
|
|
173
|
+
}
|
|
174
|
+
return { success: true, data: { transferred: quantity, from: fromLocationId, to: toLocationId, product_id: productId } };
|
|
175
|
+
}
|
|
176
|
+
case "bulk_adjust": {
|
|
177
|
+
const adjustments = args.adjustments;
|
|
178
|
+
const results = [];
|
|
179
|
+
for (const adj of adjustments || []) {
|
|
180
|
+
const result = await handlers.inventory(supabase, { action: "adjust", ...adj }, storeId);
|
|
181
|
+
results.push({ ...adj, ...result });
|
|
182
|
+
}
|
|
183
|
+
return { success: true, data: { processed: results.length, results } };
|
|
184
|
+
}
|
|
185
|
+
case "bulk_set": {
|
|
186
|
+
const items = args.items;
|
|
187
|
+
const results = [];
|
|
188
|
+
for (const item of items || []) {
|
|
189
|
+
const result = await handlers.inventory(supabase, { action: "set", ...item }, storeId);
|
|
190
|
+
results.push({ ...item, ...result });
|
|
191
|
+
}
|
|
192
|
+
return { success: true, data: { processed: results.length, results } };
|
|
193
|
+
}
|
|
194
|
+
case "bulk_clear": {
|
|
195
|
+
const locationId = args.location_id || storeId;
|
|
196
|
+
const { data, error } = await supabase
|
|
197
|
+
.from("inventory")
|
|
198
|
+
.update({ quantity: 0 })
|
|
199
|
+
.eq("location_id", locationId)
|
|
200
|
+
.select();
|
|
201
|
+
if (error)
|
|
202
|
+
return { success: false, error: error.message };
|
|
203
|
+
return { success: true, data: { cleared: data?.length || 0, location_id: locationId } };
|
|
204
|
+
}
|
|
205
|
+
default:
|
|
206
|
+
return { success: false, error: `Unknown action: ${action}. Use: adjust, set, transfer, bulk_adjust, bulk_set, bulk_clear` };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
return { success: false, error: `Inventory error: ${err}` };
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
// ===========================================================================
|
|
214
|
+
// 2. INVENTORY_QUERY - Query inventory data
|
|
215
|
+
// Actions: summary, velocity, by_location, in_stock
|
|
216
|
+
// ===========================================================================
|
|
217
|
+
inventory_query: async (supabase, args, storeId) => {
|
|
218
|
+
const action = args.action || "summary";
|
|
219
|
+
try {
|
|
220
|
+
switch (action) {
|
|
221
|
+
case "summary": {
|
|
222
|
+
let q = supabase
|
|
223
|
+
.from("inventory")
|
|
224
|
+
.select("product_id, quantity, location_id, products(name, sku)");
|
|
225
|
+
if (storeId)
|
|
226
|
+
q = q.eq("store_id", storeId);
|
|
227
|
+
const { data, error } = await q;
|
|
228
|
+
if (error)
|
|
229
|
+
return { success: false, error: error.message };
|
|
230
|
+
const totalItems = data?.length || 0;
|
|
231
|
+
const totalQuantity = data?.reduce((sum, i) => sum + (i.quantity || 0), 0) || 0;
|
|
232
|
+
const lowStock = data?.filter(i => (i.quantity || 0) < 10).length || 0;
|
|
233
|
+
const outOfStock = data?.filter(i => (i.quantity || 0) === 0).length || 0;
|
|
234
|
+
return { success: true, data: { totalItems, totalQuantity, lowStock, outOfStock, items: data?.slice(0, 50) } };
|
|
235
|
+
}
|
|
236
|
+
case "velocity": {
|
|
237
|
+
const days = args.days || 30;
|
|
238
|
+
const startDate = new Date();
|
|
239
|
+
startDate.setDate(startDate.getDate() - days);
|
|
240
|
+
const { data, error } = await supabase
|
|
241
|
+
.from("order_items")
|
|
242
|
+
.select("product_id, quantity, products(name, sku)")
|
|
243
|
+
.gte("created_at", startDate.toISOString())
|
|
244
|
+
.limit(200);
|
|
245
|
+
if (error)
|
|
246
|
+
return { success: false, error: error.message };
|
|
247
|
+
const productSales = {};
|
|
248
|
+
for (const item of data || []) {
|
|
249
|
+
const pid = item.product_id;
|
|
250
|
+
if (!productSales[pid]) {
|
|
251
|
+
productSales[pid] = {
|
|
252
|
+
name: item.products?.name || "Unknown",
|
|
253
|
+
sku: item.products?.sku || "",
|
|
254
|
+
totalQty: 0
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
productSales[pid].totalQty += item.quantity || 0;
|
|
258
|
+
}
|
|
259
|
+
const sorted = Object.entries(productSales)
|
|
260
|
+
.map(([id, p]) => ({ productId: id, ...p, velocityPerDay: Math.round((p.totalQty / days) * 100) / 100 }))
|
|
261
|
+
.sort((a, b) => b.totalQty - a.totalQty);
|
|
262
|
+
return { success: true, data: { days, products: sorted.slice(0, 50) } };
|
|
263
|
+
}
|
|
264
|
+
case "by_location": {
|
|
265
|
+
const locationId = args.location_id;
|
|
266
|
+
if (!locationId)
|
|
267
|
+
return { success: false, error: "location_id required for by_location query" };
|
|
268
|
+
const { data, error } = await supabase
|
|
269
|
+
.from("inventory")
|
|
270
|
+
.select("product_id, quantity, products(name, sku)")
|
|
271
|
+
.eq("location_id", locationId);
|
|
272
|
+
if (error)
|
|
273
|
+
return { success: false, error: error.message };
|
|
274
|
+
const total = data?.reduce((sum, i) => sum + (i.quantity || 0), 0) || 0;
|
|
275
|
+
return { success: true, data: { location_id: locationId, total_quantity: total, item_count: data?.length || 0, items: data } };
|
|
276
|
+
}
|
|
277
|
+
case "in_stock": {
|
|
278
|
+
let q = supabase
|
|
279
|
+
.from("inventory")
|
|
280
|
+
.select("product_id, quantity, location_id, products(id, name, sku)")
|
|
281
|
+
.gt("quantity", 0);
|
|
282
|
+
if (storeId)
|
|
283
|
+
q = q.eq("store_id", storeId);
|
|
284
|
+
if (args.location_id)
|
|
285
|
+
q = q.eq("location_id", args.location_id);
|
|
286
|
+
const { data, error } = await q.limit(100);
|
|
287
|
+
if (error)
|
|
288
|
+
return { success: false, error: error.message };
|
|
289
|
+
return { success: true, data };
|
|
290
|
+
}
|
|
291
|
+
default:
|
|
292
|
+
return { success: false, error: `Unknown action: ${action}. Use: summary, velocity, by_location, in_stock` };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
return { success: false, error: `Inventory query error: ${err}` };
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
// ===========================================================================
|
|
300
|
+
// 3. INVENTORY_AUDIT - Audit workflow
|
|
301
|
+
// Actions: start, count, complete, summary
|
|
302
|
+
// ===========================================================================
|
|
303
|
+
inventory_audit: async (supabase, args, storeId) => {
|
|
304
|
+
const action = args.action;
|
|
305
|
+
if (!action) {
|
|
306
|
+
return { success: false, error: "action required: start, count, complete, summary" };
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
switch (action) {
|
|
310
|
+
case "start":
|
|
311
|
+
return { success: true, data: { message: "Audit started", location_id: args.location_id || storeId, started_at: new Date().toISOString() } };
|
|
312
|
+
case "count":
|
|
313
|
+
return { success: true, data: { message: "Count recorded", product_id: args.product_id, counted: args.counted, location_id: args.location_id } };
|
|
314
|
+
case "complete":
|
|
315
|
+
return { success: true, data: { message: "Audit completed", completed_at: new Date().toISOString() } };
|
|
316
|
+
case "summary":
|
|
317
|
+
return { success: true, data: { discrepancies: [], matched: 0, total: 0 } };
|
|
318
|
+
default:
|
|
319
|
+
return { success: false, error: `Unknown action: ${action}. Use: start, count, complete, summary` };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
return { success: false, error: `Inventory audit error: ${err}` };
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
// ===========================================================================
|
|
327
|
+
// 3b. PURCHASE_ORDERS - Full purchase order management
|
|
328
|
+
// Actions: create, list, get, add_items, approve, receive, cancel
|
|
329
|
+
// ===========================================================================
|
|
330
|
+
purchase_orders: async (supabase, args, storeId) => {
|
|
331
|
+
const action = args.action;
|
|
332
|
+
if (!action) {
|
|
333
|
+
return { success: false, error: "action required: create, list, get, add_items, approve, receive, cancel" };
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
switch (action) {
|
|
337
|
+
case "create": {
|
|
338
|
+
// Generate PO number
|
|
339
|
+
const poNumber = "PO-" + Date.now().toString().slice(-10);
|
|
340
|
+
// Create PO header
|
|
341
|
+
const { data: po, error: poErr } = await supabase
|
|
342
|
+
.from("purchase_orders")
|
|
343
|
+
.insert({
|
|
344
|
+
store_id: storeId,
|
|
345
|
+
po_number: poNumber,
|
|
346
|
+
po_type: "inbound", // Required field
|
|
347
|
+
supplier_id: args.supplier_id || null,
|
|
348
|
+
location_id: args.location_id || null,
|
|
349
|
+
status: "draft",
|
|
350
|
+
notes: args.notes || null,
|
|
351
|
+
expected_delivery_date: args.expected_delivery_date || null,
|
|
352
|
+
is_ai_action: true
|
|
353
|
+
})
|
|
354
|
+
.select()
|
|
355
|
+
.single();
|
|
356
|
+
if (poErr)
|
|
357
|
+
return { success: false, error: poErr.message };
|
|
358
|
+
// Add items if provided
|
|
359
|
+
const items = args.items || [];
|
|
360
|
+
if (items.length > 0) {
|
|
361
|
+
const insertItems = items.map(item => ({
|
|
362
|
+
purchase_order_id: po.id,
|
|
363
|
+
product_id: item.product_id,
|
|
364
|
+
quantity: item.quantity,
|
|
365
|
+
unit_price: item.unit_price || 0,
|
|
366
|
+
subtotal: item.quantity * (item.unit_price || 0) // Required field
|
|
367
|
+
}));
|
|
368
|
+
await supabase.from("purchase_order_items").insert(insertItems);
|
|
369
|
+
// Calculate PO subtotal
|
|
370
|
+
const poSubtotal = items.reduce((sum, i) => sum + (i.quantity * (i.unit_price || 0)), 0);
|
|
371
|
+
await supabase
|
|
372
|
+
.from("purchase_orders")
|
|
373
|
+
.update({ subtotal: poSubtotal, total_amount: poSubtotal })
|
|
374
|
+
.eq("id", po.id);
|
|
375
|
+
}
|
|
376
|
+
return { success: true, data: { purchase_order_id: po.id, po_number: poNumber, items_count: items.length } };
|
|
377
|
+
}
|
|
378
|
+
case "list": {
|
|
379
|
+
let q = supabase
|
|
380
|
+
.from("purchase_orders")
|
|
381
|
+
.select("id, po_number, po_type, status, supplier_id, location_id, total_amount, expected_delivery_date, created_at, location:locations(name)")
|
|
382
|
+
.order("created_at", { ascending: false });
|
|
383
|
+
if (storeId)
|
|
384
|
+
q = q.eq("store_id", storeId);
|
|
385
|
+
if (args.status)
|
|
386
|
+
q = q.eq("status", args.status);
|
|
387
|
+
if (args.supplier_id)
|
|
388
|
+
q = q.eq("supplier_id", args.supplier_id);
|
|
389
|
+
const { data, error } = await q.limit(args.limit || 50);
|
|
390
|
+
if (error)
|
|
391
|
+
return { success: false, error: error.message };
|
|
392
|
+
return { success: true, data };
|
|
393
|
+
}
|
|
394
|
+
case "get": {
|
|
395
|
+
const poId = args.purchase_order_id || args.id;
|
|
396
|
+
if (!poId)
|
|
397
|
+
return { success: false, error: "purchase_order_id required" };
|
|
398
|
+
const { data, error } = await supabase
|
|
399
|
+
.from("purchase_orders")
|
|
400
|
+
.select("*, items:purchase_order_items(*, product:products(name, sku)), location:locations(name)")
|
|
401
|
+
.eq("id", poId)
|
|
402
|
+
.single();
|
|
403
|
+
if (error)
|
|
404
|
+
return { success: false, error: error.message };
|
|
405
|
+
return { success: true, data };
|
|
406
|
+
}
|
|
407
|
+
case "add_items": {
|
|
408
|
+
const poId = args.purchase_order_id;
|
|
409
|
+
const items = args.items;
|
|
410
|
+
if (!poId || !items?.length)
|
|
411
|
+
return { success: false, error: "purchase_order_id and items required" };
|
|
412
|
+
const insertItems = items.map(item => ({
|
|
413
|
+
purchase_order_id: poId,
|
|
414
|
+
product_id: item.product_id,
|
|
415
|
+
quantity: item.quantity,
|
|
416
|
+
unit_price: item.unit_price || 0,
|
|
417
|
+
subtotal: item.quantity * (item.unit_price || 0) // Calculate subtotal
|
|
418
|
+
}));
|
|
419
|
+
const { data, error } = await supabase
|
|
420
|
+
.from("purchase_order_items")
|
|
421
|
+
.insert(insertItems)
|
|
422
|
+
.select();
|
|
423
|
+
if (error)
|
|
424
|
+
return { success: false, error: error.message };
|
|
425
|
+
// Recalculate totals
|
|
426
|
+
const { data: allItems } = await supabase
|
|
427
|
+
.from("purchase_order_items")
|
|
428
|
+
.select("subtotal")
|
|
429
|
+
.eq("purchase_order_id", poId);
|
|
430
|
+
const poSubtotal = (allItems || []).reduce((sum, i) => sum + (i.subtotal || 0), 0);
|
|
431
|
+
await supabase
|
|
432
|
+
.from("purchase_orders")
|
|
433
|
+
.update({ subtotal: poSubtotal, total_amount: poSubtotal, updated_at: new Date().toISOString() })
|
|
434
|
+
.eq("id", poId);
|
|
435
|
+
return { success: true, data: { items_added: data?.length || 0, new_subtotal: poSubtotal } };
|
|
436
|
+
}
|
|
437
|
+
case "approve": {
|
|
438
|
+
const poId = args.purchase_order_id;
|
|
439
|
+
if (!poId)
|
|
440
|
+
return { success: false, error: "purchase_order_id required" };
|
|
441
|
+
// Check if PO has supplier (required for approval)
|
|
442
|
+
const { data: existing } = await supabase
|
|
443
|
+
.from("purchase_orders")
|
|
444
|
+
.select("id, status, supplier_id")
|
|
445
|
+
.eq("id", poId)
|
|
446
|
+
.single();
|
|
447
|
+
if (!existing)
|
|
448
|
+
return { success: false, error: "PO not found" };
|
|
449
|
+
if (!["draft", "pending"].includes(existing.status)) {
|
|
450
|
+
return { success: false, error: `Cannot approve PO in ${existing.status} status` };
|
|
451
|
+
}
|
|
452
|
+
if (!existing.supplier_id) {
|
|
453
|
+
return { success: false, error: "Cannot approve PO without a supplier. Set supplier_id first." };
|
|
454
|
+
}
|
|
455
|
+
const { data, error } = await supabase
|
|
456
|
+
.from("purchase_orders")
|
|
457
|
+
.update({
|
|
458
|
+
status: "approved",
|
|
459
|
+
ordered_at: new Date().toISOString(),
|
|
460
|
+
updated_at: new Date().toISOString()
|
|
461
|
+
})
|
|
462
|
+
.eq("id", poId)
|
|
463
|
+
.select()
|
|
464
|
+
.single();
|
|
465
|
+
if (error)
|
|
466
|
+
return { success: false, error: error.message };
|
|
467
|
+
return { success: true, data };
|
|
468
|
+
}
|
|
469
|
+
case "receive": {
|
|
470
|
+
const poId = args.purchase_order_id;
|
|
471
|
+
if (!poId)
|
|
472
|
+
return { success: false, error: "purchase_order_id required" };
|
|
473
|
+
// Get PO details
|
|
474
|
+
const { data: po, error: poErr } = await supabase
|
|
475
|
+
.from("purchase_orders")
|
|
476
|
+
.select("*, items:purchase_order_items(*)")
|
|
477
|
+
.eq("id", poId)
|
|
478
|
+
.single();
|
|
479
|
+
if (poErr || !po)
|
|
480
|
+
return { success: false, error: poErr?.message || "PO not found" };
|
|
481
|
+
if (po.status === "received")
|
|
482
|
+
return { success: false, error: "PO already fully received" };
|
|
483
|
+
if (po.status === "cancelled")
|
|
484
|
+
return { success: false, error: "Cannot receive cancelled PO" };
|
|
485
|
+
const receiveItems = args.items;
|
|
486
|
+
let itemsReceived = 0;
|
|
487
|
+
let totalQtyReceived = 0;
|
|
488
|
+
// Process each PO item
|
|
489
|
+
for (const poItem of po.items || []) {
|
|
490
|
+
let qtyToReceive = poItem.quantity - (poItem.received_quantity || 0);
|
|
491
|
+
// If specific items provided, find matching quantity
|
|
492
|
+
if (receiveItems) {
|
|
493
|
+
const match = receiveItems.find(ri => ri.item_id === poItem.id || ri.product_id === poItem.product_id);
|
|
494
|
+
if (!match)
|
|
495
|
+
continue;
|
|
496
|
+
qtyToReceive = Math.min(match.quantity, qtyToReceive);
|
|
497
|
+
}
|
|
498
|
+
if (qtyToReceive <= 0)
|
|
499
|
+
continue;
|
|
500
|
+
// Find or create inventory record at destination
|
|
501
|
+
let invId = null;
|
|
502
|
+
const { data: existingInv } = await supabase
|
|
503
|
+
.from("inventory")
|
|
504
|
+
.select("id, quantity")
|
|
505
|
+
.eq("product_id", poItem.product_id)
|
|
506
|
+
.eq("location_id", po.location_id)
|
|
507
|
+
.single();
|
|
508
|
+
if (existingInv) {
|
|
509
|
+
invId = existingInv.id;
|
|
510
|
+
await supabase
|
|
511
|
+
.from("inventory")
|
|
512
|
+
.update({
|
|
513
|
+
quantity: existingInv.quantity + qtyToReceive,
|
|
514
|
+
updated_at: new Date().toISOString()
|
|
515
|
+
})
|
|
516
|
+
.eq("id", invId);
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
const { data: newInv } = await supabase
|
|
520
|
+
.from("inventory")
|
|
521
|
+
.insert({
|
|
522
|
+
product_id: poItem.product_id,
|
|
523
|
+
location_id: po.location_id,
|
|
524
|
+
store_id: po.store_id,
|
|
525
|
+
quantity: qtyToReceive
|
|
526
|
+
})
|
|
527
|
+
.select()
|
|
528
|
+
.single();
|
|
529
|
+
invId = newInv?.id;
|
|
530
|
+
}
|
|
531
|
+
// Update PO item received quantity
|
|
532
|
+
const newReceivedQty = (poItem.received_quantity || 0) + qtyToReceive;
|
|
533
|
+
await supabase
|
|
534
|
+
.from("purchase_order_items")
|
|
535
|
+
.update({
|
|
536
|
+
received_quantity: newReceivedQty,
|
|
537
|
+
receive_status: newReceivedQty >= poItem.quantity ? "received" : "partial",
|
|
538
|
+
updated_at: new Date().toISOString()
|
|
539
|
+
})
|
|
540
|
+
.eq("id", poItem.id);
|
|
541
|
+
// Log adjustment (ignore errors if table doesn't exist)
|
|
542
|
+
if (invId) {
|
|
543
|
+
try {
|
|
544
|
+
await supabase.from("inventory_adjustments").insert({
|
|
545
|
+
inventory_id: invId,
|
|
546
|
+
product_id: poItem.product_id,
|
|
547
|
+
location_id: po.location_id,
|
|
548
|
+
adjustment_type: "PO_RECEIVE",
|
|
549
|
+
old_quantity: existingInv?.quantity || 0,
|
|
550
|
+
new_quantity: (existingInv?.quantity || 0) + qtyToReceive,
|
|
551
|
+
reason: `Received from PO ${po.po_number}`
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
catch { /* ignore */ }
|
|
555
|
+
}
|
|
556
|
+
itemsReceived++;
|
|
557
|
+
totalQtyReceived += qtyToReceive;
|
|
558
|
+
}
|
|
559
|
+
// Update PO status
|
|
560
|
+
const { data: updatedItems } = await supabase
|
|
561
|
+
.from("purchase_order_items")
|
|
562
|
+
.select("quantity, received_quantity")
|
|
563
|
+
.eq("purchase_order_id", poId);
|
|
564
|
+
const allReceived = (updatedItems || []).every(i => (i.received_quantity || 0) >= i.quantity);
|
|
565
|
+
const anyReceived = (updatedItems || []).some(i => (i.received_quantity || 0) > 0);
|
|
566
|
+
await supabase
|
|
567
|
+
.from("purchase_orders")
|
|
568
|
+
.update({
|
|
569
|
+
status: allReceived ? "received" : (anyReceived ? "partial" : po.status),
|
|
570
|
+
received_at: allReceived ? new Date().toISOString() : null,
|
|
571
|
+
updated_at: new Date().toISOString()
|
|
572
|
+
})
|
|
573
|
+
.eq("id", poId);
|
|
574
|
+
return {
|
|
575
|
+
success: true,
|
|
576
|
+
data: {
|
|
577
|
+
items_received: itemsReceived,
|
|
578
|
+
total_quantity_received: totalQtyReceived,
|
|
579
|
+
po_status: allReceived ? "received" : "partial"
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
case "cancel": {
|
|
584
|
+
const poId = args.purchase_order_id;
|
|
585
|
+
if (!poId)
|
|
586
|
+
return { success: false, error: "purchase_order_id required" };
|
|
587
|
+
// Check current status
|
|
588
|
+
const { data: existing } = await supabase
|
|
589
|
+
.from("purchase_orders")
|
|
590
|
+
.select("id, status")
|
|
591
|
+
.eq("id", poId)
|
|
592
|
+
.single();
|
|
593
|
+
if (!existing)
|
|
594
|
+
return { success: false, error: "PO not found" };
|
|
595
|
+
if (["received", "cancelled"].includes(existing.status)) {
|
|
596
|
+
return { success: false, error: `Cannot cancel PO in ${existing.status} status` };
|
|
597
|
+
}
|
|
598
|
+
const { data, error } = await supabase
|
|
599
|
+
.from("purchase_orders")
|
|
600
|
+
.update({
|
|
601
|
+
status: "cancelled",
|
|
602
|
+
updated_at: new Date().toISOString()
|
|
603
|
+
})
|
|
604
|
+
.eq("id", poId)
|
|
605
|
+
.select()
|
|
606
|
+
.single();
|
|
607
|
+
if (error)
|
|
608
|
+
return { success: false, error: error.message };
|
|
609
|
+
return { success: true, data };
|
|
610
|
+
}
|
|
611
|
+
default:
|
|
612
|
+
return { success: false, error: `Unknown action: ${action}. Use: create, list, get, add_items, approve, receive, cancel` };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
return { success: false, error: `Purchase orders error: ${err}` };
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
// ===========================================================================
|
|
620
|
+
// 3c. TRANSFERS - Inventory transfers between locations
|
|
621
|
+
// Actions: create, list, get, receive, cancel
|
|
622
|
+
// ===========================================================================
|
|
623
|
+
transfers: async (supabase, args, storeId) => {
|
|
624
|
+
const action = args.action;
|
|
625
|
+
if (!action) {
|
|
626
|
+
return { success: false, error: "action required: create, list, get, receive, cancel" };
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
switch (action) {
|
|
630
|
+
case "create": {
|
|
631
|
+
const fromLocationId = args.from_location_id;
|
|
632
|
+
const toLocationId = args.to_location_id;
|
|
633
|
+
const items = args.items;
|
|
634
|
+
if (!fromLocationId || !toLocationId || !items?.length) {
|
|
635
|
+
return { success: false, error: "from_location_id, to_location_id, and items required" };
|
|
636
|
+
}
|
|
637
|
+
if (fromLocationId === toLocationId) {
|
|
638
|
+
return { success: false, error: "Source and destination must be different" };
|
|
639
|
+
}
|
|
640
|
+
// Generate transfer number
|
|
641
|
+
const transferNumber = "TR-" + Date.now().toString().slice(-10);
|
|
642
|
+
// Create transfer header
|
|
643
|
+
const { data: transfer, error: trErr } = await supabase
|
|
644
|
+
.from("inventory_transfers")
|
|
645
|
+
.insert({
|
|
646
|
+
store_id: storeId,
|
|
647
|
+
transfer_number: transferNumber,
|
|
648
|
+
source_location_id: fromLocationId,
|
|
649
|
+
destination_location_id: toLocationId,
|
|
650
|
+
status: "in_transit", // Valid status
|
|
651
|
+
notes: args.notes || null,
|
|
652
|
+
is_ai_action: true
|
|
653
|
+
})
|
|
654
|
+
.select()
|
|
655
|
+
.single();
|
|
656
|
+
if (trErr)
|
|
657
|
+
return { success: false, error: trErr.message };
|
|
658
|
+
// Process each item - deduct from source, add to transfer items
|
|
659
|
+
let itemsCount = 0;
|
|
660
|
+
for (const item of items) {
|
|
661
|
+
// Check source inventory
|
|
662
|
+
const { data: srcInv } = await supabase
|
|
663
|
+
.from("inventory")
|
|
664
|
+
.select("id, quantity")
|
|
665
|
+
.eq("product_id", item.product_id)
|
|
666
|
+
.eq("location_id", fromLocationId)
|
|
667
|
+
.single();
|
|
668
|
+
if (!srcInv) {
|
|
669
|
+
// Rollback
|
|
670
|
+
await supabase.from("inventory_transfers").delete().eq("id", transfer.id);
|
|
671
|
+
return { success: false, error: `Product ${item.product_id} not found at source location` };
|
|
672
|
+
}
|
|
673
|
+
if (srcInv.quantity < item.quantity) {
|
|
674
|
+
await supabase.from("inventory_transfers").delete().eq("id", transfer.id);
|
|
675
|
+
return { success: false, error: `Insufficient quantity: have ${srcInv.quantity}, need ${item.quantity}` };
|
|
676
|
+
}
|
|
677
|
+
// Add transfer item
|
|
678
|
+
await supabase.from("inventory_transfer_items").insert({
|
|
679
|
+
transfer_id: transfer.id,
|
|
680
|
+
product_id: item.product_id,
|
|
681
|
+
quantity: item.quantity
|
|
682
|
+
});
|
|
683
|
+
// Deduct from source inventory
|
|
684
|
+
await supabase
|
|
685
|
+
.from("inventory")
|
|
686
|
+
.update({
|
|
687
|
+
quantity: srcInv.quantity - item.quantity,
|
|
688
|
+
updated_at: new Date().toISOString()
|
|
689
|
+
})
|
|
690
|
+
.eq("id", srcInv.id);
|
|
691
|
+
// Log adjustment (ignore errors)
|
|
692
|
+
try {
|
|
693
|
+
await supabase.from("inventory_adjustments").insert({
|
|
694
|
+
inventory_id: srcInv.id,
|
|
695
|
+
product_id: item.product_id,
|
|
696
|
+
location_id: fromLocationId,
|
|
697
|
+
adjustment_type: "TRANSFER_OUT",
|
|
698
|
+
old_quantity: srcInv.quantity,
|
|
699
|
+
new_quantity: srcInv.quantity - item.quantity,
|
|
700
|
+
reason: `Transfer to ${toLocationId} (${transferNumber})`
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
catch { /* ignore */ }
|
|
704
|
+
itemsCount++;
|
|
705
|
+
}
|
|
706
|
+
// Update status to in_transit
|
|
707
|
+
await supabase
|
|
708
|
+
.from("inventory_transfers")
|
|
709
|
+
.update({ status: "in_transit", shipped_at: new Date().toISOString() })
|
|
710
|
+
.eq("id", transfer.id);
|
|
711
|
+
return {
|
|
712
|
+
success: true,
|
|
713
|
+
data: { transfer_id: transfer.id, transfer_number: transferNumber, items_count: itemsCount }
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
case "list": {
|
|
717
|
+
let q = supabase
|
|
718
|
+
.from("inventory_transfers")
|
|
719
|
+
.select("id, transfer_number, status, source_location_id, destination_location_id, created_at, shipped_at, received_at, from_location:locations!inventory_transfers_source_location_id_fkey(name), to_location:locations!inventory_transfers_destination_location_id_fkey(name)")
|
|
720
|
+
.order("created_at", { ascending: false });
|
|
721
|
+
if (storeId)
|
|
722
|
+
q = q.eq("store_id", storeId);
|
|
723
|
+
if (args.status)
|
|
724
|
+
q = q.eq("status", args.status);
|
|
725
|
+
if (args.from_location_id)
|
|
726
|
+
q = q.eq("source_location_id", args.from_location_id);
|
|
727
|
+
if (args.to_location_id)
|
|
728
|
+
q = q.eq("destination_location_id", args.to_location_id);
|
|
729
|
+
const { data, error } = await q.limit(args.limit || 50);
|
|
730
|
+
if (error)
|
|
731
|
+
return { success: false, error: error.message };
|
|
732
|
+
return { success: true, data };
|
|
733
|
+
}
|
|
734
|
+
case "get": {
|
|
735
|
+
const transferId = args.transfer_id || args.id;
|
|
736
|
+
if (!transferId)
|
|
737
|
+
return { success: false, error: "transfer_id required" };
|
|
738
|
+
const { data, error } = await supabase
|
|
739
|
+
.from("inventory_transfers")
|
|
740
|
+
.select("*, items:inventory_transfer_items(*, product:products(name, sku)), from_location:locations!inventory_transfers_source_location_id_fkey(name), to_location:locations!inventory_transfers_destination_location_id_fkey(name)")
|
|
741
|
+
.eq("id", transferId)
|
|
742
|
+
.single();
|
|
743
|
+
if (error)
|
|
744
|
+
return { success: false, error: error.message };
|
|
745
|
+
return { success: true, data };
|
|
746
|
+
}
|
|
747
|
+
case "receive": {
|
|
748
|
+
const transferId = args.transfer_id;
|
|
749
|
+
if (!transferId)
|
|
750
|
+
return { success: false, error: "transfer_id required" };
|
|
751
|
+
// Get transfer details
|
|
752
|
+
const { data: transfer, error: trErr } = await supabase
|
|
753
|
+
.from("inventory_transfers")
|
|
754
|
+
.select("*, items:inventory_transfer_items(*)")
|
|
755
|
+
.eq("id", transferId)
|
|
756
|
+
.single();
|
|
757
|
+
if (trErr || !transfer)
|
|
758
|
+
return { success: false, error: trErr?.message || "Transfer not found" };
|
|
759
|
+
if (transfer.status === "received")
|
|
760
|
+
return { success: false, error: "Transfer already received" };
|
|
761
|
+
if (transfer.status === "cancelled")
|
|
762
|
+
return { success: false, error: "Cannot receive cancelled transfer" };
|
|
763
|
+
const receiveItems = args.items;
|
|
764
|
+
let itemsReceived = 0;
|
|
765
|
+
let totalQtyReceived = 0;
|
|
766
|
+
// Process each transfer item
|
|
767
|
+
for (const trItem of transfer.items || []) {
|
|
768
|
+
let qtyToReceive = trItem.quantity - (trItem.received_quantity || 0);
|
|
769
|
+
// If specific items provided, match them
|
|
770
|
+
if (receiveItems) {
|
|
771
|
+
const match = receiveItems.find(ri => ri.item_id === trItem.id || ri.product_id === trItem.product_id);
|
|
772
|
+
if (!match)
|
|
773
|
+
continue;
|
|
774
|
+
qtyToReceive = Math.min(match.quantity, qtyToReceive);
|
|
775
|
+
}
|
|
776
|
+
if (qtyToReceive <= 0)
|
|
777
|
+
continue;
|
|
778
|
+
// Find or create destination inventory
|
|
779
|
+
let destInvId = null;
|
|
780
|
+
const { data: existingInv } = await supabase
|
|
781
|
+
.from("inventory")
|
|
782
|
+
.select("id, quantity")
|
|
783
|
+
.eq("product_id", trItem.product_id)
|
|
784
|
+
.eq("location_id", transfer.destination_location_id)
|
|
785
|
+
.single();
|
|
786
|
+
if (existingInv) {
|
|
787
|
+
destInvId = existingInv.id;
|
|
788
|
+
await supabase
|
|
789
|
+
.from("inventory")
|
|
790
|
+
.update({
|
|
791
|
+
quantity: existingInv.quantity + qtyToReceive,
|
|
792
|
+
updated_at: new Date().toISOString()
|
|
793
|
+
})
|
|
794
|
+
.eq("id", destInvId);
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
const { data: newInv } = await supabase
|
|
798
|
+
.from("inventory")
|
|
799
|
+
.insert({
|
|
800
|
+
product_id: trItem.product_id,
|
|
801
|
+
location_id: transfer.destination_location_id,
|
|
802
|
+
store_id: transfer.store_id,
|
|
803
|
+
quantity: qtyToReceive
|
|
804
|
+
})
|
|
805
|
+
.select()
|
|
806
|
+
.single();
|
|
807
|
+
destInvId = newInv?.id;
|
|
808
|
+
}
|
|
809
|
+
// Update transfer item
|
|
810
|
+
const newReceivedQty = (trItem.received_quantity || 0) + qtyToReceive;
|
|
811
|
+
await supabase
|
|
812
|
+
.from("inventory_transfer_items")
|
|
813
|
+
.update({
|
|
814
|
+
received_quantity: newReceivedQty,
|
|
815
|
+
updated_at: new Date().toISOString()
|
|
816
|
+
})
|
|
817
|
+
.eq("id", trItem.id);
|
|
818
|
+
// Log adjustment (ignore errors)
|
|
819
|
+
if (destInvId) {
|
|
820
|
+
try {
|
|
821
|
+
await supabase.from("inventory_adjustments").insert({
|
|
822
|
+
inventory_id: destInvId,
|
|
823
|
+
product_id: trItem.product_id,
|
|
824
|
+
location_id: transfer.destination_location_id,
|
|
825
|
+
adjustment_type: "TRANSFER_IN",
|
|
826
|
+
old_quantity: existingInv?.quantity || 0,
|
|
827
|
+
new_quantity: (existingInv?.quantity || 0) + qtyToReceive,
|
|
828
|
+
reason: `Received from transfer ${transfer.transfer_number}`
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
catch { /* ignore */ }
|
|
832
|
+
}
|
|
833
|
+
itemsReceived++;
|
|
834
|
+
totalQtyReceived += qtyToReceive;
|
|
835
|
+
}
|
|
836
|
+
// Update transfer status
|
|
837
|
+
const { data: updatedItems } = await supabase
|
|
838
|
+
.from("inventory_transfer_items")
|
|
839
|
+
.select("quantity, received_quantity")
|
|
840
|
+
.eq("transfer_id", transferId);
|
|
841
|
+
const allReceived = (updatedItems || []).every(i => (i.received_quantity || 0) >= i.quantity);
|
|
842
|
+
await supabase
|
|
843
|
+
.from("inventory_transfers")
|
|
844
|
+
.update({
|
|
845
|
+
status: allReceived ? "received" : "in_transit",
|
|
846
|
+
received_at: allReceived ? new Date().toISOString() : null,
|
|
847
|
+
updated_at: new Date().toISOString()
|
|
848
|
+
})
|
|
849
|
+
.eq("id", transferId);
|
|
850
|
+
return {
|
|
851
|
+
success: true,
|
|
852
|
+
data: {
|
|
853
|
+
items_received: itemsReceived,
|
|
854
|
+
total_quantity_received: totalQtyReceived,
|
|
855
|
+
transfer_status: allReceived ? "received" : "partial"
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
case "cancel": {
|
|
860
|
+
const transferId = args.transfer_id;
|
|
861
|
+
if (!transferId)
|
|
862
|
+
return { success: false, error: "transfer_id required" };
|
|
863
|
+
// Get transfer items to restore inventory
|
|
864
|
+
const { data: transfer } = await supabase
|
|
865
|
+
.from("inventory_transfers")
|
|
866
|
+
.select("*, items:inventory_transfer_items(*)")
|
|
867
|
+
.eq("id", transferId)
|
|
868
|
+
.single();
|
|
869
|
+
if (!transfer)
|
|
870
|
+
return { success: false, error: "Transfer not found" };
|
|
871
|
+
if (transfer.status === "received")
|
|
872
|
+
return { success: false, error: "Cannot cancel a received transfer" };
|
|
873
|
+
if (transfer.status === "cancelled")
|
|
874
|
+
return { success: false, error: "Transfer already cancelled" };
|
|
875
|
+
// Restore inventory at source location
|
|
876
|
+
for (const item of transfer.items || []) {
|
|
877
|
+
// Get source inventory by product and source location
|
|
878
|
+
const { data: srcInv } = await supabase
|
|
879
|
+
.from("inventory")
|
|
880
|
+
.select("id, quantity")
|
|
881
|
+
.eq("product_id", item.product_id)
|
|
882
|
+
.eq("location_id", transfer.source_location_id)
|
|
883
|
+
.single();
|
|
884
|
+
if (srcInv) {
|
|
885
|
+
// Restore quantity
|
|
886
|
+
const restoreQty = item.quantity - (item.received_quantity || 0);
|
|
887
|
+
await supabase
|
|
888
|
+
.from("inventory")
|
|
889
|
+
.update({
|
|
890
|
+
quantity: srcInv.quantity + restoreQty,
|
|
891
|
+
updated_at: new Date().toISOString()
|
|
892
|
+
})
|
|
893
|
+
.eq("id", srcInv.id);
|
|
894
|
+
// Log adjustment (ignore errors)
|
|
895
|
+
try {
|
|
896
|
+
await supabase.from("inventory_adjustments").insert({
|
|
897
|
+
inventory_id: srcInv.id,
|
|
898
|
+
product_id: item.product_id,
|
|
899
|
+
location_id: transfer.source_location_id,
|
|
900
|
+
adjustment_type: "TRANSFER_CANCEL",
|
|
901
|
+
old_quantity: srcInv.quantity,
|
|
902
|
+
new_quantity: srcInv.quantity + restoreQty,
|
|
903
|
+
reason: `Cancelled transfer ${transfer.transfer_number}`
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
catch { /* ignore */ }
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// Update transfer status
|
|
910
|
+
const { data, error } = await supabase
|
|
911
|
+
.from("inventory_transfers")
|
|
912
|
+
.update({
|
|
913
|
+
status: "cancelled",
|
|
914
|
+
cancelled_at: new Date().toISOString(),
|
|
915
|
+
updated_at: new Date().toISOString()
|
|
916
|
+
})
|
|
917
|
+
.eq("id", transferId)
|
|
918
|
+
.select()
|
|
919
|
+
.single();
|
|
920
|
+
if (error)
|
|
921
|
+
return { success: false, error: error.message };
|
|
922
|
+
return { success: true, data: { ...data, inventory_restored: true } };
|
|
923
|
+
}
|
|
924
|
+
default:
|
|
925
|
+
return { success: false, error: `Unknown action: ${action}. Use: create, list, get, receive, cancel` };
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
catch (err) {
|
|
929
|
+
return { success: false, error: `Transfers error: ${err}` };
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
// ===========================================================================
|
|
933
|
+
// 4. COLLECTIONS - Manage collections
|
|
934
|
+
// Actions: find, create, get_theme, set_theme, set_icon
|
|
935
|
+
// ===========================================================================
|
|
936
|
+
collections: async (supabase, args, storeId) => {
|
|
937
|
+
const action = args.action || "find";
|
|
938
|
+
// Helper to handle missing table gracefully
|
|
939
|
+
const handleTableError = (error) => {
|
|
940
|
+
if (error.message.includes("does not exist") || error.code === "42P01" || error.message.includes("schema cache")) {
|
|
941
|
+
return { success: true, data: [], message: "Collections table not configured" };
|
|
942
|
+
}
|
|
943
|
+
return { success: false, error: error.message };
|
|
944
|
+
};
|
|
945
|
+
try {
|
|
946
|
+
switch (action) {
|
|
947
|
+
case "find": {
|
|
948
|
+
let q = supabase.from("collections").select("*");
|
|
949
|
+
if (storeId)
|
|
950
|
+
q = q.eq("store_id", storeId);
|
|
951
|
+
if (args.name)
|
|
952
|
+
q = q.ilike("name", `%${args.name}%`);
|
|
953
|
+
const { data, error } = await q;
|
|
954
|
+
if (error)
|
|
955
|
+
return handleTableError(error);
|
|
956
|
+
return { success: true, data };
|
|
957
|
+
}
|
|
958
|
+
case "create": {
|
|
959
|
+
const { data, error } = await supabase
|
|
960
|
+
.from("collections")
|
|
961
|
+
.insert({ name: args.name, description: args.description, store_id: storeId })
|
|
962
|
+
.select()
|
|
963
|
+
.single();
|
|
964
|
+
if (error)
|
|
965
|
+
return handleTableError(error);
|
|
966
|
+
return { success: true, data };
|
|
967
|
+
}
|
|
968
|
+
case "get_theme": {
|
|
969
|
+
const { data, error } = await supabase
|
|
970
|
+
.from("collections")
|
|
971
|
+
.select("id, name, theme")
|
|
972
|
+
.eq("id", args.collection_id)
|
|
973
|
+
.single();
|
|
974
|
+
if (error)
|
|
975
|
+
return handleTableError(error);
|
|
976
|
+
return { success: true, data };
|
|
977
|
+
}
|
|
978
|
+
case "set_theme": {
|
|
979
|
+
const { data, error } = await supabase
|
|
980
|
+
.from("collections")
|
|
981
|
+
.update({ theme: args.theme })
|
|
982
|
+
.eq("id", args.collection_id)
|
|
983
|
+
.select()
|
|
984
|
+
.single();
|
|
985
|
+
if (error)
|
|
986
|
+
return handleTableError(error);
|
|
987
|
+
return { success: true, data };
|
|
988
|
+
}
|
|
989
|
+
case "set_icon": {
|
|
990
|
+
const { data, error } = await supabase
|
|
991
|
+
.from("collections")
|
|
992
|
+
.update({ icon: args.icon })
|
|
993
|
+
.eq("id", args.collection_id)
|
|
994
|
+
.select()
|
|
995
|
+
.single();
|
|
996
|
+
if (error)
|
|
997
|
+
return handleTableError(error);
|
|
998
|
+
return { success: true, data };
|
|
999
|
+
}
|
|
1000
|
+
default:
|
|
1001
|
+
return { success: false, error: `Unknown action: ${action}. Use: find, create, get_theme, set_theme, set_icon` };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
return { success: false, error: `Collections error: ${err}` };
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
// ===========================================================================
|
|
1009
|
+
// 5. CUSTOMERS - Manage customers
|
|
1010
|
+
// Actions: find, create, update
|
|
1011
|
+
// ===========================================================================
|
|
1012
|
+
customers: async (supabase, args, storeId) => {
|
|
1013
|
+
const action = args.action || "find";
|
|
1014
|
+
try {
|
|
1015
|
+
switch (action) {
|
|
1016
|
+
case "find": {
|
|
1017
|
+
const query = args.query || "";
|
|
1018
|
+
const email = args.email;
|
|
1019
|
+
const phone = args.phone;
|
|
1020
|
+
const limit = args.limit || 20;
|
|
1021
|
+
let q = supabase
|
|
1022
|
+
.from("customers")
|
|
1023
|
+
.select("id, email, phone, first_name, last_name, created_at")
|
|
1024
|
+
.limit(limit);
|
|
1025
|
+
if (query) {
|
|
1026
|
+
q = q.or(`email.ilike.%${query}%,phone.ilike.%${query}%,first_name.ilike.%${query}%,last_name.ilike.%${query}%`);
|
|
1027
|
+
}
|
|
1028
|
+
if (email)
|
|
1029
|
+
q = q.eq("email", email);
|
|
1030
|
+
if (phone)
|
|
1031
|
+
q = q.eq("phone", phone);
|
|
1032
|
+
if (storeId)
|
|
1033
|
+
q = q.eq("store_id", storeId);
|
|
1034
|
+
const { data, error } = await q;
|
|
1035
|
+
if (error)
|
|
1036
|
+
return { success: false, error: error.message };
|
|
1037
|
+
return { success: true, data };
|
|
1038
|
+
}
|
|
1039
|
+
case "create": {
|
|
1040
|
+
const { data, error } = await supabase
|
|
1041
|
+
.from("customers")
|
|
1042
|
+
.insert({
|
|
1043
|
+
email: args.email,
|
|
1044
|
+
phone: args.phone,
|
|
1045
|
+
first_name: args.first_name,
|
|
1046
|
+
last_name: args.last_name,
|
|
1047
|
+
store_id: storeId
|
|
1048
|
+
})
|
|
1049
|
+
.select()
|
|
1050
|
+
.single();
|
|
1051
|
+
if (error)
|
|
1052
|
+
return { success: false, error: error.message };
|
|
1053
|
+
return { success: true, data };
|
|
1054
|
+
}
|
|
1055
|
+
case "update": {
|
|
1056
|
+
const customerId = args.customer_id;
|
|
1057
|
+
if (!customerId)
|
|
1058
|
+
return { success: false, error: "customer_id required" };
|
|
1059
|
+
const updateData = {};
|
|
1060
|
+
if (args.email)
|
|
1061
|
+
updateData.email = args.email;
|
|
1062
|
+
if (args.phone)
|
|
1063
|
+
updateData.phone = args.phone;
|
|
1064
|
+
if (args.first_name)
|
|
1065
|
+
updateData.first_name = args.first_name;
|
|
1066
|
+
if (args.last_name)
|
|
1067
|
+
updateData.last_name = args.last_name;
|
|
1068
|
+
const { data, error } = await supabase
|
|
1069
|
+
.from("customers")
|
|
1070
|
+
.update(updateData)
|
|
1071
|
+
.eq("id", customerId)
|
|
1072
|
+
.select()
|
|
1073
|
+
.single();
|
|
1074
|
+
if (error)
|
|
1075
|
+
return { success: false, error: error.message };
|
|
1076
|
+
return { success: true, data };
|
|
1077
|
+
}
|
|
1078
|
+
default:
|
|
1079
|
+
return { success: false, error: `Unknown action: ${action}. Use: find, create, update` };
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
return { success: false, error: `Customers error: ${err}` };
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
// ===========================================================================
|
|
1087
|
+
// 6. PRODUCTS - Manage products
|
|
1088
|
+
// Actions: find, create, update, pricing_templates
|
|
1089
|
+
// ===========================================================================
|
|
1090
|
+
products: async (supabase, args, storeId) => {
|
|
1091
|
+
const action = args.action || "find";
|
|
1092
|
+
try {
|
|
1093
|
+
switch (action) {
|
|
1094
|
+
case "find": {
|
|
1095
|
+
const query = args.query || "";
|
|
1096
|
+
const limit = args.limit || 200;
|
|
1097
|
+
const statusFilter = args.status || "published";
|
|
1098
|
+
// Split query into individual words for smarter matching
|
|
1099
|
+
const words = query.trim().split(/\s+/).filter(Boolean);
|
|
1100
|
+
// Separate words that might be category names vs product names
|
|
1101
|
+
// Search categories first to find matching category IDs
|
|
1102
|
+
let categoryIds = [];
|
|
1103
|
+
if (words.length > 0) {
|
|
1104
|
+
let catQ = supabase.from("categories").select("id, name");
|
|
1105
|
+
if (storeId)
|
|
1106
|
+
catQ = catQ.eq("store_id", storeId);
|
|
1107
|
+
// Match any word against category names
|
|
1108
|
+
const catFilters = words.map(w => `name.ilike.%${w}%`).join(",");
|
|
1109
|
+
catQ = catQ.or(catFilters);
|
|
1110
|
+
const { data: matchingCats } = await catQ;
|
|
1111
|
+
categoryIds = matchingCats?.map((c) => c.id) || [];
|
|
1112
|
+
}
|
|
1113
|
+
// Build product query with category join
|
|
1114
|
+
let q = supabase
|
|
1115
|
+
.from("products")
|
|
1116
|
+
.select("id, name, sku, status, primary_category_id, category:categories!primary_category_id(name)")
|
|
1117
|
+
.limit(limit);
|
|
1118
|
+
if (storeId)
|
|
1119
|
+
q = q.eq("store_id", storeId);
|
|
1120
|
+
if (statusFilter !== "all")
|
|
1121
|
+
q = q.eq("status", statusFilter);
|
|
1122
|
+
if (words.length > 0) {
|
|
1123
|
+
// Build OR filter: match name words AND/OR category membership
|
|
1124
|
+
const nameFilters = words.map(w => `name.ilike.%${w}%`);
|
|
1125
|
+
const skuFilter = `sku.ilike.%${query}%`;
|
|
1126
|
+
const allFilters = [...nameFilters, skuFilter];
|
|
1127
|
+
if (categoryIds.length > 0) {
|
|
1128
|
+
allFilters.push(`primary_category_id.in.(${categoryIds.join(",")})`);
|
|
1129
|
+
}
|
|
1130
|
+
q = q.or(allFilters.join(","));
|
|
1131
|
+
}
|
|
1132
|
+
const { data, error } = await q;
|
|
1133
|
+
if (error)
|
|
1134
|
+
return { success: false, error: error.message };
|
|
1135
|
+
const scored = (data || []).map((p) => {
|
|
1136
|
+
let score = 0;
|
|
1137
|
+
const pName = (p.name || "").toLowerCase();
|
|
1138
|
+
const catName = (p.category?.name || "").toLowerCase();
|
|
1139
|
+
for (const w of words) {
|
|
1140
|
+
const wl = w.toLowerCase();
|
|
1141
|
+
if (pName.includes(wl))
|
|
1142
|
+
score += 2;
|
|
1143
|
+
if (catName.includes(wl))
|
|
1144
|
+
score += 1;
|
|
1145
|
+
}
|
|
1146
|
+
return { product: p, score };
|
|
1147
|
+
});
|
|
1148
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1149
|
+
// Enrich with inventory totals
|
|
1150
|
+
const productIds = scored.map(s => s.product.id);
|
|
1151
|
+
let inventoryMap = {};
|
|
1152
|
+
if (productIds.length > 0) {
|
|
1153
|
+
const { data: invData } = await supabase
|
|
1154
|
+
.from("inventory")
|
|
1155
|
+
.select("product_id, quantity")
|
|
1156
|
+
.in("product_id", productIds)
|
|
1157
|
+
.gt("quantity", 0);
|
|
1158
|
+
if (invData) {
|
|
1159
|
+
for (const inv of invData) {
|
|
1160
|
+
inventoryMap[inv.product_id] = (inventoryMap[inv.product_id] || 0) + inv.quantity;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const enriched = scored.map(s => ({
|
|
1165
|
+
id: s.product.id,
|
|
1166
|
+
name: s.product.name,
|
|
1167
|
+
sku: s.product.sku,
|
|
1168
|
+
status: s.product.status,
|
|
1169
|
+
category: s.product.category?.name || null,
|
|
1170
|
+
total_stock: inventoryMap[s.product.id] || 0
|
|
1171
|
+
}));
|
|
1172
|
+
return {
|
|
1173
|
+
success: true,
|
|
1174
|
+
data: enriched,
|
|
1175
|
+
total_count: enriched.length,
|
|
1176
|
+
limit,
|
|
1177
|
+
truncated: enriched.length >= limit
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
case "create": {
|
|
1181
|
+
const { data, error } = await supabase
|
|
1182
|
+
.from("products")
|
|
1183
|
+
.insert({
|
|
1184
|
+
name: args.name,
|
|
1185
|
+
sku: args.sku,
|
|
1186
|
+
store_id: storeId
|
|
1187
|
+
})
|
|
1188
|
+
.select()
|
|
1189
|
+
.single();
|
|
1190
|
+
if (error)
|
|
1191
|
+
return { success: false, error: error.message };
|
|
1192
|
+
return { success: true, data };
|
|
1193
|
+
}
|
|
1194
|
+
case "update": {
|
|
1195
|
+
const productId = args.product_id;
|
|
1196
|
+
if (!productId)
|
|
1197
|
+
return { success: false, error: "product_id required" };
|
|
1198
|
+
const updateData = {};
|
|
1199
|
+
if (args.name)
|
|
1200
|
+
updateData.name = args.name;
|
|
1201
|
+
if (args.sku)
|
|
1202
|
+
updateData.sku = args.sku;
|
|
1203
|
+
if (args.status)
|
|
1204
|
+
updateData.status = args.status;
|
|
1205
|
+
const { data, error } = await supabase
|
|
1206
|
+
.from("products")
|
|
1207
|
+
.update(updateData)
|
|
1208
|
+
.eq("id", productId)
|
|
1209
|
+
.select()
|
|
1210
|
+
.single();
|
|
1211
|
+
if (error)
|
|
1212
|
+
return { success: false, error: error.message };
|
|
1213
|
+
return { success: true, data };
|
|
1214
|
+
}
|
|
1215
|
+
case "pricing_templates": {
|
|
1216
|
+
// pricing_templates table may not exist - return empty array gracefully
|
|
1217
|
+
try {
|
|
1218
|
+
let q = supabase.from("pricing_templates").select("*");
|
|
1219
|
+
if (storeId)
|
|
1220
|
+
q = q.eq("store_id", storeId);
|
|
1221
|
+
const { data, error } = await q;
|
|
1222
|
+
if (error) {
|
|
1223
|
+
// Table doesn't exist - return empty array
|
|
1224
|
+
if (error.message.includes("does not exist") || error.code === "42P01" || error.message.includes("schema cache")) {
|
|
1225
|
+
return { success: true, data: [], message: "Pricing templates not configured" };
|
|
1226
|
+
}
|
|
1227
|
+
return { success: false, error: error.message };
|
|
1228
|
+
}
|
|
1229
|
+
return { success: true, data };
|
|
1230
|
+
}
|
|
1231
|
+
catch {
|
|
1232
|
+
return { success: true, data: [], message: "Pricing templates not configured" };
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
default:
|
|
1236
|
+
return { success: false, error: `Unknown action: ${action}. Use: find, create, update, pricing_templates` };
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
catch (err) {
|
|
1240
|
+
return { success: false, error: `Products error: ${err}` };
|
|
1241
|
+
}
|
|
1242
|
+
},
|
|
1243
|
+
// ===========================================================================
|
|
1244
|
+
// 7. ANALYTICS - Unified analytics & data discovery
|
|
1245
|
+
// Actions: summary, by_location, detailed, discover, employee, custom
|
|
1246
|
+
// Supports: preset periods OR custom date ranges (start_date, end_date)
|
|
1247
|
+
// Also supports: days_back for "last N days" queries
|
|
1248
|
+
// ===========================================================================
|
|
1249
|
+
analytics: async (supabase, args, storeId) => {
|
|
1250
|
+
const action = args.action || "summary";
|
|
1251
|
+
const period = args.period;
|
|
1252
|
+
const locationId = args.location_id;
|
|
1253
|
+
// Custom date range support
|
|
1254
|
+
const customStartDate = args.start_date;
|
|
1255
|
+
const customEndDate = args.end_date;
|
|
1256
|
+
const daysBack = args.days_back;
|
|
1257
|
+
try {
|
|
1258
|
+
// Calculate date range - prioritize custom dates, then days_back, then period
|
|
1259
|
+
const today = new Date();
|
|
1260
|
+
let startDate;
|
|
1261
|
+
let endDate = today.toISOString().split("T")[0];
|
|
1262
|
+
if (customStartDate) {
|
|
1263
|
+
// Custom date range provided
|
|
1264
|
+
startDate = customStartDate;
|
|
1265
|
+
if (customEndDate) {
|
|
1266
|
+
endDate = customEndDate;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
else if (daysBack && daysBack > 0) {
|
|
1270
|
+
// Dynamic days back (e.g., last 365 days, last 180 days)
|
|
1271
|
+
const start = new Date(today);
|
|
1272
|
+
start.setDate(start.getDate() - daysBack);
|
|
1273
|
+
startDate = start.toISOString().split("T")[0];
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
// Use preset period
|
|
1277
|
+
const periodToUse = period || "last_30";
|
|
1278
|
+
switch (periodToUse) {
|
|
1279
|
+
case "today":
|
|
1280
|
+
startDate = today.toISOString().split("T")[0];
|
|
1281
|
+
break;
|
|
1282
|
+
case "yesterday":
|
|
1283
|
+
const yesterday = new Date(today);
|
|
1284
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
1285
|
+
startDate = yesterday.toISOString().split("T")[0];
|
|
1286
|
+
endDate = startDate;
|
|
1287
|
+
break;
|
|
1288
|
+
case "last_7":
|
|
1289
|
+
const week = new Date(today);
|
|
1290
|
+
week.setDate(week.getDate() - 7);
|
|
1291
|
+
startDate = week.toISOString().split("T")[0];
|
|
1292
|
+
break;
|
|
1293
|
+
case "last_30":
|
|
1294
|
+
const month = new Date(today);
|
|
1295
|
+
month.setDate(month.getDate() - 30);
|
|
1296
|
+
startDate = month.toISOString().split("T")[0];
|
|
1297
|
+
break;
|
|
1298
|
+
case "last_90":
|
|
1299
|
+
const quarter = new Date(today);
|
|
1300
|
+
quarter.setDate(quarter.getDate() - 90);
|
|
1301
|
+
startDate = quarter.toISOString().split("T")[0];
|
|
1302
|
+
break;
|
|
1303
|
+
case "last_180":
|
|
1304
|
+
const half = new Date(today);
|
|
1305
|
+
half.setDate(half.getDate() - 180);
|
|
1306
|
+
startDate = half.toISOString().split("T")[0];
|
|
1307
|
+
break;
|
|
1308
|
+
case "last_365":
|
|
1309
|
+
const year = new Date(today);
|
|
1310
|
+
year.setDate(year.getDate() - 365);
|
|
1311
|
+
startDate = year.toISOString().split("T")[0];
|
|
1312
|
+
break;
|
|
1313
|
+
case "ytd":
|
|
1314
|
+
startDate = `${today.getFullYear()}-01-01`;
|
|
1315
|
+
break;
|
|
1316
|
+
case "mtd":
|
|
1317
|
+
startDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-01`;
|
|
1318
|
+
break;
|
|
1319
|
+
case "last_year":
|
|
1320
|
+
startDate = `${today.getFullYear() - 1}-01-01`;
|
|
1321
|
+
endDate = `${today.getFullYear() - 1}-12-31`;
|
|
1322
|
+
break;
|
|
1323
|
+
case "all_time":
|
|
1324
|
+
startDate = "2020-01-01"; // Reasonable start date
|
|
1325
|
+
break;
|
|
1326
|
+
default:
|
|
1327
|
+
// Default to last 30 days
|
|
1328
|
+
const defaultMonth = new Date(today);
|
|
1329
|
+
defaultMonth.setDate(defaultMonth.getDate() - 30);
|
|
1330
|
+
startDate = defaultMonth.toISOString().split("T")[0];
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
switch (action) {
|
|
1334
|
+
case "summary": {
|
|
1335
|
+
// Use RPC function for efficient server-side aggregation (no row limits)
|
|
1336
|
+
const { data: rpcResult, error: rpcError } = await supabase.rpc("get_sales_analytics", {
|
|
1337
|
+
p_store_id: storeId || null,
|
|
1338
|
+
p_start_date: startDate,
|
|
1339
|
+
p_end_date: endDate,
|
|
1340
|
+
p_location_id: locationId || null
|
|
1341
|
+
});
|
|
1342
|
+
if (rpcError) {
|
|
1343
|
+
// Fallback to client-side aggregation if RPC not deployed
|
|
1344
|
+
console.warn("[Analytics] RPC failed, falling back to client-side:", rpcError.message);
|
|
1345
|
+
}
|
|
1346
|
+
else if (rpcResult) {
|
|
1347
|
+
// RPC returned data - use it directly
|
|
1348
|
+
return {
|
|
1349
|
+
success: true,
|
|
1350
|
+
data: {
|
|
1351
|
+
period: period || (daysBack ? `last_${daysBack}` : "custom"),
|
|
1352
|
+
dateRange: rpcResult.dateRange,
|
|
1353
|
+
summary: {
|
|
1354
|
+
grossSales: rpcResult.grossSales,
|
|
1355
|
+
netSales: rpcResult.netSales,
|
|
1356
|
+
totalRevenue: rpcResult.totalRevenue,
|
|
1357
|
+
totalCogs: rpcResult.totalCogs,
|
|
1358
|
+
totalProfit: rpcResult.totalProfit,
|
|
1359
|
+
taxAmount: rpcResult.taxAmount,
|
|
1360
|
+
discountAmount: rpcResult.discountAmount,
|
|
1361
|
+
shippingAmount: rpcResult.shippingAmount,
|
|
1362
|
+
totalOrders: rpcResult.totalOrders,
|
|
1363
|
+
completedOrders: rpcResult.completedOrders,
|
|
1364
|
+
cancelledOrders: rpcResult.cancelledOrders,
|
|
1365
|
+
uniqueCustomers: rpcResult.uniqueCustomers,
|
|
1366
|
+
totalQuantity: rpcResult.totalQuantity,
|
|
1367
|
+
avgOrderValue: rpcResult.avgOrderValue,
|
|
1368
|
+
profitMargin: `${rpcResult.profitMargin}%`,
|
|
1369
|
+
avgDailyRevenue: rpcResult.avgDailyRevenue,
|
|
1370
|
+
avgDailyOrders: rpcResult.avgDailyOrders
|
|
1371
|
+
},
|
|
1372
|
+
rowCount: rpcResult.rowCount
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
// Fallback: client-side aggregation
|
|
1377
|
+
let q = supabase
|
|
1378
|
+
.from("v_daily_sales")
|
|
1379
|
+
.select("*")
|
|
1380
|
+
.gte("sale_date", startDate)
|
|
1381
|
+
.lte("sale_date", endDate)
|
|
1382
|
+
.order("sale_date", { ascending: false });
|
|
1383
|
+
if (storeId)
|
|
1384
|
+
q = q.eq("store_id", storeId);
|
|
1385
|
+
if (locationId)
|
|
1386
|
+
q = q.eq("location_id", locationId);
|
|
1387
|
+
const { data, error } = await q;
|
|
1388
|
+
if (error)
|
|
1389
|
+
return { success: false, error: error.message };
|
|
1390
|
+
const totals = (data || []).reduce((acc, day) => ({
|
|
1391
|
+
grossSales: acc.grossSales + parseFloat(day.gross_sales || 0),
|
|
1392
|
+
netSales: acc.netSales + parseFloat(day.net_sales || 0),
|
|
1393
|
+
taxAmount: acc.taxAmount + parseFloat(day.total_tax || 0),
|
|
1394
|
+
discountAmount: acc.discountAmount + parseFloat(day.total_discounts || 0),
|
|
1395
|
+
shippingAmount: acc.shippingAmount + parseFloat(day.total_shipping || 0),
|
|
1396
|
+
totalOrders: acc.totalOrders + parseInt(day.order_count || 0),
|
|
1397
|
+
completedOrders: acc.completedOrders + parseInt(day.completed_orders || 0),
|
|
1398
|
+
cancelledOrders: acc.cancelledOrders + parseInt(day.cancelled_orders || 0),
|
|
1399
|
+
uniqueCustomers: acc.uniqueCustomers + parseInt(day.unique_customers || 0),
|
|
1400
|
+
totalCogs: acc.totalCogs + parseFloat(day.total_cogs || 0),
|
|
1401
|
+
totalProfit: acc.totalProfit + parseFloat(day.total_profit || 0),
|
|
1402
|
+
totalRevenue: acc.totalRevenue + parseFloat(day.total_revenue || 0)
|
|
1403
|
+
}), {
|
|
1404
|
+
grossSales: 0, netSales: 0, taxAmount: 0, discountAmount: 0, shippingAmount: 0,
|
|
1405
|
+
totalOrders: 0, completedOrders: 0, cancelledOrders: 0, uniqueCustomers: 0,
|
|
1406
|
+
totalCogs: 0, totalProfit: 0, totalRevenue: 0
|
|
1407
|
+
});
|
|
1408
|
+
const profitMargin = totals.netSales > 0
|
|
1409
|
+
? Math.round((totals.totalProfit / totals.netSales) * 10000) / 100
|
|
1410
|
+
: 0;
|
|
1411
|
+
const startD = new Date(startDate);
|
|
1412
|
+
const endD = new Date(endDate);
|
|
1413
|
+
const daysDiff = Math.ceil((endD.getTime() - startD.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
1414
|
+
return {
|
|
1415
|
+
success: true,
|
|
1416
|
+
data: {
|
|
1417
|
+
period: period || (daysBack ? `last_${daysBack}` : "custom"),
|
|
1418
|
+
dateRange: { from: startDate, to: endDate, days: daysDiff },
|
|
1419
|
+
summary: {
|
|
1420
|
+
...totals,
|
|
1421
|
+
avgOrderValue: totals.totalOrders > 0 ? Math.round((totals.netSales / totals.totalOrders) * 100) / 100 : 0,
|
|
1422
|
+
profitMargin: `${profitMargin}%`,
|
|
1423
|
+
avgDailyRevenue: daysDiff > 0 ? Math.round((totals.netSales / daysDiff) * 100) / 100 : 0,
|
|
1424
|
+
avgDailyOrders: daysDiff > 0 ? Math.round((totals.totalOrders / daysDiff) * 10) / 10 : 0
|
|
1425
|
+
},
|
|
1426
|
+
rowCount: data?.length || 0
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
case "by_location":
|
|
1431
|
+
case "detailed": {
|
|
1432
|
+
let q = supabase
|
|
1433
|
+
.from("v_daily_sales")
|
|
1434
|
+
.select("*")
|
|
1435
|
+
.gte("sale_date", startDate)
|
|
1436
|
+
.lte("sale_date", endDate)
|
|
1437
|
+
.order("sale_date", { ascending: false });
|
|
1438
|
+
if (storeId)
|
|
1439
|
+
q = q.eq("store_id", storeId);
|
|
1440
|
+
if (locationId)
|
|
1441
|
+
q = q.eq("location_id", locationId);
|
|
1442
|
+
const { data, error } = await q;
|
|
1443
|
+
if (error)
|
|
1444
|
+
return { success: false, error: error.message };
|
|
1445
|
+
if (action === "by_location") {
|
|
1446
|
+
const byLocation = {};
|
|
1447
|
+
for (const row of data || []) {
|
|
1448
|
+
const loc = row.location_id || "unknown";
|
|
1449
|
+
if (!byLocation[loc])
|
|
1450
|
+
byLocation[loc] = { orders: 0, gross: 0, net: 0 };
|
|
1451
|
+
byLocation[loc].orders += parseInt(row.order_count || 0);
|
|
1452
|
+
byLocation[loc].gross += parseFloat(row.gross_sales || 0);
|
|
1453
|
+
byLocation[loc].net += parseFloat(row.net_sales || 0);
|
|
1454
|
+
}
|
|
1455
|
+
return { success: true, data: { period, byLocation: Object.entries(byLocation).map(([id, s]) => ({ locationId: id, ...s })) } };
|
|
1456
|
+
}
|
|
1457
|
+
// Detailed action - return daily data with totals
|
|
1458
|
+
const totals = (data || []).reduce((acc, day) => ({
|
|
1459
|
+
grossSales: acc.grossSales + parseFloat(day.gross_sales || 0),
|
|
1460
|
+
netSales: acc.netSales + parseFloat(day.net_sales || 0),
|
|
1461
|
+
taxAmount: acc.taxAmount + parseFloat(day.total_tax || 0),
|
|
1462
|
+
discountAmount: acc.discountAmount + parseFloat(day.total_discounts || 0),
|
|
1463
|
+
shippingAmount: acc.shippingAmount + parseFloat(day.total_shipping || 0),
|
|
1464
|
+
totalOrders: acc.totalOrders + parseInt(day.order_count || 0),
|
|
1465
|
+
completedOrders: acc.completedOrders + parseInt(day.completed_orders || 0),
|
|
1466
|
+
cancelledOrders: acc.cancelledOrders + parseInt(day.cancelled_orders || 0),
|
|
1467
|
+
uniqueCustomers: acc.uniqueCustomers + parseInt(day.unique_customers || 0),
|
|
1468
|
+
totalCogs: acc.totalCogs + parseFloat(day.total_cogs || 0),
|
|
1469
|
+
totalProfit: acc.totalProfit + parseFloat(day.total_profit || 0),
|
|
1470
|
+
totalRevenue: acc.totalRevenue + parseFloat(day.total_revenue || 0)
|
|
1471
|
+
}), {
|
|
1472
|
+
grossSales: 0, netSales: 0, taxAmount: 0, discountAmount: 0, shippingAmount: 0,
|
|
1473
|
+
totalOrders: 0, completedOrders: 0, cancelledOrders: 0, uniqueCustomers: 0,
|
|
1474
|
+
totalCogs: 0, totalProfit: 0, totalRevenue: 0
|
|
1475
|
+
});
|
|
1476
|
+
const profitMargin = totals.netSales > 0
|
|
1477
|
+
? Math.round((totals.totalProfit / totals.netSales) * 10000) / 100
|
|
1478
|
+
: 0;
|
|
1479
|
+
const startD = new Date(startDate);
|
|
1480
|
+
const endD = new Date(endDate);
|
|
1481
|
+
const daysDiff = Math.ceil((endD.getTime() - startD.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
1482
|
+
return {
|
|
1483
|
+
success: true,
|
|
1484
|
+
data: {
|
|
1485
|
+
period: period || (daysBack ? `last_${daysBack}` : "custom"),
|
|
1486
|
+
dateRange: { from: startDate, to: endDate, days: daysDiff },
|
|
1487
|
+
summary: {
|
|
1488
|
+
...totals,
|
|
1489
|
+
avgOrderValue: totals.totalOrders > 0 ? Math.round((totals.netSales / totals.totalOrders) * 100) / 100 : 0,
|
|
1490
|
+
profitMargin: `${profitMargin}%`,
|
|
1491
|
+
avgDailyRevenue: daysDiff > 0 ? Math.round((totals.netSales / daysDiff) * 100) / 100 : 0,
|
|
1492
|
+
avgDailyOrders: daysDiff > 0 ? Math.round((totals.totalOrders / daysDiff) * 10) / 10 : 0
|
|
1493
|
+
},
|
|
1494
|
+
daily: (data || []).slice(0, 90),
|
|
1495
|
+
rowCount: data?.length || 0
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
case "discover": {
|
|
1500
|
+
const tables = ["products", "orders", "customers", "inventory", "locations"];
|
|
1501
|
+
const result = {};
|
|
1502
|
+
for (const table of tables) {
|
|
1503
|
+
const { count } = await supabase.from(table).select("*", { count: "exact", head: true });
|
|
1504
|
+
result[table] = count || 0;
|
|
1505
|
+
}
|
|
1506
|
+
return { success: true, data: result };
|
|
1507
|
+
}
|
|
1508
|
+
case "employee": {
|
|
1509
|
+
const { data, error } = await supabase
|
|
1510
|
+
.from("orders")
|
|
1511
|
+
.select("employee_id, total_amount, created_at")
|
|
1512
|
+
.not("employee_id", "is", null);
|
|
1513
|
+
if (error)
|
|
1514
|
+
return { success: false, error: error.message };
|
|
1515
|
+
const byEmployee = {};
|
|
1516
|
+
for (const order of data || []) {
|
|
1517
|
+
const eid = order.employee_id;
|
|
1518
|
+
if (!byEmployee[eid])
|
|
1519
|
+
byEmployee[eid] = { count: 0, total: 0 };
|
|
1520
|
+
byEmployee[eid].count++;
|
|
1521
|
+
byEmployee[eid].total += order.total_amount || 0;
|
|
1522
|
+
}
|
|
1523
|
+
return { success: true, data: byEmployee };
|
|
1524
|
+
}
|
|
1525
|
+
// ==== NEW INTELLIGENCE ACTIONS ====
|
|
1526
|
+
case "customers":
|
|
1527
|
+
case "customer_intelligence": {
|
|
1528
|
+
// Customer LTV, RFM segmentation, cohorts
|
|
1529
|
+
const { data, error } = await supabase.rpc("get_customer_intelligence", {
|
|
1530
|
+
p_store_id: storeId || null
|
|
1531
|
+
});
|
|
1532
|
+
if (error)
|
|
1533
|
+
return { success: false, error: error.message };
|
|
1534
|
+
return { success: true, data };
|
|
1535
|
+
}
|
|
1536
|
+
case "products":
|
|
1537
|
+
case "product_intelligence": {
|
|
1538
|
+
// Product velocity, ABC analysis, performance tiers
|
|
1539
|
+
const { data, error } = await supabase.rpc("get_product_intelligence", {
|
|
1540
|
+
p_store_id: storeId || null
|
|
1541
|
+
});
|
|
1542
|
+
if (error)
|
|
1543
|
+
return { success: false, error: error.message };
|
|
1544
|
+
return { success: true, data };
|
|
1545
|
+
}
|
|
1546
|
+
case "inventory_intelligence": {
|
|
1547
|
+
// Inventory turnover, dead stock, overstock risk
|
|
1548
|
+
const { data, error } = await supabase.rpc("get_inventory_intelligence", {
|
|
1549
|
+
p_store_id: storeId || null
|
|
1550
|
+
});
|
|
1551
|
+
if (error)
|
|
1552
|
+
return { success: false, error: error.message };
|
|
1553
|
+
return { success: true, data };
|
|
1554
|
+
}
|
|
1555
|
+
case "marketing":
|
|
1556
|
+
case "marketing_intelligence": {
|
|
1557
|
+
// UTM attribution, channel performance, conversion rates
|
|
1558
|
+
const { data, error } = await supabase.rpc("get_marketing_intelligence", {
|
|
1559
|
+
p_store_id: storeId || null,
|
|
1560
|
+
p_start_date: startDate,
|
|
1561
|
+
p_end_date: endDate
|
|
1562
|
+
});
|
|
1563
|
+
if (error)
|
|
1564
|
+
return { success: false, error: error.message };
|
|
1565
|
+
return { success: true, data };
|
|
1566
|
+
}
|
|
1567
|
+
case "fraud":
|
|
1568
|
+
case "fraud_detection": {
|
|
1569
|
+
// Risk scores, suspicious activity patterns
|
|
1570
|
+
let q = supabase
|
|
1571
|
+
.from("v_fraud_detection")
|
|
1572
|
+
.select("*")
|
|
1573
|
+
.order("risk_score", { ascending: false })
|
|
1574
|
+
.limit(100);
|
|
1575
|
+
if (storeId)
|
|
1576
|
+
q = q.eq("store_id", storeId);
|
|
1577
|
+
if (args.risk_level)
|
|
1578
|
+
q = q.eq("risk_level", args.risk_level);
|
|
1579
|
+
const { data, error } = await q;
|
|
1580
|
+
if (error)
|
|
1581
|
+
return { success: false, error: error.message };
|
|
1582
|
+
const summary = {
|
|
1583
|
+
totalOrders: data?.length || 0,
|
|
1584
|
+
highRisk: data?.filter(o => o.risk_level === "high").length || 0,
|
|
1585
|
+
mediumRisk: data?.filter(o => o.risk_level === "medium").length || 0,
|
|
1586
|
+
avgRiskScore: data?.length ? Math.round(data.reduce((s, o) => s + o.risk_score, 0) / data.length) : 0,
|
|
1587
|
+
orders: data
|
|
1588
|
+
};
|
|
1589
|
+
return { success: true, data: summary };
|
|
1590
|
+
}
|
|
1591
|
+
case "employee_performance": {
|
|
1592
|
+
// Detailed employee metrics from v_employee_performance
|
|
1593
|
+
let q = supabase
|
|
1594
|
+
.from("v_employee_performance")
|
|
1595
|
+
.select("*")
|
|
1596
|
+
.order("total_revenue", { ascending: false });
|
|
1597
|
+
if (storeId)
|
|
1598
|
+
q = q.eq("store_id", storeId);
|
|
1599
|
+
const { data, error } = await q;
|
|
1600
|
+
if (error)
|
|
1601
|
+
return { success: false, error: error.message };
|
|
1602
|
+
return { success: true, data };
|
|
1603
|
+
}
|
|
1604
|
+
case "behavior":
|
|
1605
|
+
case "behavioral_analytics": {
|
|
1606
|
+
// UX metrics, session quality, bounce rates
|
|
1607
|
+
let q = supabase
|
|
1608
|
+
.from("v_behavioral_analytics")
|
|
1609
|
+
.select("*")
|
|
1610
|
+
.gte("visit_date", startDate)
|
|
1611
|
+
.lte("visit_date", endDate);
|
|
1612
|
+
if (storeId)
|
|
1613
|
+
q = q.eq("store_id", storeId);
|
|
1614
|
+
const { data, error } = await q;
|
|
1615
|
+
if (error)
|
|
1616
|
+
return { success: false, error: error.message };
|
|
1617
|
+
// Aggregate
|
|
1618
|
+
const totals = (data || []).reduce((acc, row) => ({
|
|
1619
|
+
sessions: acc.sessions + (row.sessions || 0),
|
|
1620
|
+
pageViews: acc.pageViews + (row.page_views || 0),
|
|
1621
|
+
rageClicks: acc.rageClicks + (row.total_rage_clicks || 0),
|
|
1622
|
+
uxIssues: acc.uxIssues + (row.sessions_with_ux_issues || 0)
|
|
1623
|
+
}), { sessions: 0, pageViews: 0, rageClicks: 0, uxIssues: 0 });
|
|
1624
|
+
return {
|
|
1625
|
+
success: true,
|
|
1626
|
+
data: {
|
|
1627
|
+
summary: {
|
|
1628
|
+
...totals,
|
|
1629
|
+
avgPagesPerSession: totals.sessions ? Math.round((totals.pageViews / totals.sessions) * 100) / 100 : 0,
|
|
1630
|
+
uxIssueRate: totals.sessions ? Math.round((totals.uxIssues / totals.sessions) * 10000) / 100 : 0
|
|
1631
|
+
},
|
|
1632
|
+
byDevice: data?.reduce((acc, row) => {
|
|
1633
|
+
const device = row.device_type || "unknown";
|
|
1634
|
+
if (!acc[device])
|
|
1635
|
+
acc[device] = { sessions: 0, pageViews: 0 };
|
|
1636
|
+
acc[device].sessions += row.sessions || 0;
|
|
1637
|
+
acc[device].pageViews += row.page_views || 0;
|
|
1638
|
+
return acc;
|
|
1639
|
+
}, {})
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
case "full":
|
|
1644
|
+
case "business_intelligence": {
|
|
1645
|
+
// Master intelligence - all metrics in one call
|
|
1646
|
+
const { data, error } = await supabase.rpc("get_business_intelligence", {
|
|
1647
|
+
p_store_id: storeId || null,
|
|
1648
|
+
p_start_date: startDate,
|
|
1649
|
+
p_end_date: endDate
|
|
1650
|
+
});
|
|
1651
|
+
if (error)
|
|
1652
|
+
return { success: false, error: error.message };
|
|
1653
|
+
return { success: true, data };
|
|
1654
|
+
}
|
|
1655
|
+
default:
|
|
1656
|
+
return { success: false, error: `Unknown action: ${action}. Use: summary, by_location, detailed, discover, employee, customers, products, inventory_intelligence, marketing, fraud, employee_performance, behavior, full` };
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
catch (err) {
|
|
1660
|
+
return { success: false, error: `Analytics error: ${err}` };
|
|
1661
|
+
}
|
|
1662
|
+
},
|
|
1663
|
+
// ===========================================================================
|
|
1664
|
+
// 8. LOCATIONS - Find/list locations
|
|
1665
|
+
// ===========================================================================
|
|
1666
|
+
locations: async (supabase, args, storeId) => {
|
|
1667
|
+
try {
|
|
1668
|
+
let q = supabase.from("locations").select("id, name, address_line1, city, state, is_active");
|
|
1669
|
+
if (storeId)
|
|
1670
|
+
q = q.eq("store_id", storeId);
|
|
1671
|
+
if (args.name)
|
|
1672
|
+
q = q.ilike("name", `%${args.name}%`);
|
|
1673
|
+
if (args.is_active !== undefined)
|
|
1674
|
+
q = q.eq("is_active", args.is_active);
|
|
1675
|
+
const { data, error } = await q;
|
|
1676
|
+
if (error)
|
|
1677
|
+
return { success: false, error: error.message };
|
|
1678
|
+
return { success: true, data };
|
|
1679
|
+
}
|
|
1680
|
+
catch (err) {
|
|
1681
|
+
return { success: false, error: `Locations error: ${err}` };
|
|
1682
|
+
}
|
|
1683
|
+
},
|
|
1684
|
+
// ===========================================================================
|
|
1685
|
+
// 9. ORDERS - Manage orders
|
|
1686
|
+
// Actions: find, get, create
|
|
1687
|
+
// ===========================================================================
|
|
1688
|
+
orders: async (supabase, args, storeId) => {
|
|
1689
|
+
const action = args.action || "find";
|
|
1690
|
+
try {
|
|
1691
|
+
switch (action) {
|
|
1692
|
+
case "find": {
|
|
1693
|
+
let q = supabase
|
|
1694
|
+
.from("orders")
|
|
1695
|
+
.select("id, order_number, status, total_amount, customer_id, created_at")
|
|
1696
|
+
.order("created_at", { ascending: false })
|
|
1697
|
+
.limit(args.limit || 50);
|
|
1698
|
+
if (storeId)
|
|
1699
|
+
q = q.eq("store_id", storeId);
|
|
1700
|
+
if (args.status)
|
|
1701
|
+
q = q.eq("status", args.status);
|
|
1702
|
+
if (args.customer_id)
|
|
1703
|
+
q = q.eq("customer_id", args.customer_id);
|
|
1704
|
+
const { data, error } = await q;
|
|
1705
|
+
if (error)
|
|
1706
|
+
return { success: false, error: error.message };
|
|
1707
|
+
return { success: true, data };
|
|
1708
|
+
}
|
|
1709
|
+
case "get": {
|
|
1710
|
+
const orderId = args.order_id;
|
|
1711
|
+
if (!orderId)
|
|
1712
|
+
return { success: false, error: "order_id required" };
|
|
1713
|
+
const { data, error } = await supabase
|
|
1714
|
+
.from("orders")
|
|
1715
|
+
.select("*, order_items(*)")
|
|
1716
|
+
.eq("id", orderId)
|
|
1717
|
+
.single();
|
|
1718
|
+
if (error)
|
|
1719
|
+
return { success: false, error: error.message };
|
|
1720
|
+
return { success: true, data };
|
|
1721
|
+
}
|
|
1722
|
+
case "purchase_orders": {
|
|
1723
|
+
let q = supabase.from("purchase_orders").select("*");
|
|
1724
|
+
if (storeId)
|
|
1725
|
+
q = q.eq("store_id", storeId);
|
|
1726
|
+
const { data, error } = await q.limit(50);
|
|
1727
|
+
if (error)
|
|
1728
|
+
return { success: false, error: error.message };
|
|
1729
|
+
return { success: true, data };
|
|
1730
|
+
}
|
|
1731
|
+
default:
|
|
1732
|
+
return { success: false, error: `Unknown action: ${action}. Use: find, get, purchase_orders` };
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
catch (err) {
|
|
1736
|
+
return { success: false, error: `Orders error: ${err}` };
|
|
1737
|
+
}
|
|
1738
|
+
},
|
|
1739
|
+
// ===========================================================================
|
|
1740
|
+
// 10. SUPPLIERS - Find suppliers
|
|
1741
|
+
// ===========================================================================
|
|
1742
|
+
suppliers: async (supabase, args, storeId) => {
|
|
1743
|
+
try {
|
|
1744
|
+
let q = supabase.from("suppliers").select("*");
|
|
1745
|
+
if (storeId)
|
|
1746
|
+
q = q.eq("store_id", storeId);
|
|
1747
|
+
if (args.name)
|
|
1748
|
+
q = q.ilike("name", `%${args.name}%`);
|
|
1749
|
+
const { data, error } = await q.limit(50);
|
|
1750
|
+
if (error)
|
|
1751
|
+
return { success: false, error: error.message };
|
|
1752
|
+
return { success: true, data };
|
|
1753
|
+
}
|
|
1754
|
+
catch (err) {
|
|
1755
|
+
return { success: false, error: `Suppliers error: ${err}` };
|
|
1756
|
+
}
|
|
1757
|
+
},
|
|
1758
|
+
// ===========================================================================
|
|
1759
|
+
// 11. EMAIL - Unified email tool with inbox support
|
|
1760
|
+
// Actions: send, send_template, list, get, templates,
|
|
1761
|
+
// inbox, inbox_get, inbox_reply, inbox_update, inbox_stats
|
|
1762
|
+
// ===========================================================================
|
|
1763
|
+
email: async (supabase, args, storeId) => {
|
|
1764
|
+
const action = args.action;
|
|
1765
|
+
if (!action) {
|
|
1766
|
+
return { success: false, error: "action required: send, send_template, list, get, templates, inbox, inbox_get, inbox_reply, inbox_update, inbox_stats" };
|
|
1767
|
+
}
|
|
1768
|
+
const SUPABASE_URL = process.env.SUPABASE_URL || "https://uaednwpxursknmwdeejn.supabase.co";
|
|
1769
|
+
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
1770
|
+
const invokeEdgeFunction = async (functionName, body) => {
|
|
1771
|
+
const res = await fetch(`${SUPABASE_URL}/functions/v1/${functionName}`, {
|
|
1772
|
+
method: "POST",
|
|
1773
|
+
headers: {
|
|
1774
|
+
"Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
|
|
1775
|
+
"Content-Type": "application/json"
|
|
1776
|
+
},
|
|
1777
|
+
body: JSON.stringify(body)
|
|
1778
|
+
});
|
|
1779
|
+
const data = await res.json();
|
|
1780
|
+
if (!res.ok)
|
|
1781
|
+
throw new Error(data.error || data.message || `HTTP ${res.status}`);
|
|
1782
|
+
return data;
|
|
1783
|
+
};
|
|
1784
|
+
try {
|
|
1785
|
+
switch (action) {
|
|
1786
|
+
case "send": {
|
|
1787
|
+
const to = args.to;
|
|
1788
|
+
const subject = args.subject;
|
|
1789
|
+
const html = args.html;
|
|
1790
|
+
const text = args.text;
|
|
1791
|
+
if (!to || !subject || (!html && !text)) {
|
|
1792
|
+
return { success: false, error: "send requires: to, subject, and html or text" };
|
|
1793
|
+
}
|
|
1794
|
+
const result = await invokeEdgeFunction("send-email", { to, subject, html, text, storeId });
|
|
1795
|
+
return { success: true, data: result };
|
|
1796
|
+
}
|
|
1797
|
+
case "send_template": {
|
|
1798
|
+
const to = args.to;
|
|
1799
|
+
const template = args.template;
|
|
1800
|
+
const templateData = args.template_data;
|
|
1801
|
+
if (!to || !template) {
|
|
1802
|
+
return { success: false, error: "send_template requires: to, template" };
|
|
1803
|
+
}
|
|
1804
|
+
const result = await invokeEdgeFunction("send-email", { to, template, template_data: templateData, storeId });
|
|
1805
|
+
return { success: true, data: result };
|
|
1806
|
+
}
|
|
1807
|
+
case "list": {
|
|
1808
|
+
let query = supabase
|
|
1809
|
+
.from("email_sends")
|
|
1810
|
+
.select("id, to_email, subject, status, category, created_at")
|
|
1811
|
+
.order("created_at", { ascending: false })
|
|
1812
|
+
.limit(args.limit || 50);
|
|
1813
|
+
if (storeId)
|
|
1814
|
+
query = query.eq("store_id", storeId);
|
|
1815
|
+
const { data, error } = await query;
|
|
1816
|
+
if (error)
|
|
1817
|
+
return { success: false, error: error.message };
|
|
1818
|
+
return { success: true, data };
|
|
1819
|
+
}
|
|
1820
|
+
case "get": {
|
|
1821
|
+
const emailId = args.email_id;
|
|
1822
|
+
if (!emailId)
|
|
1823
|
+
return { success: false, error: "get requires email_id" };
|
|
1824
|
+
const { data, error } = await supabase.from("email_sends").select("*").eq("id", emailId).single();
|
|
1825
|
+
if (error)
|
|
1826
|
+
return { success: false, error: error.message };
|
|
1827
|
+
return { success: true, data };
|
|
1828
|
+
}
|
|
1829
|
+
case "templates": {
|
|
1830
|
+
const { data, error } = await supabase
|
|
1831
|
+
.from("email_templates")
|
|
1832
|
+
.select("id, name, slug, subject, description, category, is_active")
|
|
1833
|
+
.eq("is_active", true);
|
|
1834
|
+
if (error)
|
|
1835
|
+
return { success: false, error: error.message };
|
|
1836
|
+
return { success: true, data };
|
|
1837
|
+
}
|
|
1838
|
+
// =====================================================================
|
|
1839
|
+
// INBOX ACTIONS - AI-powered inbound email management
|
|
1840
|
+
// =====================================================================
|
|
1841
|
+
case "inbox": {
|
|
1842
|
+
// List threads with optional filters
|
|
1843
|
+
const mailbox = args.mailbox;
|
|
1844
|
+
const status = args.status;
|
|
1845
|
+
const priority = args.priority;
|
|
1846
|
+
const limit = args.limit || 25;
|
|
1847
|
+
let query = supabase
|
|
1848
|
+
.from("email_threads")
|
|
1849
|
+
.select(`
|
|
1850
|
+
id, subject, mailbox, status, priority, intent, ai_summary,
|
|
1851
|
+
message_count, unread_count, last_message_at, created_at,
|
|
1852
|
+
customer:customers(id, first_name, last_name, email),
|
|
1853
|
+
order:orders(id, order_number, status)
|
|
1854
|
+
`)
|
|
1855
|
+
.order("last_message_at", { ascending: false })
|
|
1856
|
+
.limit(limit);
|
|
1857
|
+
if (storeId)
|
|
1858
|
+
query = query.eq("store_id", storeId);
|
|
1859
|
+
if (mailbox)
|
|
1860
|
+
query = query.eq("mailbox", mailbox);
|
|
1861
|
+
if (status)
|
|
1862
|
+
query = query.eq("status", status);
|
|
1863
|
+
if (priority)
|
|
1864
|
+
query = query.eq("priority", priority);
|
|
1865
|
+
const { data, error } = await query;
|
|
1866
|
+
if (error)
|
|
1867
|
+
return { success: false, error: error.message };
|
|
1868
|
+
return { success: true, data };
|
|
1869
|
+
}
|
|
1870
|
+
case "inbox_get": {
|
|
1871
|
+
// Get full thread with all messages and context
|
|
1872
|
+
const threadId = args.thread_id;
|
|
1873
|
+
if (!threadId)
|
|
1874
|
+
return { success: false, error: "inbox_get requires thread_id" };
|
|
1875
|
+
// Fetch thread
|
|
1876
|
+
const { data: thread, error: threadError } = await supabase
|
|
1877
|
+
.from("email_threads")
|
|
1878
|
+
.select(`
|
|
1879
|
+
*,
|
|
1880
|
+
customer:customers(id, first_name, last_name, email, phone),
|
|
1881
|
+
order:orders(id, order_number, status, total, created_at)
|
|
1882
|
+
`)
|
|
1883
|
+
.eq("id", threadId)
|
|
1884
|
+
.single();
|
|
1885
|
+
if (threadError)
|
|
1886
|
+
return { success: false, error: threadError.message };
|
|
1887
|
+
// Fetch all messages in thread
|
|
1888
|
+
const { data: messages, error: msgError } = await supabase
|
|
1889
|
+
.from("email_inbox")
|
|
1890
|
+
.select("id, direction, from_email, from_name, to_email, subject, body_text, body_html, status, ai_draft, ai_intent, ai_confidence, has_attachments, created_at, read_at, replied_at")
|
|
1891
|
+
.eq("thread_id", threadId)
|
|
1892
|
+
.order("created_at", { ascending: true });
|
|
1893
|
+
if (msgError)
|
|
1894
|
+
return { success: false, error: msgError.message };
|
|
1895
|
+
// Mark unread messages as read
|
|
1896
|
+
const unreadIds = (messages || [])
|
|
1897
|
+
.filter((m) => m.direction === "inbound" && !m.read_at)
|
|
1898
|
+
.map((m) => m.id);
|
|
1899
|
+
if (unreadIds.length > 0) {
|
|
1900
|
+
await supabase
|
|
1901
|
+
.from("email_inbox")
|
|
1902
|
+
.update({ status: "read", read_at: new Date().toISOString() })
|
|
1903
|
+
.in("id", unreadIds);
|
|
1904
|
+
await supabase
|
|
1905
|
+
.from("email_threads")
|
|
1906
|
+
.update({ unread_count: 0 })
|
|
1907
|
+
.eq("id", threadId);
|
|
1908
|
+
}
|
|
1909
|
+
return { success: true, data: { thread, messages } };
|
|
1910
|
+
}
|
|
1911
|
+
case "inbox_reply": {
|
|
1912
|
+
// Reply to a thread
|
|
1913
|
+
const threadId = args.thread_id;
|
|
1914
|
+
const html = args.html;
|
|
1915
|
+
const text = args.text;
|
|
1916
|
+
if (!threadId)
|
|
1917
|
+
return { success: false, error: "inbox_reply requires thread_id" };
|
|
1918
|
+
if (!html && !text)
|
|
1919
|
+
return { success: false, error: "inbox_reply requires html or text body" };
|
|
1920
|
+
// Get thread + last inbound message for threading
|
|
1921
|
+
const { data: thread } = await supabase
|
|
1922
|
+
.from("email_threads")
|
|
1923
|
+
.select("id, subject, customer:customers(email)")
|
|
1924
|
+
.eq("id", threadId)
|
|
1925
|
+
.single();
|
|
1926
|
+
if (!thread)
|
|
1927
|
+
return { success: false, error: "Thread not found" };
|
|
1928
|
+
const { data: lastInbound } = await supabase
|
|
1929
|
+
.from("email_inbox")
|
|
1930
|
+
.select("from_email, message_id, references")
|
|
1931
|
+
.eq("thread_id", threadId)
|
|
1932
|
+
.eq("direction", "inbound")
|
|
1933
|
+
.order("created_at", { ascending: false })
|
|
1934
|
+
.limit(1)
|
|
1935
|
+
.single();
|
|
1936
|
+
if (!lastInbound)
|
|
1937
|
+
return { success: false, error: "No inbound message to reply to" };
|
|
1938
|
+
// Build threading references
|
|
1939
|
+
const refs = lastInbound.references || [];
|
|
1940
|
+
if (lastInbound.message_id && !refs.includes(lastInbound.message_id)) {
|
|
1941
|
+
refs.push(lastInbound.message_id);
|
|
1942
|
+
}
|
|
1943
|
+
const result = await invokeEdgeFunction("send-email", {
|
|
1944
|
+
to: lastInbound.from_email,
|
|
1945
|
+
subject: `Re: ${thread.subject || ""}`,
|
|
1946
|
+
html,
|
|
1947
|
+
text,
|
|
1948
|
+
storeId,
|
|
1949
|
+
thread_id: threadId,
|
|
1950
|
+
in_reply_to: lastInbound.message_id,
|
|
1951
|
+
references: refs,
|
|
1952
|
+
});
|
|
1953
|
+
return { success: true, data: result };
|
|
1954
|
+
}
|
|
1955
|
+
case "inbox_update": {
|
|
1956
|
+
// Update thread status, priority, intent, or summary
|
|
1957
|
+
const threadId = args.thread_id;
|
|
1958
|
+
if (!threadId)
|
|
1959
|
+
return { success: false, error: "inbox_update requires thread_id" };
|
|
1960
|
+
const updates = { updated_at: new Date().toISOString() };
|
|
1961
|
+
if (args.status)
|
|
1962
|
+
updates.status = args.status;
|
|
1963
|
+
if (args.priority)
|
|
1964
|
+
updates.priority = args.priority;
|
|
1965
|
+
if (args.intent)
|
|
1966
|
+
updates.intent = args.intent;
|
|
1967
|
+
if (args.ai_summary)
|
|
1968
|
+
updates.ai_summary = args.ai_summary;
|
|
1969
|
+
const { data, error } = await supabase
|
|
1970
|
+
.from("email_threads")
|
|
1971
|
+
.update(updates)
|
|
1972
|
+
.eq("id", threadId)
|
|
1973
|
+
.select("id, status, priority, intent, ai_summary")
|
|
1974
|
+
.single();
|
|
1975
|
+
if (error)
|
|
1976
|
+
return { success: false, error: error.message };
|
|
1977
|
+
return { success: true, data };
|
|
1978
|
+
}
|
|
1979
|
+
case "inbox_stats": {
|
|
1980
|
+
// Inbox statistics
|
|
1981
|
+
let query = supabase
|
|
1982
|
+
.from("email_threads")
|
|
1983
|
+
.select("mailbox, status, priority", { count: "exact" });
|
|
1984
|
+
if (storeId)
|
|
1985
|
+
query = query.eq("store_id", storeId);
|
|
1986
|
+
const { data: threads, error } = await query;
|
|
1987
|
+
if (error)
|
|
1988
|
+
return { success: false, error: error.message };
|
|
1989
|
+
// Aggregate counts
|
|
1990
|
+
const stats = {
|
|
1991
|
+
total: (threads || []).length,
|
|
1992
|
+
by_status: {},
|
|
1993
|
+
by_mailbox: {},
|
|
1994
|
+
by_priority: {},
|
|
1995
|
+
};
|
|
1996
|
+
for (const t of (threads || [])) {
|
|
1997
|
+
stats.by_status[t.status] = (stats.by_status[t.status] || 0) + 1;
|
|
1998
|
+
stats.by_mailbox[t.mailbox] = (stats.by_mailbox[t.mailbox] || 0) + 1;
|
|
1999
|
+
stats.by_priority[t.priority] = (stats.by_priority[t.priority] || 0) + 1;
|
|
2000
|
+
}
|
|
2001
|
+
return { success: true, data: stats };
|
|
2002
|
+
}
|
|
2003
|
+
// =====================================================================
|
|
2004
|
+
// DOMAIN MANAGEMENT - Multi-tenant email domain configuration
|
|
2005
|
+
// =====================================================================
|
|
2006
|
+
case "domains_list": {
|
|
2007
|
+
// List email domains for the store
|
|
2008
|
+
let query = supabase
|
|
2009
|
+
.from("store_email_domains")
|
|
2010
|
+
.select("id, domain, inbound_subdomain, status, receiving_enabled, sending_verified, dns_records, created_at");
|
|
2011
|
+
if (storeId)
|
|
2012
|
+
query = query.eq("store_id", storeId);
|
|
2013
|
+
const { data, error } = await query;
|
|
2014
|
+
if (error)
|
|
2015
|
+
return { success: false, error: error.message };
|
|
2016
|
+
return { success: true, data };
|
|
2017
|
+
}
|
|
2018
|
+
case "domains_add": {
|
|
2019
|
+
// Add a new email domain for the store
|
|
2020
|
+
const domain = args.domain;
|
|
2021
|
+
const inboundSubdomain = args.inbound_subdomain || "in";
|
|
2022
|
+
if (!domain)
|
|
2023
|
+
return { success: false, error: "domain required" };
|
|
2024
|
+
if (!storeId)
|
|
2025
|
+
return { success: false, error: "store_id required" };
|
|
2026
|
+
// Call Resend API to add domain
|
|
2027
|
+
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
|
2028
|
+
if (!RESEND_API_KEY)
|
|
2029
|
+
return { success: false, error: "RESEND_API_KEY not configured" };
|
|
2030
|
+
const fullDomain = `${inboundSubdomain}.${domain}`;
|
|
2031
|
+
const resendRes = await fetch("https://api.resend.com/domains", {
|
|
2032
|
+
method: "POST",
|
|
2033
|
+
headers: {
|
|
2034
|
+
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
|
2035
|
+
"Content-Type": "application/json"
|
|
2036
|
+
},
|
|
2037
|
+
body: JSON.stringify({ name: fullDomain, region: "us-east-1" })
|
|
2038
|
+
});
|
|
2039
|
+
const resendData = await resendRes.json();
|
|
2040
|
+
if (!resendRes.ok) {
|
|
2041
|
+
return { success: false, error: `Resend API error: ${resendData.message || resendRes.status}` };
|
|
2042
|
+
}
|
|
2043
|
+
// Enable receiving
|
|
2044
|
+
await fetch(`https://api.resend.com/domains/${resendData.id}`, {
|
|
2045
|
+
method: "PATCH",
|
|
2046
|
+
headers: {
|
|
2047
|
+
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
|
2048
|
+
"Content-Type": "application/json"
|
|
2049
|
+
},
|
|
2050
|
+
body: JSON.stringify({ capabilities: { receiving: "enabled" } })
|
|
2051
|
+
});
|
|
2052
|
+
// Format DNS records for display
|
|
2053
|
+
const dnsRecords = (resendData.records || []).map((r) => ({
|
|
2054
|
+
record: r.record,
|
|
2055
|
+
name: r.name,
|
|
2056
|
+
type: r.type,
|
|
2057
|
+
value: r.value,
|
|
2058
|
+
priority: r.priority,
|
|
2059
|
+
status: r.status
|
|
2060
|
+
}));
|
|
2061
|
+
// Add receiving MX record to the list
|
|
2062
|
+
dnsRecords.push({
|
|
2063
|
+
record: "Receiving",
|
|
2064
|
+
name: inboundSubdomain,
|
|
2065
|
+
type: "MX",
|
|
2066
|
+
value: "inbound-smtp.us-east-1.amazonaws.com",
|
|
2067
|
+
priority: 10,
|
|
2068
|
+
status: "pending"
|
|
2069
|
+
});
|
|
2070
|
+
// Insert into database
|
|
2071
|
+
const { data, error } = await supabase
|
|
2072
|
+
.from("store_email_domains")
|
|
2073
|
+
.insert({
|
|
2074
|
+
store_id: storeId,
|
|
2075
|
+
domain,
|
|
2076
|
+
inbound_subdomain: inboundSubdomain,
|
|
2077
|
+
resend_domain_id: resendData.id,
|
|
2078
|
+
status: "pending",
|
|
2079
|
+
receiving_enabled: true,
|
|
2080
|
+
dns_records: dnsRecords
|
|
2081
|
+
})
|
|
2082
|
+
.select()
|
|
2083
|
+
.single();
|
|
2084
|
+
if (error)
|
|
2085
|
+
return { success: false, error: error.message };
|
|
2086
|
+
return {
|
|
2087
|
+
success: true,
|
|
2088
|
+
data: {
|
|
2089
|
+
...data,
|
|
2090
|
+
message: "Domain added. Please add the following DNS records to your domain registrar:",
|
|
2091
|
+
dns_records: dnsRecords
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
case "domains_verify": {
|
|
2096
|
+
// Trigger verification for a domain
|
|
2097
|
+
const domainId = args.domain_id;
|
|
2098
|
+
if (!domainId)
|
|
2099
|
+
return { success: false, error: "domain_id required" };
|
|
2100
|
+
// Get domain record
|
|
2101
|
+
const { data: domainRecord, error: fetchError } = await supabase
|
|
2102
|
+
.from("store_email_domains")
|
|
2103
|
+
.select("*")
|
|
2104
|
+
.eq("id", domainId)
|
|
2105
|
+
.single();
|
|
2106
|
+
if (fetchError || !domainRecord) {
|
|
2107
|
+
return { success: false, error: "Domain not found" };
|
|
2108
|
+
}
|
|
2109
|
+
// Call Resend verify API
|
|
2110
|
+
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
|
2111
|
+
if (!RESEND_API_KEY)
|
|
2112
|
+
return { success: false, error: "RESEND_API_KEY not configured" };
|
|
2113
|
+
await fetch(`https://api.resend.com/domains/${domainRecord.resend_domain_id}/verify`, {
|
|
2114
|
+
method: "POST",
|
|
2115
|
+
headers: { "Authorization": `Bearer ${RESEND_API_KEY}` }
|
|
2116
|
+
});
|
|
2117
|
+
// Fetch updated status
|
|
2118
|
+
const statusRes = await fetch(`https://api.resend.com/domains/${domainRecord.resend_domain_id}`, {
|
|
2119
|
+
headers: { "Authorization": `Bearer ${RESEND_API_KEY}` }
|
|
2120
|
+
});
|
|
2121
|
+
const statusData = await statusRes.json();
|
|
2122
|
+
// Update database
|
|
2123
|
+
const dnsRecords = (statusData.records || []).map((r) => ({
|
|
2124
|
+
record: r.record,
|
|
2125
|
+
name: r.name,
|
|
2126
|
+
type: r.type,
|
|
2127
|
+
value: r.value,
|
|
2128
|
+
priority: r.priority,
|
|
2129
|
+
status: r.status
|
|
2130
|
+
}));
|
|
2131
|
+
const receivingVerified = dnsRecords.some((r) => r.record === "Receiving" && r.status === "verified");
|
|
2132
|
+
const sendingVerified = dnsRecords.filter((r) => r.record !== "Receiving").every((r) => r.status === "verified");
|
|
2133
|
+
const { data, error } = await supabase
|
|
2134
|
+
.from("store_email_domains")
|
|
2135
|
+
.update({
|
|
2136
|
+
status: statusData.status === "verified" || receivingVerified ? "verified" : "pending",
|
|
2137
|
+
sending_verified: sendingVerified,
|
|
2138
|
+
dns_records: dnsRecords,
|
|
2139
|
+
verified_at: receivingVerified ? new Date().toISOString() : null
|
|
2140
|
+
})
|
|
2141
|
+
.eq("id", domainId)
|
|
2142
|
+
.select()
|
|
2143
|
+
.single();
|
|
2144
|
+
if (error)
|
|
2145
|
+
return { success: false, error: error.message };
|
|
2146
|
+
return { success: true, data };
|
|
2147
|
+
}
|
|
2148
|
+
case "addresses_list": {
|
|
2149
|
+
// List email addresses for the store
|
|
2150
|
+
let query = supabase
|
|
2151
|
+
.from("store_email_addresses")
|
|
2152
|
+
.select(`
|
|
2153
|
+
id, address, display_name, mailbox_type, ai_enabled, ai_auto_reply, is_active, created_at,
|
|
2154
|
+
domain:store_email_domains(id, domain, inbound_subdomain, status)
|
|
2155
|
+
`);
|
|
2156
|
+
if (storeId)
|
|
2157
|
+
query = query.eq("store_id", storeId);
|
|
2158
|
+
const { data, error } = await query;
|
|
2159
|
+
if (error)
|
|
2160
|
+
return { success: false, error: error.message };
|
|
2161
|
+
// Format with full email address
|
|
2162
|
+
const addresses = (data || []).map((a) => ({
|
|
2163
|
+
...a,
|
|
2164
|
+
full_email: a.domain ? `${a.address}@${a.domain.inbound_subdomain}.${a.domain.domain}` : null
|
|
2165
|
+
}));
|
|
2166
|
+
return { success: true, data: addresses };
|
|
2167
|
+
}
|
|
2168
|
+
case "addresses_add": {
|
|
2169
|
+
// Add a new email address/mailbox
|
|
2170
|
+
const domainId = args.domain_id;
|
|
2171
|
+
const address = args.address;
|
|
2172
|
+
const displayName = args.display_name;
|
|
2173
|
+
const mailboxType = args.mailbox_type || "general";
|
|
2174
|
+
const aiEnabled = args.ai_enabled !== false;
|
|
2175
|
+
if (!domainId || !address) {
|
|
2176
|
+
return { success: false, error: "domain_id and address required" };
|
|
2177
|
+
}
|
|
2178
|
+
if (!storeId)
|
|
2179
|
+
return { success: false, error: "store_id required" };
|
|
2180
|
+
// Verify domain belongs to store
|
|
2181
|
+
const { data: domain } = await supabase
|
|
2182
|
+
.from("store_email_domains")
|
|
2183
|
+
.select("id, domain, inbound_subdomain")
|
|
2184
|
+
.eq("id", domainId)
|
|
2185
|
+
.eq("store_id", storeId)
|
|
2186
|
+
.single();
|
|
2187
|
+
if (!domain)
|
|
2188
|
+
return { success: false, error: "Domain not found or doesn't belong to store" };
|
|
2189
|
+
const { data, error } = await supabase
|
|
2190
|
+
.from("store_email_addresses")
|
|
2191
|
+
.insert({
|
|
2192
|
+
store_id: storeId,
|
|
2193
|
+
domain_id: domainId,
|
|
2194
|
+
address: address.toLowerCase(),
|
|
2195
|
+
display_name: displayName,
|
|
2196
|
+
mailbox_type: mailboxType,
|
|
2197
|
+
ai_enabled: aiEnabled
|
|
2198
|
+
})
|
|
2199
|
+
.select()
|
|
2200
|
+
.single();
|
|
2201
|
+
if (error)
|
|
2202
|
+
return { success: false, error: error.message };
|
|
2203
|
+
return {
|
|
2204
|
+
success: true,
|
|
2205
|
+
data: {
|
|
2206
|
+
...data,
|
|
2207
|
+
full_email: `${address}@${domain.inbound_subdomain}.${domain.domain}`
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
default:
|
|
2212
|
+
return { success: false, error: `Unknown action: ${action}. Use: send, send_template, list, get, templates, inbox, inbox_get, inbox_reply, inbox_update, inbox_stats, domains_list, domains_add, domains_verify, addresses_list, addresses_add` };
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
catch (err) {
|
|
2216
|
+
return { success: false, error: `Email error: ${err}` };
|
|
2217
|
+
}
|
|
2218
|
+
},
|
|
2219
|
+
// ===========================================================================
|
|
2220
|
+
// 12. DOCUMENTS - Document generation (COA, etc.)
|
|
2221
|
+
// ===========================================================================
|
|
2222
|
+
documents: async (supabase, args, storeId) => {
|
|
2223
|
+
try {
|
|
2224
|
+
const DOCUMENTS_API_URL = process.env.DOCUMENTS_API_URL || "http://localhost:3102/api/tools";
|
|
2225
|
+
const oauthToken = process.env.DOCUMENTS_OAUTH_TOKEN;
|
|
2226
|
+
const headers = { "Content-Type": "application/json" };
|
|
2227
|
+
if (oauthToken)
|
|
2228
|
+
headers["Authorization"] = `Bearer ${oauthToken}`;
|
|
2229
|
+
const response = await fetch(DOCUMENTS_API_URL, {
|
|
2230
|
+
method: "POST",
|
|
2231
|
+
headers,
|
|
2232
|
+
body: JSON.stringify({ tool: "documents", input: args, context: { storeId } })
|
|
2233
|
+
});
|
|
2234
|
+
const result = await response.json();
|
|
2235
|
+
if (!response.ok)
|
|
2236
|
+
return { success: false, error: result.error || `HTTP ${response.status}` };
|
|
2237
|
+
return { success: result.success, data: result.data, error: result.error };
|
|
2238
|
+
}
|
|
2239
|
+
catch (err) {
|
|
2240
|
+
return { success: false, error: `Documents error: ${err}` };
|
|
2241
|
+
}
|
|
2242
|
+
},
|
|
2243
|
+
// ===========================================================================
|
|
2244
|
+
// 13. ALERTS - System alerts (low stock, pending orders)
|
|
2245
|
+
// ===========================================================================
|
|
2246
|
+
alerts: async (supabase, args, storeId) => {
|
|
2247
|
+
try {
|
|
2248
|
+
const { data: lowStock } = await supabase
|
|
2249
|
+
.from("inventory")
|
|
2250
|
+
.select("product_id, quantity, products(name)")
|
|
2251
|
+
.lt("quantity", 10)
|
|
2252
|
+
.limit(20);
|
|
2253
|
+
const { data: pendingOrders } = await supabase
|
|
2254
|
+
.from("orders")
|
|
2255
|
+
.select("id, order_number")
|
|
2256
|
+
.eq("status", "pending")
|
|
2257
|
+
.limit(20);
|
|
2258
|
+
return {
|
|
2259
|
+
success: true,
|
|
2260
|
+
data: {
|
|
2261
|
+
lowStock: lowStock?.length || 0,
|
|
2262
|
+
pendingOrders: pendingOrders?.length || 0,
|
|
2263
|
+
alerts: [
|
|
2264
|
+
...(lowStock || []).map(i => ({ type: "low_stock", product: i.products?.name, quantity: i.quantity })),
|
|
2265
|
+
...(pendingOrders || []).map(o => ({ type: "pending_order", order_number: o.order_number }))
|
|
2266
|
+
]
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
catch (err) {
|
|
2271
|
+
return { success: false, error: `Alerts error: ${err}` };
|
|
2272
|
+
}
|
|
2273
|
+
},
|
|
2274
|
+
// ===========================================================================
|
|
2275
|
+
// 14. AUDIT_TRAIL - View audit logs
|
|
2276
|
+
// ===========================================================================
|
|
2277
|
+
audit_trail: async (supabase, args, storeId) => {
|
|
2278
|
+
try {
|
|
2279
|
+
const limit = args.limit || 50;
|
|
2280
|
+
let q = supabase
|
|
2281
|
+
.from("audit_logs")
|
|
2282
|
+
.select("*")
|
|
2283
|
+
.order("created_at", { ascending: false })
|
|
2284
|
+
.limit(limit);
|
|
2285
|
+
if (storeId)
|
|
2286
|
+
q = q.eq("store_id", storeId);
|
|
2287
|
+
const { data, error } = await q;
|
|
2288
|
+
if (error)
|
|
2289
|
+
return { success: false, error: error.message };
|
|
2290
|
+
return { success: true, data };
|
|
2291
|
+
}
|
|
2292
|
+
catch (err) {
|
|
2293
|
+
return { success: false, error: `Audit trail error: ${err}` };
|
|
2294
|
+
}
|
|
2295
|
+
},
|
|
2296
|
+
};
|
|
2297
|
+
// Generate W3C-compliant span ID (16 hex chars)
|
|
2298
|
+
function generateSpanId() {
|
|
2299
|
+
const bytes = new Uint8Array(8);
|
|
2300
|
+
crypto.getRandomValues(bytes);
|
|
2301
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
2302
|
+
}
|
|
2303
|
+
// Generate W3C-compliant trace ID (32 hex chars)
|
|
2304
|
+
function generateTraceId() {
|
|
2305
|
+
const bytes = new Uint8Array(16);
|
|
2306
|
+
crypto.getRandomValues(bytes);
|
|
2307
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
2308
|
+
}
|
|
2309
|
+
async function logToolExecution(supabase, toolName, action, args, result, durationMs, storeId, context, startTime) {
|
|
2310
|
+
try {
|
|
2311
|
+
// Sanitize args - remove sensitive data (Anthropic best practice: comprehensive pattern matching)
|
|
2312
|
+
const SENSITIVE_PATTERNS = [
|
|
2313
|
+
/key/i,
|
|
2314
|
+
/secret/i,
|
|
2315
|
+
/token/i,
|
|
2316
|
+
/password/i,
|
|
2317
|
+
/auth/i,
|
|
2318
|
+
/credential/i,
|
|
2319
|
+
/bearer/i,
|
|
2320
|
+
/api.?key/i,
|
|
2321
|
+
/private/i
|
|
2322
|
+
];
|
|
2323
|
+
const sanitizedArgs = {};
|
|
2324
|
+
for (const [key, value] of Object.entries(args)) {
|
|
2325
|
+
if (SENSITIVE_PATTERNS.some(pattern => pattern.test(key))) {
|
|
2326
|
+
sanitizedArgs[key] = "[REDACTED]";
|
|
2327
|
+
}
|
|
2328
|
+
else {
|
|
2329
|
+
sanitizedArgs[key] = value;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
// Generate W3C Trace Context IDs
|
|
2333
|
+
const spanId = context?.spanId || generateSpanId();
|
|
2334
|
+
const traceId = context?.traceId || context?.requestId || generateTraceId();
|
|
2335
|
+
const endTime = new Date();
|
|
2336
|
+
const actualStartTime = startTime || new Date(endTime.getTime() - durationMs);
|
|
2337
|
+
// Build the insert payload with BOTH denormalized columns and JSONB details
|
|
2338
|
+
const corePayload = {
|
|
2339
|
+
action: `tool.${toolName}${action ? `.${action}` : ""}`,
|
|
2340
|
+
severity: result.success ? "info" : "error",
|
|
2341
|
+
store_id: storeId || null,
|
|
2342
|
+
user_id: context?.userId || null,
|
|
2343
|
+
resource_type: "mcp_tool",
|
|
2344
|
+
resource_id: toolName,
|
|
2345
|
+
request_id: context?.requestId || traceId,
|
|
2346
|
+
parent_id: context?.parentId || null,
|
|
2347
|
+
duration_ms: durationMs,
|
|
2348
|
+
error_message: result.error || null,
|
|
2349
|
+
// Denormalized OTEL columns for fast queries/aggregations
|
|
2350
|
+
trace_id: traceId,
|
|
2351
|
+
span_id: spanId,
|
|
2352
|
+
trace_flags: context?.traceFlags ?? 1,
|
|
2353
|
+
span_kind: "INTERNAL",
|
|
2354
|
+
service_name: context?.serviceName || "agent-server",
|
|
2355
|
+
service_version: context?.serviceVersion || "3.0.0",
|
|
2356
|
+
status_code: result.success ? "OK" : "ERROR",
|
|
2357
|
+
start_time: actualStartTime.toISOString(),
|
|
2358
|
+
end_time: endTime.toISOString(),
|
|
2359
|
+
error_type: result.errorType || null,
|
|
2360
|
+
retryable: result.retryable ?? false,
|
|
2361
|
+
// AI telemetry columns (if executing in agent context)
|
|
2362
|
+
model: context?.model || null,
|
|
2363
|
+
input_tokens: context?.inputTokens || null,
|
|
2364
|
+
output_tokens: context?.outputTokens || null,
|
|
2365
|
+
total_cost: context?.totalCost || null,
|
|
2366
|
+
turn_number: context?.turnNumber || null,
|
|
2367
|
+
conversation_id: context?.conversationId || null,
|
|
2368
|
+
// JSONB details for flexible/extended attributes
|
|
2369
|
+
details: {
|
|
2370
|
+
// Source and agent info (for UI filtering)
|
|
2371
|
+
source: context?.source || "api",
|
|
2372
|
+
agent_id: context?.agentId || null,
|
|
2373
|
+
agent_name: context?.agentName || null,
|
|
2374
|
+
// Tool execution details (for UI display)
|
|
2375
|
+
tool_input: sanitizedArgs,
|
|
2376
|
+
tool_result: result.success ? (typeof result.data === 'string' ? result.data.slice(0, 1000) : result.data) : null,
|
|
2377
|
+
tool_error: result.error || null,
|
|
2378
|
+
timed_out: result.timedOut || false,
|
|
2379
|
+
// Cost isolation: marginal cost of the API turn that triggered this tool
|
|
2380
|
+
marginal_cost: context?.turnCost ?? null,
|
|
2381
|
+
cost_before: context?.costBefore ?? null,
|
|
2382
|
+
// Payload size tracking (bytes)
|
|
2383
|
+
input_bytes: JSON.stringify(sanitizedArgs).length,
|
|
2384
|
+
output_bytes: result.success
|
|
2385
|
+
? (typeof result.data === 'string' ? result.data.length : JSON.stringify(result.data).length)
|
|
2386
|
+
: (result.error?.length ?? 0),
|
|
2387
|
+
// Error classification (surfaced for UI badges)
|
|
2388
|
+
error_type: result.errorType || null,
|
|
2389
|
+
retryable: result.retryable ?? false,
|
|
2390
|
+
// Legacy/backwards compatibility
|
|
2391
|
+
args: sanitizedArgs,
|
|
2392
|
+
result: result.success ? result.data : null,
|
|
2393
|
+
// OTEL context embedded (for tools that need full context)
|
|
2394
|
+
otel: {
|
|
2395
|
+
trace_id: traceId,
|
|
2396
|
+
span_id: spanId,
|
|
2397
|
+
parent_span_id: context?.parentSpanId || null,
|
|
2398
|
+
trace_flags: context?.traceFlags ?? 1,
|
|
2399
|
+
span_kind: "INTERNAL",
|
|
2400
|
+
service_name: context?.serviceName || "agent-server",
|
|
2401
|
+
service_version: context?.serviceVersion || "3.0.0",
|
|
2402
|
+
status_code: result.success ? "OK" : "ERROR",
|
|
2403
|
+
error_type: result.errorType || null,
|
|
2404
|
+
retryable: result.retryable ?? false
|
|
2405
|
+
},
|
|
2406
|
+
// OTEL resource attributes (gen_ai.* + tool.* semantic conventions)
|
|
2407
|
+
resource_attributes: {
|
|
2408
|
+
"tool.name": toolName,
|
|
2409
|
+
"tool.action": action || null,
|
|
2410
|
+
"tool.input_bytes": JSON.stringify(sanitizedArgs).length,
|
|
2411
|
+
"tool.output_bytes": result.success
|
|
2412
|
+
? (typeof result.data === 'string' ? result.data.length : JSON.stringify(result.data).length)
|
|
2413
|
+
: 0,
|
|
2414
|
+
"ai.agent.id": context?.agentId || null,
|
|
2415
|
+
"ai.agent.name": context?.agentName || null,
|
|
2416
|
+
"ai.model": context?.model || null,
|
|
2417
|
+
"ai.turn_number": context?.turnNumber || null,
|
|
2418
|
+
"ai.conversation_id": context?.conversationId || null
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
// Insert using existing schema - OTEL data is embedded in details JSONB
|
|
2423
|
+
// This works without any migration and the UI can parse details.otel and details.ai
|
|
2424
|
+
const { data } = await supabase.from("audit_logs").insert(corePayload).select("id").single();
|
|
2425
|
+
return data?.id || null;
|
|
2426
|
+
}
|
|
2427
|
+
catch (err) {
|
|
2428
|
+
// Don't fail the tool call if logging fails
|
|
2429
|
+
console.error("[Telemetry] Failed to log:", err);
|
|
2430
|
+
return null;
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
// ============================================================================
|
|
2434
|
+
// EXECUTOR
|
|
2435
|
+
// ============================================================================
|
|
2436
|
+
export async function executeTool(supabase, toolName, args, storeId, context) {
|
|
2437
|
+
const startDate = new Date();
|
|
2438
|
+
const startTime = startDate.getTime();
|
|
2439
|
+
const action = args.action;
|
|
2440
|
+
const handler = handlers[toolName];
|
|
2441
|
+
if (!handler) {
|
|
2442
|
+
const result = {
|
|
2443
|
+
success: false,
|
|
2444
|
+
error: `Tool "${toolName}" not found. Available tools: ${Object.keys(handlers).join(", ")}`,
|
|
2445
|
+
errorType: ToolErrorType.VALIDATION,
|
|
2446
|
+
retryable: false
|
|
2447
|
+
};
|
|
2448
|
+
// Log failed tool lookup with OTEL fields
|
|
2449
|
+
await logToolExecution(supabase, toolName, action, args, result, Date.now() - startTime, storeId, context, startDate);
|
|
2450
|
+
return result;
|
|
2451
|
+
}
|
|
2452
|
+
let result;
|
|
2453
|
+
try {
|
|
2454
|
+
result = await handler(supabase, args, storeId);
|
|
2455
|
+
// If handler returned an error without classification, classify it
|
|
2456
|
+
if (!result.success && !result.errorType) {
|
|
2457
|
+
const classification = classifyError({ message: result.error });
|
|
2458
|
+
result.errorType = classification.errorType;
|
|
2459
|
+
result.retryable = classification.retryable;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
catch (err) {
|
|
2463
|
+
// Classify the caught error
|
|
2464
|
+
const classification = classifyError(err);
|
|
2465
|
+
result = {
|
|
2466
|
+
success: false,
|
|
2467
|
+
error: `Tool execution error: ${err.message || err}`,
|
|
2468
|
+
errorType: classification.errorType,
|
|
2469
|
+
retryable: classification.retryable
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
// Log the execution with full OTEL telemetry
|
|
2473
|
+
const durationMs = Date.now() - startTime;
|
|
2474
|
+
await logToolExecution(supabase, toolName, action, args, result, durationMs, storeId, context, startDate);
|
|
2475
|
+
return result;
|
|
2476
|
+
}
|
|
2477
|
+
export function getImplementedTools() {
|
|
2478
|
+
return Object.keys(handlers);
|
|
2479
|
+
}
|