WFDASH-WEBAPP/scripts/deploy_v5_to_n8n.py
SJCoetz 7df7a8850f feat(item-history): add getItemHistory service with 5 PB queries
Co-authored-by: Orca <help@stably.ai>
2026-07-29 14:51:58 +02:00

384 lines
13 KiB
Python

"""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=<quote_id>` 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()