{
  "id": "v41nqKecTo3htFFl",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Generate monthly BigQuery KPI PDF reports with Claude, Google Docs and Outlook",
  "tags": [],
  "nodes": [
    {
      "id": "d440d2d7-9347-4ff8-a92d-bcdc04a57c1d",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        6704,
        224
      ],
      "parameters": {
        "width": 432,
        "height": 864,
        "content": "##Generate monthly BigQuery KPI PDF reports with Claude, Google Docs and Outlook\n\n### How it works\n\nThis workflow runs monthly to generate a KPI report for the previous full month. It queries BigQuery for revenue, category, weekly trend, and customer metrics, consolidates the results, and uses Claude to create a narrative summary. It copies a Google Docs template, fills it with KPI data and the AI narrative, exports the document as a PDF, then archives it to OneDrive, emails it through Outlook, and posts a Teams summary.\n\n### Setup steps\n\n- Configure the monthly schedule trigger for the desired reporting time and timezone.\n- Connect Google BigQuery credentials and update the SQL queries, dataset, table names, and date filters used by the KPI query nodes.\n- Configure Anthropic credentials for the Claude chat model used by the LLM chain.\n- Connect Google Drive and Google Docs credentials, and set the report template file ID plus any destination folder IDs needed for copying and exporting.\n- Connect Microsoft OneDrive, Outlook, and Teams credentials, then configure archive locations, email recipients, and Teams channel or chat targets.\n\n### Customization\n\nAdjust the BigQuery queries, template placeholders, Claude prompt, email recipients, and Teams card content to match the KPIs and audience for the report."
      },
      "typeVersion": 1
    },
    {
      "id": "3449e3d8-f812-4316-ae48-78565793309b",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        7200,
        448
      ],
      "parameters": {
        "color": 7,
        "width": 576,
        "height": 384,
        "content": "## Start reporting period\n\nTriggers the workflow on the monthly schedule and calculates the previous full month as the reporting window used by all downstream queries."
      },
      "typeVersion": 1
    },
    {
      "id": "b1c755d5-2a0c-4e6d-8b0d-4fbbc3eb8e99",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        7824,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Fetch revenue KPIs\n\nRuns the revenue KPI query in BigQuery and tags those rows with their source before merging."
      },
      "typeVersion": 1
    },
    {
      "id": "64eecea3-b5df-4609-9ef1-04e80145b790",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        7824,
        368
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Fetch category rankings\n\nQueries top product or business categories from BigQuery and labels the returned category results for later aggregation."
      },
      "typeVersion": 1
    },
    {
      "id": "1a879d20-27c4-4b6a-a60c-a5fdfb7de55c",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        7824,
        720
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Fetch weekly trend\n\nRetrieves week-by-week trend data from BigQuery and tags the weekly results with their source query identifier."
      },
      "typeVersion": 1
    },
    {
      "id": "58ed5879-7e27-465a-9c61-9a4578dd0393",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        7824,
        1072
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Fetch top customers\n\nQueries top customer metrics from BigQuery and annotates the customer rows so they can be separated after the merge."
      },
      "typeVersion": 1
    },
    {
      "id": "7c752f98-2c2b-4e64-a8f0-d7f39f120a00",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        8320,
        432
      ],
      "parameters": {
        "color": 7,
        "width": 656,
        "height": 400,
        "content": "## Consolidate report data\n\nCombines all tagged BigQuery outputs, aggregates them into a structured report payload, and explicitly carries that payload forward for both narrative and document creation branches."
      },
      "typeVersion": 1
    },
    {
      "id": "1ab5cc2e-23f5-410e-bee4-d154d770d26a",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        9040,
        64
      ],
      "parameters": {
        "color": 7,
        "width": 608,
        "height": 576,
        "content": "## Generate Claude narrative\n\nSends the report payload to Claude through the LLM chain, then parses and merges the generated narrative back into the report data structure.\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "21eef0ce-2e07-43c9-9450-81776ba3111c",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        9040,
        688
      ],
      "parameters": {
        "color": 7,
        "width": 608,
        "height": 352,
        "content": "## Copy report template\n\nCreates a new Google Drive copy of the report template in the lower document-preparation branch.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "43d21119-9042-4890-b589-46ab9423bd0d",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        9696,
        432
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 384,
        "content": "## Prepare template replacements\n\nWaits for both the Claude-enhanced report data and copied document ID, then builds the Google Docs replacement requests for template placeholders."
      },
      "typeVersion": 1
    },
    {
      "id": "71b041f9-4b5c-49ee-8070-3348b1859320",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10144,
        432
      ],
      "parameters": {
        "color": 7,
        "width": 448,
        "height": 384,
        "content": "## Fill and export PDF\n\nApplies the replacement requests to the copied Google Doc and exports the completed report as a PDF through Google Drive.\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "21816bb9-9626-48cc-b4b8-b6a0fdf50942",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10640,
        368
      ],
      "parameters": {
        "color": 7,
        "width": 608,
        "height": 512,
        "content": "## Distribute finished report\n\nArchives the generated PDF to OneDrive, emails it through Outlook, and posts a summary notification to Microsoft Teams.\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "af873869-33b2-4bad-abe3-2c6b1b5053fb",
      "name": "When First of Month at 07:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "notes": "Runs on the 1st of every month at 07:00 UTC. Change expression to weekly (0 7 * * 1) or daily (0 7 * * *) as needed.",
      "position": [
        7312,
        640
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 1 * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "e54f406c-cc83-4f74-8855-e75e8b8b5011",
      "name": "Calculate Previous Month",
      "type": "n8n-nodes-base.code",
      "notes": "Calculates start/end dates for the previous full calendar month and generates a collision-safe report ID using crypto.randomUUID().",
      "position": [
        7536,
        640
      ],
      "parameters": {
        "jsCode": "const now = new Date();\nconst firstOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);\nconst lastOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0);\nconst fmt = (d) => d.toISOString().split('T')[0];\nconst startDate = fmt(firstOfLastMonth);\nconst endDate = fmt(lastOfLastMonth);\nconst periodSlug = startDate.replace(/-/g, '');\nconst reportId = `RPT-${periodSlug}-${crypto.randomUUID().split('-')[0].toUpperCase()}`;\nreturn [{ json: {\n  label: firstOfLastMonth.toLocaleString('en-GB', { month: 'long', year: 'numeric' }),\n  startDate,\n  endDate,\n  reportId,\n  generatedAt: now.toISOString()\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "e69b456f-8ba7-463b-8f1a-4b01f81daf5e",
      "name": "Fetch Revenue KPIs",
      "type": "n8n-nodes-base.googleBigQuery",
      "notes": "Returns one aggregated row. Credential: Google BigQuery. Env vars: GCP_PROJECT_ID, BQ_DATASET. Update table name if not 'orders'.",
      "position": [
        7872,
        160
      ],
      "parameters": {
        "options": {
          "location": "EU"
        },
        "sqlQuery": "SELECT\n  COUNT(*) AS total_orders,\n  SUM(order_total) AS total_revenue,\n  AVG(order_total) AS avg_order_value,\n  COUNT(DISTINCT customer_id) AS unique_customers,\n  COUNTIF(is_new_customer = TRUE) AS new_customers,\n  COUNTIF(is_new_customer = FALSE) AS returning_customers\nFROM `{{ $env.BQ_DATASET }}.orders`\nWHERE DATE(created_at) BETWEEN @start_date AND @end_date",
        "projectId": "={{ $env.GCP_PROJECT_ID }}"
      },
      "credentials": {
        "googleBigQueryOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "23e4cade-1863-4d59-94a2-03beeb73ce42",
      "name": "Fetch Top Sales Categories",
      "type": "n8n-nodes-base.googleBigQuery",
      "notes": "Returns up to 10 rows, one per category. Credential: Google BigQuery.",
      "position": [
        7872,
        528
      ],
      "parameters": {
        "options": {
          "location": "EU"
        },
        "sqlQuery": "SELECT\n  product_category,\n  COUNT(*) AS orders,\n  SUM(order_total) AS revenue,\n  ROUND(100.0 * SUM(order_total) / SUM(SUM(order_total)) OVER (), 1) AS revenue_pct\nFROM `{{ $env.BQ_DATASET }}.orders`\nWHERE DATE(created_at) BETWEEN @start_date AND @end_date\nGROUP BY product_category\nORDER BY revenue DESC\nLIMIT 10",
        "projectId": "={{ $env.GCP_PROJECT_ID }}"
      },
      "typeVersion": 2
    },
    {
      "id": "8ed3cf3a-be2d-44c1-b85c-b027c6f05b09",
      "name": "Fetch Weekly Sales Trend",
      "type": "n8n-nodes-base.googleBigQuery",
      "notes": "Returns one row per week in the reporting period. Credential: Google BigQuery.",
      "position": [
        7872,
        880
      ],
      "parameters": {
        "options": {
          "location": "EU"
        },
        "sqlQuery": "SELECT\n  DATE_TRUNC(DATE(created_at), WEEK) AS week_start,\n  COUNT(*) AS orders,\n  SUM(order_total) AS weekly_revenue\nFROM `{{ $env.BQ_DATASET }}.orders`\nWHERE DATE(created_at) BETWEEN @start_date AND @end_date\nGROUP BY week_start\nORDER BY week_start ASC",
        "projectId": "={{ $env.GCP_PROJECT_ID }}"
      },
      "credentials": {
        "googleBigQueryOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "bce15ffd-f877-4f9e-aab0-cf1896cd10a7",
      "name": "Fetch Top Customers",
      "type": "n8n-nodes-base.googleBigQuery",
      "notes": "Returns up to 10 rows, one per customer. Credential: Google BigQuery.",
      "position": [
        7872,
        1232
      ],
      "parameters": {
        "options": {
          "location": "EU"
        },
        "sqlQuery": "SELECT\n  customer_name,\n  COUNT(*) AS orders,\n  SUM(order_total) AS total_spent\nFROM `{{ $env.BQ_DATASET }}.orders`\nWHERE DATE(created_at) BETWEEN @start_date AND @end_date\nGROUP BY customer_name\nORDER BY total_spent DESC\nLIMIT 10",
        "projectId": "={{ $env.GCP_PROJECT_ID }}"
      },
      "typeVersion": 2
    },
    {
      "id": "9a848455-3aee-475e-96fb-2de6d2f33b35",
      "name": "Label Revenue Data",
      "type": "n8n-nodes-base.code",
      "position": [
        8096,
        160
      ],
      "parameters": {
        "jsCode": "return $input.all().map(item => ({ json: { ...item.json, _sourceQuery: 'revenue' } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "70c8feb8-bdd9-4582-b067-e8440a4ee6a8",
      "name": "Label Category Data",
      "type": "n8n-nodes-base.code",
      "position": [
        8096,
        528
      ],
      "parameters": {
        "jsCode": "return $input.all().map(item => ({ json: { ...item.json, _sourceQuery: 'categories' } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "55eeee38-576a-4d03-8208-9de2d81320c8",
      "name": "Label Weekly Data",
      "type": "n8n-nodes-base.code",
      "position": [
        8096,
        880
      ],
      "parameters": {
        "jsCode": "return $input.all().map(item => ({ json: { ...item.json, _sourceQuery: 'weekly' } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "3fc6878b-dccd-402a-8068-ea3fc1075d53",
      "name": "Label Customer Data",
      "type": "n8n-nodes-base.code",
      "position": [
        8096,
        1232
      ],
      "parameters": {
        "jsCode": "return $input.all().map(item => ({ json: { ...item.json, _sourceQuery: 'customers' } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "c962ffc4-9024-4447-86fb-9d65fa780c58",
      "name": "Merge BigQuery Results",
      "type": "n8n-nodes-base.merge",
      "notes": "mode=append preserves ALL rows from all 4 branches. waitForAll (the default) would truncate multi-row results to 1 row per branch, emptying topCategories, weeklyTrend, and topCustomers arrays.",
      "position": [
        8384,
        592
      ],
      "parameters": {
        "numberInputs": 4
      },
      "typeVersion": 3
    },
    {
      "id": "326deef6-f208-43d8-ba78-1a2ed5ebe942",
      "name": "Consolidate BigQuery Data",
      "type": "n8n-nodes-base.code",
      "position": [
        8608,
        624
      ],
      "parameters": {
        "jsCode": "const period = $('Calculate Previous Month').first().json;\nconst allItems = $input.all().map(i => i.json);\nconst revenueRow   = allItems.find(i => i._sourceQuery === 'revenue') || {};\nconst categoryRows = allItems.filter(i => i._sourceQuery === 'categories');\nconst weeklyRows   = allItems.filter(i => i._sourceQuery === 'weekly');\nconst customerRows = allItems.filter(i => i._sourceQuery === 'customers');\nconst eur = (v) => `EUR ${Number(v || 0).toLocaleString('en-FI', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;\nconst payload = {\n  report: {\n    id: period.reportId,\n    period: period.label,\n    startDate: period.startDate,\n    endDate: period.endDate,\n    generatedAt: period.generatedAt,\n    generatedBy: 'AutomiQ BigQuery Report Agent'\n  },\n  company: {\n    name: 'AutomiQ / KW Catering & Consulting Oy',\n    website: 'automiq.fi',\n    preparedBy: 'Mychel Garzon'\n  },\n  kpis: {\n    totalOrders:        Number(revenueRow.total_orders || 0),\n    totalRevenue:       eur(revenueRow.total_revenue),\n    totalRevenueRaw:    Number(revenueRow.total_revenue || 0),\n    avgOrderValue:      eur(revenueRow.avg_order_value),\n    uniqueCustomers:    Number(revenueRow.unique_customers || 0),\n    newCustomers:       Number(revenueRow.new_customers || 0),\n    returningCustomers: Number(revenueRow.returning_customers || 0),\n    newCustomerPct: revenueRow.unique_customers > 0\n      ? Math.round((revenueRow.new_customers / revenueRow.unique_customers) * 100) + '%'\n      : '0%'\n  },\n  topCategories: categoryRows.map(r => ({\n    category:   r.product_category,\n    orders:     Number(r.orders),\n    revenue:    eur(r.revenue),\n    revenuePct: r.revenue_pct + '%'\n  })),\n  weeklyTrend: weeklyRows.map(r => ({\n    weekStart: r.week_start,\n    orders:    Number(r.orders),\n    revenue:   eur(r.weekly_revenue)\n  })),\n  topCustomers: customerRows.map(r => ({\n    name:       r.customer_name,\n    orders:     Number(r.orders),\n    totalSpent: eur(r.total_spent)\n  }))\n};\nreturn [{ json: payload }];"
      },
      "typeVersion": 2
    },
    {
      "id": "a0892539-ffad-4ec0-b4fb-5c89efa2bd0a",
      "name": "Transfer Report Data",
      "type": "n8n-nodes-base.code",
      "notes": "Wraps the full report payload under a 'reportData' key so downstream nodes can reference it via $json.reportData without cross-branch node lookups.",
      "position": [
        8832,
        624
      ],
      "parameters": {
        "jsCode": "const reportData = $input.first().json;\nreturn [{ json: { reportData } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "4887c977-e14b-463d-b6fc-55e22c81f10c",
      "name": "Create AI Narrative with Claude",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "notes": "Basic LLM Chain. The Claude Chat Model sub-node must be connected to this node's ai_languageModel input (bottom connector on canvas). Output: { text: '<JSON string>' } parsed by Integrate Narrative with Data.",
      "position": [
        9088,
        272
      ],
      "parameters": {},
      "typeVersion": 1.4
    },
    {
      "id": "c2059374-e4c8-4772-8d13-c1fc8b1f35fa",
      "name": "Integrate Narrative with Data",
      "type": "n8n-nodes-base.code",
      "notes": "Parses Claude's JSON response and merges the narrative into the report payload. Falls back to KPI-derived text if Claude returns invalid JSON or is unavailable.",
      "position": [
        9440,
        272
      ],
      "parameters": {
        "jsCode": "const chainOutput = $input.first().json;\nconst reportData = $('Transfer Report Data').first().json.reportData;\nconst rawText = chainOutput.text || chainOutput.output || chainOutput.content?.[0]?.text || '';\nlet narrative;\nif (rawText) {\n  try {\n    const cleaned = rawText.replace(/```json|```/g, '').trim();\n    narrative = JSON.parse(cleaned);\n  } catch(e) {\n    narrative = null;\n  }\n}\nif (!narrative) {\n  narrative = {\n    executiveSummary: `KPI report for ${reportData.report.period}. Total revenue: ${reportData.kpis.totalRevenue}. Total orders: ${reportData.kpis.totalOrders}. AI narrative unavailable this cycle.`,\n    revenueAnalysis: `Revenue: ${reportData.kpis.totalRevenue}. Avg order value: ${reportData.kpis.avgOrderValue}.`,\n    categoryInsights: 'See category breakdown table.',\n    customerInsights: `${reportData.kpis.uniqueCustomers} unique customers. New: ${reportData.kpis.newCustomerPct}.`,\n    recommendation: 'AI narrative could not be generated. Review KPI data manually.'\n  };\n}\nreportData.narrative = narrative;\nreturn [{ json: reportData }];"
      },
      "typeVersion": 2
    },
    {
      "id": "9d6b760c-ac53-4f3e-8f61-052210fc5138",
      "name": "Claude Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "notes": "Anthropic Chat Model sub-node. Credential: Anthropic API (native n8n credential \u2014 paste your API key in the credential form). Connect this node's output to the ai_languageModel input (bottom socket) of Create AI Narrative with Claude.",
      "position": [
        9088,
        496
      ],
      "parameters": {
        "model": "claude-sonnet-4-6",
        "options": {
          "temperature": 0.3
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "c2d77142-57aa-4a6f-930f-feb8a6d541b7",
      "name": "Duplicate Report Template",
      "type": "n8n-nodes-base.googleDrive",
      "notes": "Copies the master Google Docs template to your reports folder. The original is never modified. Credential: Google Drive OAuth2. Env vars: GDOCS_TEMPLATE_FILE_ID (Drive file ID from the Doc URL), GDRIVE_REPORTS_FOLDER_ID (destination folder ID).",
      "position": [
        9264,
        864
      ],
      "parameters": {
        "name": "=KPI_Report_{{ $('Integrate Narrative with Data').first().json.report.period.replace(/ /g, '_') }}_{{ $('Integrate Narrative with Data').first().json.report.id }}",
        "fileId": "={{ $env.GDOCS_TEMPLATE_FILE_ID }}",
        "options": {},
        "operation": "copy"
      },
      "typeVersion": 3
    },
    {
      "id": "933b3767-6e97-413d-99dc-a9287966fef5",
      "name": "Combine Narrative and Doc ID",
      "type": "n8n-nodes-base.merge",
      "notes": "Waits for both concurrent branches: input[0] = narrative-enriched report payload from Integrate Narrative with Data, input[1] = Drive copy response containing the new Doc ID from Duplicate Report Template. Ensures Construct Replacement Requests fires exactly once with both data sets available.",
      "position": [
        9744,
        624
      ],
      "parameters": {
        "mode": "waitForAll"
      },
      "typeVersion": 3
    },
    {
      "id": "69e9d44b-3aaf-4122-97e2-29cf9b09ce69",
      "name": "Construct Replacement Requests",
      "type": "n8n-nodes-base.code",
      "notes": "Builds the Docs API batchUpdate payload. Each {{PLACEHOLDER}} in your template Doc becomes a replaceAllText request. Tables render as monospace plain text. copiedDocId is passed forward for the Populate and Export nodes.",
      "position": [
        9968,
        624
      ],
      "parameters": {
        "jsCode": "const allItems = $input.all();\nconst reportData = allItems.find(i => i.json.report)?.json;\nconst copiedDocId = allItems.find(i => i.json.id && !i.json.report)?.json.id;\nif (!reportData) throw new Error('Construct Replacement Requests: reportData not found in merged input');\nif (!copiedDocId) throw new Error('Construct Replacement Requests: copiedDocId not found in merged input');\n\nconst tableText = (headers, rows, cols) => {\n  const pad = (s, n) => String(s).slice(0, n).padEnd(n);\n  const widths = cols.map((c, i) => Math.max(headers[i].length, ...rows.map(r => String(r[c] || '').length)));\n  const hr = widths.map(w => '-'.repeat(w + 2)).join('+');\n  const row = (cells) => cells.map((c, i) => ' ' + pad(c, widths[i]) + ' ').join('|');\n  return [row(headers), hr, ...rows.map(r => row(cols.map(c => r[c] || '')))].join('\\n');\n};\n\nconst categoriesTable = tableText(\n  ['Category', 'Orders', 'Revenue', 'Share'],\n  reportData.topCategories,\n  ['category', 'orders', 'revenue', 'revenuePct']\n);\nconst weeklyTable = tableText(\n  ['Week Starting', 'Orders', 'Revenue'],\n  reportData.weeklyTrend,\n  ['weekStart', 'orders', 'revenue']\n);\nconst customersTable = tableText(\n  ['Customer', 'Orders', 'Total Spent'],\n  reportData.topCustomers,\n  ['name', 'orders', 'totalSpent']\n);\n\nconst replacements = [\n  ['{{REPORT_PERIOD}}',           reportData.report.period],\n  ['{{REPORT_ID}}',               reportData.report.id],\n  ['{{GENERATED_AT}}',            reportData.report.generatedAt],\n  ['{{KPI_TOTAL_REVENUE}}',       reportData.kpis.totalRevenue],\n  ['{{KPI_TOTAL_ORDERS}}',        String(reportData.kpis.totalOrders)],\n  ['{{KPI_AVG_ORDER}}',           reportData.kpis.avgOrderValue],\n  ['{{KPI_UNIQUE_CUSTOMERS}}',    String(reportData.kpis.uniqueCustomers)],\n  ['{{KPI_NEW_CUSTOMERS}}',       String(reportData.kpis.newCustomers)],\n  ['{{KPI_NEW_PCT}}',             reportData.kpis.newCustomerPct],\n  ['{{NARRATIVE_SUMMARY}}',       reportData.narrative.executiveSummary],\n  ['{{NARRATIVE_REVENUE}}',       reportData.narrative.revenueAnalysis],\n  ['{{NARRATIVE_CATEGORIES}}',    reportData.narrative.categoryInsights],\n  ['{{NARRATIVE_CUSTOMERS}}',     reportData.narrative.customerInsights],\n  ['{{NARRATIVE_RECOMMENDATION}}',reportData.narrative.recommendation],\n  ['{{TOP_CATEGORIES_TABLE}}',    categoriesTable],\n  ['{{WEEKLY_TREND_TABLE}}',      weeklyTable],\n  ['{{TOP_CUSTOMERS_TABLE}}',     customersTable]\n];\n\nconst requests = replacements.map(([placeholder, newText]) => ({\n  replaceAllText: {\n    containsText: { text: placeholder, matchCase: true },\n    replaceText: newText || ''\n  }\n}));\n\nreturn [{ json: { copiedDocId, requests } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "53f1c68f-a798-48e2-96ca-badb7dd354a9",
      "name": "Populate Template Fields",
      "type": "n8n-nodes-base.googleDocs",
      "notes": "Calls Google Docs batchUpdate to replace all {{PLACEHOLDER}} strings in the copied Doc. Requires typeVersion 2 for the requestsJson parameter. Credential: Google Docs OAuth2 (scope: https://www.googleapis.com/auth/documents).",
      "position": [
        10192,
        624
      ],
      "parameters": {
        "actionsUi": {
          "actionFields": [
            {
              "action": "insert"
            }
          ]
        },
        "operation": "update"
      },
      "credentials": {
        "googleDocsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "eb82940f-2ffc-4a72-88b0-3ce03094929a",
      "name": "Export Document to PDF",
      "type": "n8n-nodes-base.googleDrive",
      "notes": "Exports the populated Google Doc as PDF using Drive's native file conversion. Binary stored as 'report_pdf' \u2014 the same property name expected by Save Report to OneDrive and Send Report via Outlook. Credential: Google Drive OAuth2.",
      "position": [
        10416,
        624
      ],
      "parameters": {
        "fileId": "={{ $('Construct Replacement Requests').first().json.copiedDocId }}",
        "options": {
          "googleFileConversion": {
            "conversion": {
              "docsToFormat": "application/pdf"
            }
          }
        },
        "operation": "download"
      },
      "typeVersion": 3
    },
    {
      "id": "daf0224e-e93c-4acb-b00a-6e1fcad17bad",
      "name": "Save Report to OneDrive",
      "type": "n8n-nodes-base.microsoftOneDrive",
      "notes": "Archives the generated PDF to OneDrive. Credential: Microsoft OneDrive OAuth2. Env var: ONEDRIVE_REPORTS_FOLDER_ID (OneDrive folder GUID).",
      "position": [
        10864,
        528
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "9616bf10-b5cc-4de8-bcd8-54d169f1aa96",
      "name": "Send Report via Outlook",
      "type": "n8n-nodes-base.microsoftOutlook",
      "notes": "Emails the PDF report to all recipients. Credential: Microsoft Outlook OAuth2. Env var: REPORT_RECIPIENTS (comma-separated email addresses).",
      "position": [
        10752,
        720
      ],
      "parameters": {
        "subject": "=Monthly KPI Report \u2013 {{ $('Integrate Narrative with Data').first().json.report.period }} | {{ $('Integrate Narrative with Data').first().json.report.id }}",
        "bodyContent": "=<h2>Monthly KPI Report \u2013 {{ $('Integrate Narrative with Data').first().json.report.period }}</h2>\n<p>{{ $('Integrate Narrative with Data').first().json.narrative.executiveSummary }}</p>\n<p>Please find the full PDF report attached.</p>\n<hr/>\n<h3>Key Highlights</h3>\n<ul>\n  <li><strong>Total Revenue:</strong> {{ $('Integrate Narrative with Data').first().json.kpis.totalRevenue }}</li>\n  <li><strong>Total Orders:</strong> {{ $('Integrate Narrative with Data').first().json.kpis.totalOrders }}</li>\n  <li><strong>Avg Order Value:</strong> {{ $('Integrate Narrative with Data').first().json.kpis.avgOrderValue }}</li>\n  <li><strong>Unique Customers:</strong> {{ $('Integrate Narrative with Data').first().json.kpis.uniqueCustomers }} ({{ $('Integrate Narrative with Data').first().json.kpis.newCustomerPct }} new)</li>\n</ul>\n<p><em>AutomiQ / KW Catering &amp; Consulting Oy &mdash; automiq.fi</em></p>",
        "toRecipients": "={{ $env.REPORT_RECIPIENTS }}",
        "additionalFields": {
          "attachments": {
            "attachments": [
              {
                "binaryPropertyName": "report_pdf"
              }
            ]
          }
        }
      },
      "typeVersion": 2
    },
    {
      "id": "f804a6a1-a32e-4cb9-8da2-f41750384fe0",
      "name": "Share Summary on Teams",
      "type": "n8n-nodes-base.microsoftTeams",
      "notes": "Posts a KPI summary card to a Teams channel. Credential: Microsoft Teams OAuth2. Env vars: TEAMS_TEAM_ID, TEAMS_CHANNEL_ID (find both via Microsoft Graph Explorer).",
      "position": [
        10976,
        720
      ],
      "parameters": {
        "teamId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {}
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "3ccc957b-82d4-477f-9beb-4cb115286633",
  "nodeGroups": [],
  "connections": {
    "Claude Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Create AI Narrative with Claude",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Label Weekly Data": {
      "main": [
        [
          {
            "node": "Merge BigQuery Results",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Fetch Revenue KPIs": {
      "main": [
        [
          {
            "node": "Label Revenue Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Label Revenue Data": {
      "main": [
        [
          {
            "node": "Merge BigQuery Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Top Customers": {
      "main": [
        [
          {
            "node": "Label Customer Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Label Category Data": {
      "main": [
        [
          {
            "node": "Merge BigQuery Results",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Label Customer Data": {
      "main": [
        [
          {
            "node": "Merge BigQuery Results",
            "type": "main",
            "index": 3
          }
        ]
      ]
    },
    "Transfer Report Data": {
      "main": [
        [
          {
            "node": "Create AI Narrative with Claude",
            "type": "main",
            "index": 0
          },
          {
            "node": "Duplicate Report Template",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Export Document to PDF": {
      "main": [
        [
          {
            "node": "Save Report to OneDrive",
            "type": "main",
            "index": 0
          },
          {
            "node": "Send Report via Outlook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge BigQuery Results": {
      "main": [
        [
          {
            "node": "Consolidate BigQuery Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Report via Outlook": {
      "main": [
        [
          {
            "node": "Share Summary on Teams",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Previous Month": {
      "main": [
        [
          {
            "node": "Fetch Revenue KPIs",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Top Sales Categories",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Weekly Sales Trend",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Top Customers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Weekly Sales Trend": {
      "main": [
        [
          {
            "node": "Label Weekly Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Populate Template Fields": {
      "main": [
        [
          {
            "node": "Export Document to PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Consolidate BigQuery Data": {
      "main": [
        [
          {
            "node": "Transfer Report Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Duplicate Report Template": {
      "main": [
        [
          {
            "node": "Combine Narrative and Doc ID",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Fetch Top Sales Categories": {
      "main": [
        [
          {
            "node": "Label Category Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine Narrative and Doc ID": {
      "main": [
        [
          {
            "node": "Construct Replacement Requests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When First of Month at 07:00": {
      "main": [
        [
          {
            "node": "Calculate Previous Month",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Integrate Narrative with Data": {
      "main": [
        [
          {
            "node": "Combine Narrative and Doc ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Construct Replacement Requests": {
      "main": [
        [
          {
            "node": "Populate Template Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create AI Narrative with Claude": {
      "main": [
        [
          {
            "node": "Integrate Narrative with Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}