This commit implements the full inbound quote email processing workflow and atomic quote numbering: - Add PocketBase collections for monthly atomic quote counters, email attachments, and threaded replies - Add idempotent migration script to set up new collections and add deduplication field to existing quotes table - Implement `/api/v1/quotes/next-number` endpoint for properly formatted Q-YYYY-MM-NNN quote numbers - Add MCP tools for idempotent quote creation, reply threading, and message ID lookup - Add end-to-end and concurrent smoke tests for pipeline validation - Update TypeScript types for new quote-related entities - Update project documentation and package.json scripts - Mark quote email intake as complete in the parity backlog - Add n8n v2 workflows for automated email monitoring and error alerts
384 lines
13 KiB
Python
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()
|