diff --git a/.opencode/openwork/inbox/chat-attachments/ses_01738112fffe3dQ66XdT6shP6s/8f9f550e-bde4-48c6-ba22-86e2bf0c4ef8-POPULATE AI INSIGHTS-3.json b/.opencode/openwork/inbox/chat-attachments/ses_01738112fffe3dQ66XdT6shP6s/8f9f550e-bde4-48c6-ba22-86e2bf0c4ef8-POPULATE AI INSIGHTS-3.json new file mode 100644 index 0000000..0452bd4 --- /dev/null +++ b/.opencode/openwork/inbox/chat-attachments/ses_01738112fffe3dQ66XdT6shP6s/8f9f550e-bde4-48c6-ba22-86e2bf0c4ef8-POPULATE AI INSIGHTS-3.json @@ -0,0 +1,293 @@ +{ + "name": "POPULATE AI INSIGHTS", + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -368, + -32 + ], + "id": "5fcddf3e-573e-474a-a688-5272b188c0e6", + "name": "When clicking ‘Execute workflow’" + }, + { + "parameters": { + "promptType": "define", + "text": "=Analyze the following JSON. I am looking for items where the description, name, or equivalent field is a match or high-confidence product match for: {{ $json.description }}\n\nCRITICAL MATCHING RULES:\n- Include exact matches.\n- Include close semantic variations of the exact same pack size (e.g., if targeting \"20L\", include matches for \"20 LT\" or \"20 Litre\").\n- STRIKTLY EXCLUDE different pack sizes or bulk configurations (e.g., if targeting \"20L\", do NOT match \"210L\", \"1000L\", \"20X500ML\", or \"12X1LT\").\n- Ignore minor specification differences like \"SF/CF4\" as long as the base product and pack size are the exact same.\n\nReturn a valid JSON object containing ONLY the arrays and the specific items that match this item based on the rules above.\n\nCRITICAL INSTRUCTIONS:\n- Output ONLY raw JSON. \n- DO NOT wrap the output in markdown blocks.\n- DO NOT include backticks (```).\n- DO NOT write any explanations, greetings, or conversational text. \n- If you write anything other than the raw JSON object, the system will break.\n\nData to filter:\n{{ JSON.stringify($json.ai_item_insights, null, 2) }}", + "hasOutputParser": true, + "needsFallback": true, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "position": [ + 112, + -32 + ], + "id": "96b4f602-97d6-4aec-a920-ba2e09f5c4f3", + "name": "AI Agent" + }, + { + "parameters": { + "model": "MiniMax-M2.1", + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.lmChatMinimax", + "typeVersion": 1, + "position": [ + -32, + 240 + ], + "id": "05947ad8-ec67-49f2-aeef-68bd61a82b4d", + "name": "MiniMax Chat Model", + "credentials": { + "minimaxApi": { + "id": "kn97KHCrkyTm3v2g", + "name": "MiniMax account" + } + } + }, + { + "parameters": { + "schemaType": "manual", + "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"stock_list\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the stock list. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"supplier_history\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the supplier history. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"price_list\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the price list. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"recent_quote\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the recent quotes. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"recent_order\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the recent orders. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n }\n },\n \"required\": [\n \"stock_list\",\n \"supplier_history\"\n ]\n}", + "autoFix": true + }, + "type": "@n8n/n8n-nodes-langchain.outputParserStructured", + "typeVersion": 1.3, + "position": [ + 288, + 176 + ], + "id": "3548a5a7-25b8-4a4e-8e09-60a5282c6022", + "name": "Structured Output Parser" + }, + { + "parameters": { + "model": { + "__rl": true, + "value": "google/gemma-4-e2b", + "mode": "list", + "cachedResultName": "google/gemma-4-e2b" + }, + "responsesApiEnabled": false, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.3, + "position": [ + 144, + 240 + ], + "id": "b566b192-2cfe-4f22-8ca3-863ae295d4c4", + "name": "OpenAI Chat Model1", + "credentials": { + "openAiApi": { + "id": "LcoRpE8OF7QrpByT", + "name": "OpenAI account" + } + } + }, + { + "parameters": { + "model": "MiniMax-M2.1", + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.lmChatMinimax", + "typeVersion": 1, + "position": [ + 288, + 320 + ], + "id": "19f05b98-850b-4bc6-9ffb-3681a708584d", + "name": "MiniMax Chat Model1", + "credentials": { + "minimaxApi": { + "id": "kn97KHCrkyTm3v2g", + "name": "MiniMax account" + } + } + }, + { + "parameters": { + "resource": "quote_items", + "operation": "update", + "elementId": "={{ $('Search quote_items').item.json.id }}", + "parameters": { + "allElements": false, + "elementsPerPage": 1 + }, + "fields": { + "assignments": [ + { + "id": "44822f7e-b731-463b-a1f5-d46484853e11", + "name": "ai_item_insights", + "value": "={{ $json.ai_item_insights }}", + "type": "object" + } + ] + }, + "requestOptions": {} + }, + "type": "n8n-nodes-pocketbase.pocketbaseHttp", + "typeVersion": 1, + "position": [ + 912, + -32 + ], + "id": "6c8e5ae6-b56b-4468-afe4-947e13274f0e", + "name": "Update quote_items", + "credentials": { + "pocketbaseHttpApi": { + "id": "hLwB1QPIJEWsuVyW", + "name": "PocketBase HTTP account" + } + } + }, + { + "parameters": { + "jsCode": "const allSearch = $('Search quote_items').all();\nconsole.log(\"Search returned \" + allSearch.length + \" items\");\nif (allSearch.length > 0) {\n console.log(\"First item keys: \" + Object.keys(allSearch[0]).join(\", \"));\n console.log(\"First item id: \" + allSearch[0].id);\n console.log(\"First item description: \" + allSearch[0].description);\n}\n\nconst items = $('Search quote_items').all();\nconst llmOutput = ($input.all()[0] && $input.all()[0].json && $input.all()[0].json.output) || {\n stock_list: [],\n supplier_history: [],\n price_list: [],\n recent_quote: [],\n recent_order: [],\n};\n\nif (!items || items.length === 0) {\n console.log(\"WARN: no items returned from Search\");\n return [{ json: { _status: \"no_items\" } }];\n}\n\nconst src = items[0];\nconsole.log(\"Processing item: \" + (src.id || \"UNKNOWN\") + \" \" + (src.description || \"\"));\n\nreturn items.map((src) => ({\n json: {\n ai_item_insights: {\n item: {\n id: src.id || \"\",\n collection: src.collectionName || src.collectionId || \"quote_items\",\n description: src.description || \"\",\n sku: src.sku || \"\",\n quantity: Number(src.quantity) || 0,\n },\n stock_list: (llmOutput.stock_list || []).filter((r) => r && r.stock_item_id),\n supplier_history: (llmOutput.supplier_history || []).filter((r) => r && r.supplier_id),\n price_list: (llmOutput.price_list || []).filter((r) => r && r.sku),\n recent_quote: (llmOutput.recent_quote || []).filter((r) => r && r.quote_item_id),\n recent_order: (llmOutput.recent_order || []).filter((r) => r && r.order_item_id),\n verified: {\n at: new Date().toISOString(),\n by: \"n8n:ai-verifier\",\n removed_count: Number(llmOutput.removed_count) || 0,\n notes: llmOutput.notes || \"\",\n },\n },\n },\n}));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 624, + -32 + ], + "id": "835e218b-b10b-4f3e-a39d-5ea76af364b9", + "name": "Code in JavaScript" + }, + { + "parameters": { + "resource": "quote_items", + "parameters": { + "allElements": false, + "elementsPerPage": 1, + "filter": "ai_item_insights !~ 'verified'" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-pocketbase.pocketbaseHttp", + "typeVersion": 1, + "position": [ + -144, + -32 + ], + "id": "96180b56-34a5-4778-aff3-0a219fbc7051", + "name": "Search quote_items", + "credentials": { + "pocketbaseHttpApi": { + "id": "hLwB1QPIJEWsuVyW", + "name": "PocketBase HTTP account" + } + } + } + ], + "pinData": {}, + "connections": { + "When clicking ‘Execute workflow’": { + "main": [ + [ + { + "node": "Search quote_items", + "type": "main", + "index": 0 + } + ] + ] + }, + "MiniMax Chat Model": { + "ai_languageModel": [ + [ + { + "node": "AI Agent", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "AI Agent": { + "main": [ + [ + { + "node": "Code in JavaScript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Structured Output Parser": { + "ai_outputParser": [ + [ + { + "node": "AI Agent", + "type": "ai_outputParser", + "index": 0 + } + ] + ] + }, + "OpenAI Chat Model1": { + "ai_languageModel": [ + [ + { + "node": "AI Agent", + "type": "ai_languageModel", + "index": 1 + } + ] + ] + }, + "MiniMax Chat Model1": { + "ai_languageModel": [ + [ + { + "node": "Structured Output Parser", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Code in JavaScript": { + "main": [ + [ + { + "node": "Update quote_items", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search quote_items": { + "main": [ + [ + { + "node": "AI Agent", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": false + }, + "versionId": "d2297962-055d-4324-bdb4-1f8c93f1c390", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "05bcfa3b50919141d3600718ff4b7b2b52a3883525b05573a27ba5ef437ac7fb" + }, + "nodeGroups": [], + "id": "paqlZICDbXbmY61V", + "tags": [] +} \ No newline at end of file diff --git a/.opencode/openwork/inbox/chat-attachments/ses_01738112fffe3dQ66XdT6shP6s/9d4e3bc5-9f95-4dfa-af3e-7b56a99572a3-POPULATE AI INSIGHTS-2.json b/.opencode/openwork/inbox/chat-attachments/ses_01738112fffe3dQ66XdT6shP6s/9d4e3bc5-9f95-4dfa-af3e-7b56a99572a3-POPULATE AI INSIGHTS-2.json new file mode 100644 index 0000000..57aee6a --- /dev/null +++ b/.opencode/openwork/inbox/chat-attachments/ses_01738112fffe3dQ66XdT6shP6s/9d4e3bc5-9f95-4dfa-af3e-7b56a99572a3-POPULATE AI INSIGHTS-2.json @@ -0,0 +1,442 @@ +{ + "name": "POPULATE AI INSIGHTS", + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -368, + -32 + ], + "id": "5fcddf3e-573e-474a-a688-5272b188c0e6", + "name": "When clicking ‘Execute workflow’" + }, + { + "parameters": { + "promptType": "define", + "text": "=Analyze the following JSON. I am looking for items where the description, name, or equivalent field is a match or high-confidence product match for: {{ $json.description }}\n\nCRITICAL MATCHING RULES:\n- Include exact matches.\n- Include close semantic variations of the exact same pack size (e.g., if targeting \"20L\", include matches for \"20 LT\" or \"20 Litre\").\n- STRIKTLY EXCLUDE different pack sizes or bulk configurations (e.g., if targeting \"20L\", do NOT match \"210L\", \"1000L\", \"20X500ML\", or \"12X1LT\").\n- Ignore minor specification differences like \"SF/CF4\" as long as the base product and pack size are the exact same.\n\nReturn a valid JSON object containing ONLY the arrays and the specific items that match this item based on the rules above.\n\nCRITICAL INSTRUCTIONS:\n- Output ONLY raw JSON. \n- DO NOT wrap the output in markdown blocks.\n- DO NOT include backticks (```).\n- DO NOT write any explanations, greetings, or conversational text. \n- If you write anything other than the raw JSON object, the system will break.\n\nData to filter:\n{{ JSON.stringify($json.ai_item_insights, null, 2) }}", + "hasOutputParser": true, + "needsFallback": true, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "position": [ + 112, + -32 + ], + "id": "96b4f602-97d6-4aec-a920-ba2e09f5c4f3", + "name": "AI Agent" + }, + { + "parameters": { + "model": "MiniMax-M2.1", + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.lmChatMinimax", + "typeVersion": 1, + "position": [ + -32, + 240 + ], + "id": "05947ad8-ec67-49f2-aeef-68bd61a82b4d", + "name": "MiniMax Chat Model", + "credentials": { + "minimaxApi": { + "id": "kn97KHCrkyTm3v2g", + "name": "MiniMax account" + } + } + }, + { + "parameters": { + "schemaType": "manual", + "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"stock_list\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the stock list. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"supplier_history\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the supplier history. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"price_list\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the price list. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"recent_quote\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the recent quotes. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"recent_order\": {\n \"type\": \"array\",\n \"description\": \"Exact matching items from the recent orders. Leave empty if no match.\",\n \"items\": {\n \"type\": \"object\"\n }\n }\n },\n \"required\": [\n \"stock_list\",\n \"supplier_history\"\n ]\n}", + "autoFix": true + }, + "type": "@n8n/n8n-nodes-langchain.outputParserStructured", + "typeVersion": 1.3, + "position": [ + 288, + 176 + ], + "id": "3548a5a7-25b8-4a4e-8e09-60a5282c6022", + "name": "Structured Output Parser" + }, + { + "parameters": { + "model": { + "__rl": true, + "value": "google/gemma-4-e2b", + "mode": "list", + "cachedResultName": "google/gemma-4-e2b" + }, + "responsesApiEnabled": false, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.3, + "position": [ + 144, + 240 + ], + "id": "b566b192-2cfe-4f22-8ca3-863ae295d4c4", + "name": "OpenAI Chat Model1", + "credentials": { + "openAiApi": { + "id": "LcoRpE8OF7QrpByT", + "name": "OpenAI account" + } + } + }, + { + "parameters": { + "model": "MiniMax-M2.1", + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.lmChatMinimax", + "typeVersion": 1, + "position": [ + 288, + 320 + ], + "id": "19f05b98-850b-4bc6-9ffb-3681a708584d", + "name": "MiniMax Chat Model1", + "credentials": { + "minimaxApi": { + "id": "kn97KHCrkyTm3v2g", + "name": "MiniMax account" + } + } + }, + { + "parameters": { + "resource": "quote_items", + "operation": "update", + "elementId": "={{ $('Search quote_items').item.json.id }}", + "parameters": { + "allElements": false, + "elementsPerPage": 1 + }, + "fields": { + "assignments": [ + { + "id": "44822f7e-b731-463b-a1f5-d46484853e11", + "name": "ai_item_insights", + "value": "={\n \"name\": \"ai_item_insights\",\n \"value\": \"={{ $json.ai_item_insights }}\",\n \"type\": \"object\"\n}", + "type": "object" + } + ] + }, + "requestOptions": {} + }, + "type": "n8n-nodes-pocketbase.pocketbaseHttp", + "typeVersion": 1, + "position": [ + 912, + -32 + ], + "id": "6c8e5ae6-b56b-4468-afe4-947e13274f0e", + "name": "Update quote_items", + "credentials": { + "pocketbaseHttpApi": { + "id": "hLwB1QPIJEWsuVyW", + "name": "PocketBase HTTP account" + } + } + }, + { + "parameters": { + "resource": "quote_items", + "parameters": { + "allElements": false, + "elementsPerPage": 1, + "filter": "ai_item_insights !~ 'Verified'" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-pocketbase.pocketbaseHttp", + "typeVersion": 1, + "position": [ + -112, + -32 + ], + "id": "c24cf6c7-9b10-4388-aed6-aa6e706f44bd", + "name": "Search quote_items", + "credentials": { + "pocketbaseHttpApi": { + "id": "hLwB1QPIJEWsuVyW", + "name": "PocketBase HTTP account" + } + } + }, + { + "parameters": { + "jsCode": "const items = $('Search quote_items').all();\nconst llmOutput = $input.all()[0].json.output; // whatever the AI Agent emitted\n\nconst updates = items.map((src) => ({\n json: {\n ai_item_insights: {\n // Static header from the source row\n item: {\n id: src.id,\n collection: src.collectionName,\n description: src.description,\n sku: src.sku || '',\n quantity: src.quantity || 0,\n },\n // LLM-filtered sections (truncated)\n stock_list: (llmOutput.stock_list || []).filter((r) => r.stock_item_id),\n supplier_history: (llmOutput.supplier_history || []).filter((r) => r.supplier_id),\n price_list: (llmOutput.price_list || []).filter((r) => r.sku),\n recent_quote: (llmOutput.recent_quote || []).filter((r) => r.quote_item_id),\n recent_order: (llmOutput.recent_order || []).filter((r) => r.order_item_id),\n // Verification metadata\n verified: {\n at: new Date().toISOString(),\n by: 'n8n:ai-verifier',\n removed_count: ((llmOutput.removed_count) || 0),\n notes: llmOutput.notes || '',\n },\n },\n },\n}));\n\nreturn updates;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 624, + -32 + ], + "id": "835e218b-b10b-4f3e-a39d-5ea76af364b9", + "name": "Code in JavaScript" + } + ], + "pinData": { + "When clicking ‘Execute workflow’": [ + { + "json": {}, + "pairedItem": { + "item": 0 + } + } + ], + "AI Agent": [ + { + "json": { + "output": { + "stock_list": [ + { + "cost_price": 990, + "description": "SAE 50 - 20L", + "min_qty": 0, + "name": "SAE 50 - 20L", + "qty": 2, + "score": 1, + "selling_price": 0, + "sku": "SAE50SF/CF4-20", + "status": "in_stock", + "stock_item_id": "kxrpa6lqda58xzl", + "supplier_id": "pn93z9minsdhavt", + "supplier_name": "AFRIQ LUBE AND SERVICES CC" + } + ], + "supplier_history": [ + { + "days_ago": 10, + "last_bought_at": "2026-07-31 12:20:09.438Z", + "last_cost": 990, + "last_purchase_number": "PO26073109STK", + "stock_item_id": "kxrpa6lqda58xzl", + "stock_item_name": "SAE 50 - 20L", + "stock_item_sku": "SAE50SF/CF4-20", + "supplier_id": "pn93z9minsdhavt", + "supplier_name": "AFRIQ LUBE AND SERVICES CC", + "times_bought": 5 + } + ], + "price_list": [ + { + "brand": "AFRIQ", + "category": "Lubricants", + "cost_price": 879, + "description": "SAE 50 SF/CF4 - 20 LT", + "score": 0.667, + "selling_price": 879, + "sku": "AFRIQ-ENGINEOILS-SAE_50_SF_CF4-20LT", + "source_file": "AFRIQ-RESELLERS-2026-05", + "supplier": "AFRIQ LUBE AND SERVICES CC", + "updated": "2026-08-10 12:33:36.175Z" + } + ], + "recent_quote": [], + "recent_order": [] + } + }, + "pairedItem": { + "item": 0 + } + } + ], + "Search quote_items": [ + { + "json": { + "ai_item_insights": { + "recent_order": [ + { + "cost_price": 70, + "days_ago": 12, + "description": "M22 X 130 STUD", + "item_status": "delivered", + "order_id": "83w6worlzraohdq", + "order_item_id": "3yyylccvtcn1bfq", + "order_number": "73652", + "quantity": 100, + "score": 1, + "selling_price": 83.94, + "supplier_id": "rlhxhaw73dxmsu3", + "supplier_name": "CNL - IN STOCK", + "updated": "2026-07-29 22:30:36.558Z" + } + ], + "recent_quote": [ + { + "company": "Ultra Reconditioners", + "cost_price": 43.2, + "customer_name": "Ultra Reconditioners", + "days_ago": 15, + "description": "M22 X 130 STUDS\r\n", + "quantity": 1, + "quote_id": "dczyn2f5ukt3rjt", + "quote_item_id": "43vmjzg165lug33", + "quote_number": "Q007945", + "quote_status": "approved", + "quoted_at": "2026-07-26 22:44:04.337Z", + "score": 0.667, + "selling_price": 74.25 + } + ], + "stock_list": [], + "supplier_history": [] + }, + "approved_in_batch": false, + "category": "4emvaanzogj0586", + "claim_expires_at": "", + "claim_status": "unclaimed", + "claimed_at": "", + "claimed_by": "", + "collectionId": "pbc_470828888", + "collectionName": "quote_items", + "cost_price": 70, + "created": "2026-07-26 22:12:14.145Z", + "description": "M22 X 130 STUD ", + "id": "6fil34avzkhnl2a", + "is_processed": true, + "markup": 19.91, + "matched_price_list_id": "", + "meiliesearch": true, + "migration_meta": { + "migrated_at": "2026-07-26T22:12:13.869876+00:00", + "wfdash_quote_item_id": 32294 + }, + "moved_to_quote": "", + "notes": "", + "outbound_message_id": "", + "outbound_sent_at": "", + "pricing_notes": "", + "pricing_source": "", + "quantity": 100, + "quote": "biuwwj1aiek9lrj", + "quote_description": "", + "quote_number": "Q010474", + "quote_pdf_id": "", + "quote_reference": "", + "selling_price": 83.94, + "sku": "", + "supplier": "", + "supplier_replied_at": "", + "supplier_reply_count": 0, + "updated": "2026-08-11 00:44:42.641Z", + "user_edited": false + } + } + ] + }, + "connections": { + "When clicking ‘Execute workflow’": { + "main": [ + [ + { + "node": "Search quote_items", + "type": "main", + "index": 0 + } + ] + ] + }, + "MiniMax Chat Model": { + "ai_languageModel": [ + [ + { + "node": "AI Agent", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "AI Agent": { + "main": [ + [ + { + "node": "Code in JavaScript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Structured Output Parser": { + "ai_outputParser": [ + [ + { + "node": "AI Agent", + "type": "ai_outputParser", + "index": 0 + } + ] + ] + }, + "OpenAI Chat Model1": { + "ai_languageModel": [ + [ + { + "node": "AI Agent", + "type": "ai_languageModel", + "index": 1 + } + ] + ] + }, + "MiniMax Chat Model1": { + "ai_languageModel": [ + [ + { + "node": "Structured Output Parser", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Search quote_items": { + "main": [ + [ + { + "node": "AI Agent", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript": { + "main": [ + [ + { + "node": "Update quote_items", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": false + }, + "versionId": "56a896a4-bf88-4e9e-85ef-99cfc467c6c4", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "05bcfa3b50919141d3600718ff4b7b2b52a3883525b05573a27ba5ef437ac7fb" + }, + "nodeGroups": [], + "id": "paqlZICDbXbmY61V", + "tags": [] +} \ No newline at end of file diff --git a/scripts/migrations/2026-08-09-backfill-quote-validity-days.ts b/scripts/migrations/2026-08-09-backfill-quote-validity-days.ts new file mode 100644 index 0000000..7a11597 --- /dev/null +++ b/scripts/migrations/2026-08-09-backfill-quote-validity-days.ts @@ -0,0 +1,153 @@ +/* eslint-disable */ +/** + * One-off data repair: stamp `validity_days = 30` on every quote + * where the field is missing, null, or zero. + * + * ## Background + * + * PocketBase returns `0` (not `null`) for an un-set number field. The + * PDF formatter originally used `quote.validity_days ?? 30`, which + * does *not* treat 0 as nullish. Every quote created via the email + * ingest path (`createQuoteFromEmail`) before this fix shipped + * ended up with `validity_days = 0`, so the generated PDF said + * "Valid until: today". + * + * This script does a one-shot patch to bring every existing quote + * to the package default. Quotes where an operator had explicitly + * set a value (any positive number) are left untouched. + * + * ## Usage + * + * Dry run (default — safe to run anytime): + * + * npx tsx scripts/migrations/2026-08-09-backfill-quote-validity-days.ts + * + * Apply the repair: + * + * npx tsx scripts/migrations/2026-08-09-backfill-quote-validity-days.ts --apply + * + * ## Idempotency + * + * The filter `validity_days = null || validity_days <= 0` only matches + * quotes that need fixing. Re-running after a successful apply + * updates zero rows and exits cleanly. + */ +import PocketBase from "pocketbase"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function loadEnvFile(name: string) { + try { + const raw = readFileSync(resolve(process.cwd(), name), "utf8"); + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i); + if (m && !process.env[m[1]]) { + process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim(); + } + } + } catch { + // missing file — fine + } +} +loadEnvFile(".env"); +loadEnvFile(".env.local"); + +const QUOTE_VALID_DAYS = 30; +const APPLY = process.argv.includes("--apply"); +const PAGE_SIZE = 500; + +async function main() { + const url = process.env.NEXT_PUBLIC_POCKETBASE_URL; + const email = process.env.PB_ADMIN_EMAIL; + const password = process.env.PB_ADMIN_PASSWORD; + if (!url || !email || !password) { + throw new Error( + "Missing NEXT_PUBLIC_POCKETBASE_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in env", + ); + } + + const pb = new PocketBase(url); + await pb.admins.authWithPassword(email, password); + + // Match every quote that's missing / unset / zero / negative on + // validity_days. The shape mirrors `effectiveValidityDays`'s + // nullish handling so we patch exactly the rows the PDF code + // would currently fall back on. + const filter = 'validity_days = null || validity_days <= 0'; + const all = (await pb.collection("quotes").getFullList({ + filter, + fields: "id,quote_number,validity_days,quote_date", + sort: "quote_number", + })) as Array<{ + id: string; + quote_number: string; + validity_days: number | null; + quote_date: string; + }>; + + if (all.length === 0) { + console.log("No quotes with missing/0 validity_days — nothing to repair."); + return; + } + + console.log(`Found ${all.length} quote(s) with missing or 0 validity_days.`); + console.log(""); + console.log("First 20 rows that would be patched:"); + for (const q of all.slice(0, 20)) { + console.log( + ` ${q.quote_number.padEnd(16)} was=${String(q.validity_days).padStart(4)} → ${QUOTE_VALID_DAYS} (date=${q.quote_date || "—"})`, + ); + } + if (all.length > 20) { + console.log(` ... and ${all.length - 20} more`); + } + console.log(""); + + if (!APPLY) { + console.log("DRY RUN — pass --apply to perform the repair."); + console.log(""); + console.log("Re-run with: npx tsx scripts/migrations/2026-08-09-backfill-quote-validity-days.ts --apply"); + return; + } + + // PB doesn't expose a batch-update collection endpoint via the SDK + // for arbitrary fields. Update each row one-by-one. With 200 rows + // worst case this takes ~30 s. + console.log("Patching..."); + let patched = 0; + let failed = 0; + for (const q of all) { + try { + await pb.collection("quotes").update(q.id, { validity_days: QUOTE_VALID_DAYS }); + patched++; + } catch (err) { + failed++; + console.error(` ✗ ${q.quote_number} (${q.id}) failed:`, err); + } + } + console.log(` ✓ Patched ${patched} quote(s).`); + if (failed > 0) { + console.error(` ✗ ${failed} quote(s) failed.`); + } + + // Re-query to confirm zero rows remain in the "needs-fixing" set. + const remaining = await pb.collection("quotes").getFullList({ + filter, + fields: "id", + }); + console.log(""); + console.log(`Repair complete. Quotes still in the "needs fixing" set: ${remaining.length}`); + if (remaining.length > 0) { + console.error( + "WARNING: some quotes still have validity_days = 0/null — investigate before re-running.", + ); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/components/item-intel-popover.tsx b/src/components/item-intel-popover.tsx index 684710a..4beb477 100644 --- a/src/components/item-intel-popover.tsx +++ b/src/components/item-intel-popover.tsx @@ -25,8 +25,10 @@ import { CheckCircle2, CircleDashed, } from "lucide-react"; +import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { supplierColor } from "@/lib/supplier-color"; +import getPocketBase from "@/lib/pocketbase"; import type { ItemIntel, ItemIntelVerified } from "@/lib/types"; interface Props { @@ -138,15 +140,28 @@ export function ItemIntelPopover({ ? `/api/v1/quote_items/${intel.item?.id ?? itemId}` : `/api/v1/order-items/${intel.item?.id ?? itemId}`); - /** PATCH the parent quote/order item with the given payload. */ - function applyPatch(patch: Record) { - fetch(resolvedPatchUrl, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }) - .then((r) => (r.ok ? onApplied?.(patch) : null)) - .catch(() => {}); + /** PATCH the parent quote/order item with the given payload via the + * user's PB session (so the browser's cookie auth works). The + * agent-authenticated `/api/v1/quote_items/[id]` route requires + * the AGENT_API_KEY bearer, which the browser doesn't carry. */ + async function applyPatch(patch: Record) { + const summary = + patch.description + ? String(patch.description) + : patch.cost_price + ? `cost R ${Number(patch.cost_price).toFixed(2)}` + : "pricing"; + try { + const pb = getPocketBase(); + const targetId = intel?.item?.id ?? itemId; + await pb.collection(collection).update(targetId, patch); + toast.success(`Applied ${summary}`); + onApplied?.(patch); + } catch (err) { + const message = + err instanceof Error ? err.message : "unknown error"; + toast.error(`Apply failed: ${message}`); + } } return ( diff --git a/src/lib/pdf/format.test.ts b/src/lib/pdf/format.test.ts index cd667b1..07c8ff7 100644 --- a/src/lib/pdf/format.test.ts +++ b/src/lib/pdf/format.test.ts @@ -120,6 +120,17 @@ describe("effectiveValidityDays", () => { it("falls back to QUOTE_VALID_DAYS (30) when undefined", () => { expect(effectiveValidityDays({})).toBe(QUOTE_VALID_DAYS); }); + // PB defaults an unset number field to 0 (not null). Pre-fix every + // quote shipped with validity_days=0, which the original `??` + // passed through and produced a "Valid until: today" PDF. + it("falls back to QUOTE_VALID_DAYS (30) when 0 (PocketBase default)", () => { + expect(effectiveValidityDays({ validity_days: 0 })).toBe(QUOTE_VALID_DAYS); + }); + it("falls back to QUOTE_VALID_DAYS (30) when negative", () => { + // Defensive — no real path writes this, but a misconfigured + // input shouldn't ever produce a pre-date expiry. + expect(effectiveValidityDays({ validity_days: -7 })).toBe(QUOTE_VALID_DAYS); + }); }); describe("poSubtotal", () => { diff --git a/src/lib/pdf/format.ts b/src/lib/pdf/format.ts index a3cd424..f92e0be 100644 --- a/src/lib/pdf/format.ts +++ b/src/lib/pdf/format.ts @@ -95,9 +95,18 @@ export function effectiveQuoteDate( * Effective validity period in days. Prefers `validity_days` on the * quote (lets the office extend to 2 months etc.), falls back to the * package default. + * + * Treats `0` as nullish as well as `null` / `undefined` because + * PocketBase defaults un-set number fields to `0` rather than `null`, + * so every quote created before this fix landed carries + * `validity_days = 0` even though no operator set it. The `??` + * operator alone would pass `0` through and produce a "Valid until: + * today" PDF. We use a strict positive check instead so missing / + * unset / zero all fall back to the package default. */ export function effectiveValidityDays( quote: { validity_days?: number | null } ): number { - return quote.validity_days ?? QUOTE_VALID_DAYS; + const v = quote.validity_days; + return typeof v === "number" && v > 0 ? v : QUOTE_VALID_DAYS; } \ No newline at end of file diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index ebf80a8..f6cf653 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -1,4 +1,5 @@ import { getAdminPocketBase } from "./pocketbase-admin"; +import { QUOTE_VALID_DAYS } from "@/lib/pdf/format"; import { PLACEHOLDER_CUSTOMER_IDS, PLACEHOLDER_CUSTOMER_NAMES, @@ -1155,6 +1156,14 @@ export async function createQuoteFromEmail( const quoteNumber = await generateQuoteNumberServer(); // ── 5. Create the quote. + // + // Default `validity_days` to `QUOTE_VALID_DAYS` (30) explicitly: + // PocketBase returns `0` for unset number fields, and the PDF + // formatter's `??` falls through on `0` — so every quote created + // before this fix landed shipped with a "Valid until: today" + // expiry. Writing the default here keeps new quotes consistent + // regardless of any old data. Legacy rows with `0` are patched by + // the one-time backfill in `scripts/migrations/...`. const quote = await pb.collection(QUOTES_COLLECTION).create({ quote_number: quoteNumber, customer: customer.id, @@ -1162,6 +1171,7 @@ export async function createQuoteFromEmail( description: payload.subject, status: "new", notes: "", + validity_days: QUOTE_VALID_DAYS, email_sender: fromEmail, email_subject: payload.subject, email_body: payload.body_plain || payload.body_html || "",