{
  "name": "GrantOps Incoming Correspondence Triage - Portfolio Workflow Blueprint",
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true
  },
  "meta": {
    "portfolioProject": true,
    "interactivePrototypeScope": "interface and workflow logic",
    "liveWorkflowExecutable": false,
    "liveIntegrationsConnected": false,
    "syntheticData": true,
    "workflowNodeCount": 20,
    "attachmentRetries": 2,
    "attachmentHandling": "split supported attachments, extract each file, aggregate all extracted text, then call AI once",
    "projectLookup": "lookup returns projectFound, projectData and the complete original AI payload",
    "approvalMode": "Wait node resumed by webhook, then explicit approved/rejected branch",
    "technicalNotes": [
      "AI Structured Extraction запускается один раз после объединения письма и всех поддерживаемых вложений",
      "неподдерживаемые или повреждённые вложения направляют событие в Manual Queue",
      "после Lookup Project выполняется отдельная проверка Project Found?",
      "summary, deadline, required actions, required documents, risk, confidence, исходное письмо и AI payload сохраняются до Create Task & Event",
      "внешние аккаунты, credentials, база данных и AI endpoint не подключены"
    ]
  },
  "nodes": [
    {
      "id": "1",
      "name": "Gmail Trigger",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [0, 300],
      "parameters": {
        "pollTimes": {"item": [{"mode": "everyMinute"}]},
        "filters": {"q": "has:attachment OR from:(fund.example patent.example)"}
      },
      "credentials": {"gmailOAuth2": {"id": "REPLACE_ME", "name": "GrantOps Gmail"}}
    },
    {
      "id": "2",
      "name": "Normalize Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [240, 300],
      "parameters": {
        "jsCode": "const operationByExtension = { pdf: 'pdf', txt: 'text', md: 'text', csv: 'csv', json: 'json', html: 'html', htm: 'html', rtf: 'rtf', xls: 'xls', xlsx: 'xlsx', ods: 'ods' }; return items.map(item => { const binary = item.binary || {}; const allAttachments = Object.entries(binary).map(([key, file]) => { const fileName = file.fileName || key; const extension = (file.fileExtension || fileName.split('.').pop() || '').toLowerCase(); const operation = operationByExtension[extension] || null; return { key, fileName, mimeType: file.mimeType || null, extension, operation, supported: Boolean(operation) }; }); return { json: { messageId: item.json.id, from: item.json.from, subject: item.json.subject, body: item.json.textPlain || item.json.snippet || '', receivedAt: item.json.date, attachments: allAttachments.filter(a => a.supported), unsupportedAttachments: allAttachments.filter(a => !a.supported) }, binary }; });"
      }
    },
    {
      "id": "3",
      "name": "Has Attachments?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [480, 300],
      "parameters": {"conditions": {"boolean": [{"value1": "={{$json.attachments.length > 0}}", "value2": true}]}}
    },
    {
      "id": "4",
      "name": "Split Attachments",
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [720, 160],
      "parameters": {
        "fieldToSplitOut": "attachments",
        "include": "allOtherFields",
        "options": {"destinationFieldName": "attachment"}
      }
    },
    {
      "id": "5",
      "name": "Extract Text From Attachment",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [960, 160],
      "parameters": {
        "operation": "={{$json.attachment.operation}}",
        "binaryPropertyName": "={{$json.attachment.key}}",
        "options": {}
      },
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 1000,
      "onError": "continueErrorOutput"
    },
    {
      "id": "6",
      "name": "Aggregate Attachment Text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1200, 160],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const email = $('Normalize Email').first().json; const attachmentTexts = items.map((item, index) => ({ fileName: item.json.attachment?.fileName || `attachment-${index + 1}`, text: item.json.text || item.json.data || item.json.content || '' })); return [{ json: { ...email, attachmentTexts, attachmentText: attachmentTexts.map(x => `FILE: ${x.fileName}\\n${x.text}`).join('\\n\\n') } }];"
      }
    },
    {
      "id": "7",
      "name": "Merge Email + Attachment Text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1440, 300],
      "parameters": {
        "jsCode": "const current = $json; const email = current.messageId ? current : $('Normalize Email').first().json; const attachmentText = current.attachmentText || ''; return [{ json: { ...email, attachmentTexts: current.attachmentTexts || [], combinedText: [email.body, attachmentText].filter(Boolean).join('\\n\\n') } }];"
      }
    },
    {
      "id": "8",
      "name": "AI Structured Extraction",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1680, 300],
      "parameters": {
        "method": "POST",
        "url": "https://YOUR_LLM_GATEWAY/v1/extract",
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={\n  \"task\": \"grantops_correspondence_extraction\",\n  \"inputText\": {{ JSON.stringify($json.combinedText || $json.body || '') }},\n  \"messageId\": {{ JSON.stringify($json.messageId) }},\n  \"schema\": {\n    \"company\": \"string|null\",\n    \"project\": \"string|null\",\n    \"applicationNumber\": \"string|null\",\n    \"category\": \"string\",\n    \"deadline\": \"date|null\",\n    \"requiredActions\": \"string[]\",\n    \"requiredDocuments\": \"string[]\",\n    \"risk\": \"low|medium|high|critical\",\n    \"confidence\": \"number\",\n    \"summary\": \"string\",\n    \"suggestedReply\": \"string\"\n  }\n}"
      }
    },
    {
      "id": "9",
      "name": "Validate Extraction",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1920, 300],
      "parameters": {
        "jsCode": "const response = $json.body || $json; const aiExtraction = response.data || response; const email = $('Merge Email + Attachment Text').item.json; const errors = []; if (!aiExtraction.summary) errors.push('missing_summary'); if (typeof aiExtraction.confidence !== 'number') errors.push('invalid_confidence'); if (!aiExtraction.applicationNumber) errors.push('missing_application_number'); if (email.unsupportedAttachments?.length) errors.push('unsupported_attachments'); const manualReview = errors.length > 0 || aiExtraction.confidence < 0.75; return [{ json: { ...aiExtraction, sourceEmail: { messageId: email.messageId, from: email.from, subject: email.subject, body: email.body, receivedAt: email.receivedAt }, sourceText: email.combinedText, attachmentTexts: email.attachmentTexts, aiExtraction, validationErrors: errors, manualReview } }];"
      }
    },
    {
      "id": "10",
      "name": "Manual Review?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [2160, 300],
      "parameters": {"conditions": {"boolean": [{"value1": "={{$json.manualReview}}", "value2": true}]}}
    },
    {
      "id": "11",
      "name": "Lookup Project",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [2400, 160],
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT (p.id IS NOT NULL) AS \"projectFound\", to_jsonb(p) AS \"projectData\", $2::jsonb AS \"aiPayload\" FROM (SELECT 1) seed LEFT JOIN LATERAL (SELECT * FROM projects WHERE application_number = $1 LIMIT 1) p ON true;",
        "options": {"queryReplacement": "={{[$json.applicationNumber, JSON.stringify($json)]}}"}
      },
      "alwaysOutputData": true,
      "credentials": {"postgres": {"id": "REPLACE_ME", "name": "GrantOps DB"}}
    },
    {
      "id": "12",
      "name": "Project Found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [2640, 160],
      "parameters": {"conditions": {"boolean": [{"value1": "={{$json.projectFound}}", "value2": true}]}}
    },
    {
      "id": "13",
      "name": "Merge Project Data + Original AI Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [2880, 80],
      "parameters": {
        "jsCode": "const aiPayload = typeof $json.aiPayload === 'string' ? JSON.parse($json.aiPayload) : ($json.aiPayload || {}); return [{ json: { ...aiPayload, projectRecord: $json.projectData, sourceEmail: aiPayload.sourceEmail, aiExtraction: aiPayload.aiExtraction, lookup: { projectFound: true } } }];"
      }
    },
    {
      "id": "14",
      "name": "Create Task & Event",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [3120, 80],
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH created AS (INSERT INTO project_events (application_number, deadline, summary, payload) VALUES ($1, $2, $3, $4::jsonb) RETURNING id) SELECT created.id AS \"eventId\", $4::jsonb AS \"payload\" FROM created;",
        "options": {"queryReplacement": "={{[$json.applicationNumber, $json.deadline, $json.summary, JSON.stringify($json)]}}"}
      },
      "credentials": {"postgres": {"id": "REPLACE_ME", "name": "GrantOps DB"}}
    },
    {
      "id": "15",
      "name": "Telegram Notification",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [3360, 80],
      "parameters": {
        "chatId": "REPLACE_ME",
        "text": "=Новая задача по {{$json.payload.applicationNumber}}. Дедлайн: {{$json.payload.deadline}}. Риск: {{$json.payload.risk}}. Решение: POST {{$execution.resumeUrl}} с body {\"decision\":\"approved\"} или {\"decision\":\"rejected\"}.",
        "additionalFields": {}
      },
      "credentials": {"telegramApi": {"id": "REPLACE_ME", "name": "GrantOps Telegram"}}
    },
    {
      "id": "16",
      "name": "Wait for Approval",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [3600, 80],
      "parameters": {
        "resume": "webhook",
        "httpMethod": "POST",
        "responseMode": "onReceived",
        "limitWaitTime": true,
        "limitType": "afterTimeInterval",
        "resumeAmount": 48,
        "resumeUnit": "hours",
        "options": {"webhookSuffix": "grantops-approval"}
      }
    },
    {
      "id": "17",
      "name": "Approval Decision",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [3840, 80],
      "parameters": {"conditions": {"string": [{"value1": "={{$json.body.decision}}", "operation": "equal", "value2": "approved"}]}}
    },
    {
      "id": "18",
      "name": "Write Audit Log",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [4080, 80],
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO audit_log (event_type, payload) VALUES ('grantops_decision', $1::jsonb) RETURNING id;",
        "options": {"queryReplacement": "={{[JSON.stringify($json.payload || $json)]}}"}
      },
      "credentials": {"postgres": {"id": "REPLACE_ME", "name": "GrantOps DB"}}
    },
    {
      "id": "19",
      "name": "Manual Queue",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [2880, 420],
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH queued AS (INSERT INTO manual_queue (payload) VALUES ($1::jsonb) RETURNING id) SELECT queued.id AS \"queueId\", $1::jsonb AS \"payload\" FROM queued;",
        "options": {"queryReplacement": "={{[JSON.stringify($json.aiPayload || $json.payload || $json)]}}"}
      },
      "credentials": {"postgres": {"id": "REPLACE_ME", "name": "GrantOps DB"}}
    },
    {
      "id": "20",
      "name": "Fallback Notification",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [3120, 420],
      "parameters": {
        "chatId": "REPLACE_ME",
        "text": "=Письмо или решение отправлено в ручную очередь. Причина: {{$json.payload.validationErrors || $json.payload.error || $json.payload.body?.decision || 'project not found / extraction error / rejected'}}",
        "additionalFields": {}
      },
      "credentials": {"telegramApi": {"id": "REPLACE_ME", "name": "GrantOps Telegram"}}
    }
  ],
  "connections": {
    "Gmail Trigger": {"main": [[{"node": "Normalize Email", "type": "main", "index": 0}]]},
    "Normalize Email": {"main": [[{"node": "Has Attachments?", "type": "main", "index": 0}]]},
    "Has Attachments?": {"main": [[{"node": "Split Attachments", "type": "main", "index": 0}], [{"node": "Merge Email + Attachment Text", "type": "main", "index": 0}]]},
    "Split Attachments": {"main": [[{"node": "Extract Text From Attachment", "type": "main", "index": 0}]]},
    "Extract Text From Attachment": {"main": [[{"node": "Aggregate Attachment Text", "type": "main", "index": 0}], [{"node": "Manual Queue", "type": "main", "index": 0}]]},
    "Aggregate Attachment Text": {"main": [[{"node": "Merge Email + Attachment Text", "type": "main", "index": 0}]]},
    "Merge Email + Attachment Text": {"main": [[{"node": "AI Structured Extraction", "type": "main", "index": 0}]]},
    "AI Structured Extraction": {"main": [[{"node": "Validate Extraction", "type": "main", "index": 0}]]},
    "Validate Extraction": {"main": [[{"node": "Manual Review?", "type": "main", "index": 0}]]},
    "Manual Review?": {"main": [[{"node": "Manual Queue", "type": "main", "index": 0}], [{"node": "Lookup Project", "type": "main", "index": 0}]]},
    "Lookup Project": {"main": [[{"node": "Project Found?", "type": "main", "index": 0}]]},
    "Project Found?": {"main": [[{"node": "Merge Project Data + Original AI Payload", "type": "main", "index": 0}], [{"node": "Manual Queue", "type": "main", "index": 0}]]},
    "Merge Project Data + Original AI Payload": {"main": [[{"node": "Create Task & Event", "type": "main", "index": 0}]]},
    "Create Task & Event": {"main": [[{"node": "Telegram Notification", "type": "main", "index": 0}]]},
    "Telegram Notification": {"main": [[{"node": "Wait for Approval", "type": "main", "index": 0}]]},
    "Wait for Approval": {"main": [[{"node": "Approval Decision", "type": "main", "index": 0}]]},
    "Approval Decision": {"main": [[{"node": "Write Audit Log", "type": "main", "index": 0}], [{"node": "Manual Queue", "type": "main", "index": 0}]]},
    "Manual Queue": {"main": [[{"node": "Fallback Notification", "type": "main", "index": 0}]]},
    "Fallback Notification": {"main": [[{"node": "Write Audit Log", "type": "main", "index": 0}]]}
  },
  "pinData": {},
  "tags": ["portfolio", "grantops", "ai-automation", "workflow-blueprint"]
}
