fix: correct quote expiry handling for PocketBase's 0 default

PocketBase returns 0 for unset number fields, which broke the original
null-coalescing check in effectiveValidityDays, leading to PDFs showing
"Valid until: today" for all new quotes created via email ingest.

Changes include:
- Update effectiveValidityDays to treat 0, negatives, null/undefined as falling back to 30-day default
- Add test cases for 0 and negative validity_days inputs
- Explicitly set validity_days to QUOTE_VALID_DAYS when creating new quotes
- Add one-time idempotent migration script to backfill legacy invalid validity values
- Refactor item intel popover patch logic to use PocketBase SDK for authenticated requests
- Add n8n workflow for AI-powered item insight matching
This commit is contained in:
Dave 2026-08-11 08:32:17 +02:00
parent 608a6ab978
commit 397df8e2b2
7 changed files with 943 additions and 10 deletions

View file

@ -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": []
}

View file

@ -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": []
}

View file

@ -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);
});

View file

@ -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<string, unknown>) {
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<string, unknown>) {
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 (

View file

@ -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", () => {

View file

@ -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;
}

View file

@ -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 || "",