"""Build the v5 deployment ops for n8n MCP update_workflow. Run: python3 scripts/deploy_v5_to_n8n.py It builds the JSON for the update_workflow MCP call and invokes it. The operations are designed to be atomic — if any op fails, the whole workflow is left unchanged (per the MCP description: 'the whole batch is atomic'). """ import json import subprocess import sys from pathlib import Path WORKFLOW_ID = "eXSh623OjByIcip8" # v4 workflow in n8n N8N_URL = "http://n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io" # ============================================================================= # Node definitions (the 5 new nodes for v5) # ============================================================================= CODE_PLAN_UPLOAD_EARLY = '''// Plan: Upload Attachments Early (Catch and Create) // CATCH the email binary immediately after IMAP + auth, // BEFORE any downstream Code node can strip it. We: // 1. Walk every binary entry on the email // 2. Emit one item per attachment, each carrying the // data needed by the HTTP POST that uploads it // // Context source: the IMMEDIATE upstream $json is the PB // admin auth response ({ token, admin: {id} }) which has no // email context. We pull pb_url + message_id from // "PocketBase Connection" (the last node with full context). // pb_token comes from $json.token (the auth response itself). // // On no-attachments emails, emits one placeholder item // with no binary so the chain continues gracefully. const inputs = $input.all(); if (inputs.length === 0) { return [{ json: { _attachment_count: 0 }, binary: {} }]; } const ctx = $('PocketBase Connection').item.json || {}; const pbUrl = String(ctx.pb_url || ''); const pbToken = String(($json && $json.token) || ''); const pbAdminId = String(($json && $json.admin && $json.admin.id) || ''); const messageId = String(ctx.message_id || ''); const effectiveMessageId = String(ctx._effective_message_id || messageId); const out = []; let attachmentIndex = 0; for (const input of inputs) { const bin = input.binary || {}; for (const key of Object.keys(bin)) { const b = bin[key]; if (!b) continue; const filename = String(b.fileName || 'attachment'); const contentType = String(b.mimeType || 'application/octet-stream'); out.push({ json: { pb_url: pbUrl, pb_token: pbToken, pb_admin_id: pbAdminId, email_message_id: effectiveMessageId, filename: filename, content_type: contentType, size_bytes: Number(b.fileSize || 0), binary_key: key, _upload_index: attachmentIndex }, binary: { attachment: b } }); attachmentIndex++; } } if (out.length === 0) { // No attachments — emit placeholder so chain continues out.push({ json: { pb_url: pbUrl, pb_token: pbToken, pb_admin_id: pbAdminId, email_message_id: effectiveMessageId, filename: '', content_type: '', size_bytes: 0, binary_key: '__none__', _upload_index: 0, _no_attachments: true }, binary: {} }); } return out; ''' CODE_COLLECT_IDS = '''// Collect Attachment IDs // After N HTTP POSTs (one per attachment), each item has a // PB response with `id`. Aggregate them into one item with // an array of attachment record IDs, and merge back the // upstream email context so the chain can continue. // // Context source: read from "Plan: Upload Attachments Early" // because the immediate upstream $json is just the PB upload // response ({ id, collectionId, ... }) — no email context. const inputs = $input.all(); const ids = []; const details = []; let upstream = {}; try { upstream = $('Plan: Upload Attachments Early').first().json || {}; } catch (e) { upstream = {}; } const mergedBinary = {}; for (const input of inputs) { const j = input.json || {}; if (j.id) { ids.push(String(j.id)); details.push({ id: String(j.id), filename: upstream.filename || j.filename || '', content_type: upstream.content_type || j.content_type || '', size_bytes: upstream.size_bytes || j.size_bytes || 0, email_message_id: upstream.email_message_id || j.email_message_id || '' }); } if (input.binary) { for (const k of Object.keys(input.binary)) { if (!mergedBinary[k]) mergedBinary[k] = input.binary[k]; } } } return [{ json: Object.assign({}, upstream, { _attachment_record_ids: ids, _attachment_records: details, _attachment_count: ids.length }), binary: mergedBinary }]; ''' CODE_LINK_ATTACHMENTS = '''// Link Attachments to Quote // The PB attachment record IDs were captured EARLY in the // chain (right after IMAP). Now that the quote exists, // emit one item per attachment record so the next HTTP // PATCH can set `quote=` on each. // // On no-attachments emails, emit one placeholder with // _skip=true so downstream PATCH is skipped but the chain // (Mark has_attachments) still runs. const inputs = $input.all(); if (inputs.length === 0) { return [{ json: { _skip: true, reason: 'no upstream' } }]; } const ctx = inputs[0].json || {}; const quoteId = String(ctx.quote_id || ''); const ids = Array.isArray(ctx._attachment_record_ids) ? ctx._attachment_record_ids : []; if (!quoteId) { return [{ json: Object.assign({}, ctx, { _skip: true, reason: 'no quote_id' }) }]; } if (ids.length === 0) { return [{ json: Object.assign({}, ctx, { _skip: true, reason: 'no attachments to link', _attachment_record_ids: [] }) }]; } return ids.map(id => ({ json: { pb_url: ctx.pb_url || '', pb_token: ctx.pb_token || '', attachment_id: id, quote_id: quoteId, quote_number: ctx.quote_number || '' } })); ''' PARSE_PB_AUTH_FIXED = '''// Parse PB Auth response (FIXED for v5 Catch and Create) // Now preserves binary AND reads email context from // "Collect Attachment IDs" (which in v5 carries the // attachment IDs from the early upload + the original // email metadata). Original v4 read from "PocketBase // Connection" — still works as a fallback. // // After POST /api/admins/auth-with-password, the response is // { token, admin: {...} }. Pass the token through on // $json.pb_token so every downstream HTTP node can use it // as Bearer auth. const body = $json || {}; const token = String(body.token || ''); let ctx = {}; try { ctx = $('Collect Attachment IDs').first().json || {}; } catch (e) { ctx = $('PocketBase Connection').item.json || {}; } if (!token) { return { json: Object.assign({}, ctx, { pb_token: '', pb_auth_error: 'auth returned no token: ' + JSON.stringify(body) }), binary: $binary || {} }; } return { json: Object.assign({}, ctx, { pb_token: token, pb_admin_id: (body.admin && body.admin.id) ? body.admin.id : '' }), binary: $binary || {} }; ''' def make_code_node(name, jscode, position, version=2): return { "name": name, "type": "n8n-nodes-base.code", "typeVersion": version, "parameters": { "mode": "runOnceForAllItems" if "Plan: Upload" in name or "Collect Attachment" in name else "runOnceForEachItem", "jsCode": jscode, }, "position": position, "id": f"v5-{name.replace(' ', '-').replace(':', '').replace('(', '').replace(')', '').lower()}"[:36], } def make_http_node(name, url, content_type, body_params, position, method="POST", on_error="continueRegularOutput"): params = { "method": method, "url": url, "sendHeaders": True, "sendBody": True, } if content_type == "multipart": params["contentType"] = "multipart-form-data" params["bodyParameters"] = {"parameters": body_params} elif content_type == "json": params["specifyBody"] = "json" params["jsonBody"] = body_params else: params["jsonBody"] = body_params return { "name": name, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "parameters": params, "position": position, "onError": on_error, "id": f"v5-{name.replace(' ', '-').replace(':', '').replace('(', '').replace(')', '').lower()}"[:36], } def build_operations(): """Build the list of update_workflow operations.""" ops = [] # 1. Rename workflow ops.append({ "type": "setWorkflowMetadata", "name": "WFDASH — Quote Email Monitor (v5 catch-and-create)", "description": "Catch and Create: uploads attachments to PB immediately after auth (while binary is fresh), then PATCHes them to link to the quote after quote creation. Fixes v4's silent binary-stripping bug.", }) # 2. Add the 5 new nodes new_nodes = [ # Early branch (3 nodes, inserted between PB Admin Auth and Parse PB Auth) make_code_node("Plan: Upload Attachments Early", CODE_PLAN_UPLOAD_EARLY, [1120, 240]), make_http_node( "Upload Attachment Early", "={{ $json.pb_url }}/api/collections/quote_attachments/records", "multipart", [ {"name": "filename", "value": "={{ $json.filename }}"}, {"name": "content_type", "value": "={{ $json.content_type }}"}, {"name": "size_bytes", "value": "={{ $json.size_bytes }}"}, {"name": "email_message_id", "value": "={{ $json.email_message_id }}"}, {"name": "uploaded_at", "value": "={{ $now.toISO() }}"}, {"name": "file", "value": "={{ $binary.attachment }}", "inputDataFieldName": "attachment"}, ], [1344, 240], ), make_code_node("Collect Attachment IDs", CODE_COLLECT_IDS, [1568, 240]), # Late branch (2 nodes, replace broken Plan: Upload Attachments chain) make_code_node("Link Attachments to Quote", CODE_LINK_ATTACHMENTS, [6624, 560]), make_http_node( "PATCH Attachment (set quote)", "={{ $json.pb_url }}/api/collections/quote_attachments/records/{{ $json.attachment_id }}", "json", "={{ { \"quote\": $json.quote_id } }}", [6848, 560], method="PATCH", ), ] for n in new_nodes: ops.append({"type": "addNode", "node": n}) # 3. Detach old connections that pointed at the broken chain ops.append({"type": "removeConnection", "source": "PocketBase Admin Auth", "target": "Parse PB Auth"}) ops.append({"type": "removeConnection", "source": "Create Quote Item", "target": "Plan: Upload Attachments"}) # 4. Wire the new connections new_connections = [ # Early branch ("PocketBase Admin Auth", "Plan: Upload Attachments Early"), ("Plan: Upload Attachments Early", "Upload Attachment Early"), ("Upload Attachment Early", "Collect Attachment IDs"), ("Collect Attachment IDs", "Parse PB Auth"), # Late branch ("Create Quote Item", "Link Attachments to Quote"), ("Link Attachments to Quote", "PATCH Attachment (set quote)"), ("PATCH Attachment (set quote)", "Mark has_attachments"), ] for src, tgt in new_connections: ops.append({"type": "addConnection", "source": src, "target": tgt}) # 5. Disable the broken nodes (preserve them as rollback artifacts) ops.append({"type": "setNodeDisabled", "nodeName": "Plan: Upload Attachments", "disabled": True}) ops.append({"type": "setNodeDisabled", "nodeName": "Upload Attachment", "disabled": True}) # 6. Update Parse PB Auth to preserve binary + read from Collect Attachment IDs ops.append({ "type": "updateNodeParameters", "nodeName": "Parse PB Auth", "parameters": { "mode": "runOnceForEachItem", "jsCode": PARSE_PB_AUTH_FIXED, }, "replace": True, }) return ops def main(): ops = build_operations() print(f"Built {len(ops)} operations") for op in ops: if op["type"] in ("addNode", "addConnection", "removeConnection", "setNodeDisabled", "setWorkflowMetadata"): pass # compact else: print(f" {op['type']}: {op.get('nodeName', '?')}") payload = { "workflowId": WORKFLOW_ID, "operations": ops, } # Save for inspection out_path = Path("/tmp/v5_deploy_ops.json") out_path.write_text(json.dumps(payload, indent=2)) print(f"\nSaved ops to {out_path} ({out_path.stat().st_size} bytes)") # Optionally invoke MCP if "--apply" in sys.argv: print("\n=== Applying to n8n via MCP ===") result = subprocess.run( ["mavis", "mcp", "call", "n8n", "update_workflow", json.dumps(payload)], capture_output=True, text=True ) print("STDOUT:", result.stdout[:2000]) print("STDERR:", result.stderr[:500]) else: print("\n(Dry run. Re-run with --apply to deploy.)") if __name__ == "__main__": main()