diff --git a/infra/n8n/ocsentinel-weekly-organization-reports-outlook.json b/infra/n8n/ocsentinel-weekly-organization-reports-outlook.json
new file mode 100644
index 0000000..e19e50e
--- /dev/null
+++ b/infra/n8n/ocsentinel-weekly-organization-reports-outlook.json
@@ -0,0 +1,222 @@
+{
+ "id": "OCwRpt7eK3mQ2xL9",
+ "name": "OCSentinel - Weekly Organization Reports",
+ "nodes": [
+ {
+ "parameters": {
+ "rule": {
+ "interval": [
+ {
+ "field": "weeks",
+ "weeksInterval": 1,
+ "triggerAtDay": [
+ 1
+ ],
+ "triggerAtHour": 7,
+ "triggerAtMinute": 20
+ }
+ ]
+ }
+ },
+ "id": "schedule-weekly-reports",
+ "name": "Every Monday 07:20",
+ "type": "n8n-nodes-base.scheduleTrigger",
+ "typeVersion": 1.3,
+ "position": [
+ 300,
+ 300
+ ]
+ },
+ {
+ "parameters": {
+ "operation": "executeQuery",
+ "query": "WITH latest AS (\n SELECT DISTINCT ON (d.id) d.machine_name, r.alert_state, r.payload\n FROM ocsentinel.scan_report AS r\n JOIN ocsentinel.device AS d ON d.id = r.device_id\n WHERE r.received_at >= now() - interval '8 days'\n ORDER BY d.id, r.generated_at_utc DESC, r.received_at DESC\n)\nSELECT machine_name, alert_state, payload\nFROM latest\nORDER BY payload #>> '{NinjaOne,OrganizationName}', machine_name;"
+ },
+ "id": "load-weekly-data",
+ "name": "Load Latest Device Reports",
+ "type": "n8n-nodes-base.postgres",
+ "typeVersion": 2.5,
+ "position": [
+ 560,
+ 300
+ ],
+ "credentials": {
+ "postgres": {
+ "id": "WkjY0kIF3kHvREys",
+ "name": "OCSentinel PostgreSQL"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "const esc = (value) => String(value ?? '')\n .replace(/&/g, '&').replace(//g, '>')\n .replace(/\"/g, '"').replace(/'/g, ''')\n .replace(/[^\\x20-\\x7e]/g, (character) => `${character.codePointAt(0)};`);\nconst now = new Date();\nconst end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));\nend.setUTCDate(end.getUTCDate() - ((end.getUTCDay() + 6) % 7));\nconst start = new Date(end.getTime() - 7 * 86400000);\nconst formatDate = (value) => new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' });\nconst groups = new Map();\nfor (const item of items) {\n const payload = item.json.payload || {}, ninja = payload.NinjaOne || payload.ninjaOne || {};\n const id = String(ninja.OrganizationId || ninja.organizationId || 'unknown');\n if (!groups.has(id)) groups.set(id, { id, name: String(ninja.OrganizationName || ninja.organizationName || `Organisation ${id}`), devices: [] });\n groups.get(id).devices.push({ name: item.json.machine_name || payload.MachineName || 'Unbekannt', state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(), payload });\n}\nconst output = [];\nfor (const group of groups.values()) {\n let warnings = 0, criticals = 0, totalEvents = 0, cveCritical = 0;\n const ips = new Set(), rows = [];\n for (const device of group.devices.sort((a, b) => a.name.localeCompare(b.name))) {\n if (device.state === 'warning') warnings++;\n if (device.state === 'critical') criticals++;\n cveCritical += Number((device.payload.VulnerabilityCorrelation || {}).CriticalCount || 0);\n const events = (device.payload.Events || []).filter((event) => { const timestamp = new Date(event.Timestamp); return !Number.isNaN(timestamp) && timestamp >= start && timestamp < end; });\n totalEvents += events.length;\n if (!events.length) { rows.push(`
| ${esc(device.name)} | Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum. | Sauber |
`); continue; }\n const grouped = new Map();\n for (const event of events) {\n const ip = event.SourceIp || '-', account = event.Username || '-', type = event.Target || 'Sicherheitsereignis', key = [type, account, ip].join('|');\n const entry = grouped.get(key) || { ip, account, type, count: 0, latest: event.Timestamp };\n entry.count++; if (new Date(event.Timestamp) > new Date(entry.latest)) entry.latest = event.Timestamp; grouped.set(key, entry); if (ip !== '-') ips.add(ip);\n }\n for (const event of grouped.values()) { const critical = device.state === 'critical'; rows.push(`| ${esc(device.name)} | ${esc(event.type)} | ${esc(event.account)} | ${esc(formatDate(event.latest))} | ${event.count} | ${esc(event.ip)} | ${critical ? 'Kritisch' : 'Pruefen'} |
`); }\n }\n const risk = criticals ? ['Kritisch', '#991b1b', '#fee2e2'] : warnings ? ['Beobachten', '#92400e', '#fef3c7'] : ['Unauffaellig', '#166534', '#dcfce7'];\n const metric = (value, label) => `${value}${label} | `;\n const html = `${risk[0]}OfficeCom Sentinel Sicherheitsbericht${esc(group.name)} | ${esc(start.toLocaleDateString('de-DE'))} bis ${esc(end.toLocaleDateString('de-DE'))} |
${metric(group.devices.length, 'Geräte')}${metric(criticals, 'Kritisch')}${metric(warnings, 'Warnungen')}${metric(totalEvents, 'Ereignisse')}${metric(ips.size, 'Quell-IP-Adressen')}${metric(cveCritical, 'Kritische CVEs')}
| System | Vorfall | Konto | Letzter Zeitpunkt | Anzahl | Quell-IP | Bewertung | ${rows.join('')}
Automatisch erstellt am ${esc(formatDate(now))} durch OfficeCom Sentinel. |
`;\n output.push({ json: { organizationId: group.id, organizationName: group.name, periodStartUtc: start.toISOString(), periodEndUtc: end.toISOString(), deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveTotal: 0, cveCritical, reportHtml: html, summaryJson: JSON.stringify({ deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveCritical }) } });\n}\nreturn output;\n"
+ },
+ "id": "build-weekly-reports",
+ "name": "Build Organization HTML Reports",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 820,
+ 300
+ ]
+ },
+ {
+ "parameters": {
+ "operation": "executeQuery",
+ "query": "INSERT INTO ocsentinel.weekly_organization_report (organization_id, organization_name, period_start_utc, period_end_utc, device_count, warning_count, critical_count, total_events, unique_ips, cve_total, cve_critical, report_html, summary)\nVALUES ($1, $2, $3::timestamptz, $4::timestamptz, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)\nON CONFLICT (organization_id, period_start_utc) DO UPDATE SET organization_name=EXCLUDED.organization_name, period_end_utc=EXCLUDED.period_end_utc, generated_at=now(), device_count=EXCLUDED.device_count, warning_count=EXCLUDED.warning_count, critical_count=EXCLUDED.critical_count, total_events=EXCLUDED.total_events, unique_ips=EXCLUDED.unique_ips, cve_total=EXCLUDED.cve_total, cve_critical=EXCLUDED.cve_critical, report_html=EXCLUDED.report_html, summary=EXCLUDED.summary\nRETURNING id;",
+ "options": {
+ "queryReplacement": "={{ [$json.organizationId, $json.organizationName, $json.periodStartUtc, $json.periodEndUtc, $json.deviceCount, $json.warningCount, $json.criticalCount, $json.totalEvents, $json.uniqueIps, $json.cveTotal, $json.cveCritical, $json.reportHtml, $json.summaryJson] }}"
+ }
+ },
+ "id": "store-weekly-reports",
+ "name": "Store Weekly Organization Reports",
+ "type": "n8n-nodes-base.postgres",
+ "typeVersion": 2.5,
+ "position": [
+ 1080,
+ 300
+ ],
+ "credentials": {
+ "postgres": {
+ "id": "WkjY0kIF3kHvREys",
+ "name": "OCSentinel PostgreSQL"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "operation": "executeQuery",
+ "query": "SELECT coalesce(array_agg(recipient_email ORDER BY recipient_email), ARRAY[]::text[]) AS recipients\nFROM ocsentinel.organization_report_recipient\nWHERE enabled = TRUE AND (organization_id = '*' OR organization_id = $1);",
+ "options": {
+ "queryReplacement": "={{ [$json.organizationId] }}"
+ }
+ },
+ "id": "load-report-recipients",
+ "name": "Empfaenger aus zentraler Zuordnung laden",
+ "type": "n8n-nodes-base.postgres",
+ "typeVersion": 2.5,
+ "position": [
+ 1560,
+ 300
+ ],
+ "credentials": {
+ "postgres": {
+ "id": "WkjY0kIF3kHvREys",
+ "name": "OCSentinel PostgreSQL"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "mode": "runOnceForEachItem",
+ "jsCode": "const report = $('Build Organization HTML Reports').item.json;\nconst recipients = Array.from(new Set($json.recipients || []));\nif (recipients.length === 0) return [];\nreturn { json: { ...report, recipients } };"
+ },
+ "id": "prepare-report-email",
+ "name": "E-Mail vorbereiten",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 1800,
+ 300
+ ]
+ },
+ {
+ "parameters": {
+ "fromEmail": "donotreply@officecom.biz",
+ "toEmail": "={{ $json.recipients.join(', ') }}",
+ "subject": "=OCSentinel Wochenbericht - {{ $json.organizationName }}",
+ "html": "={{ $json.reportHtml }}",
+ "options": {
+ "appendAttribution": false
+ }
+ },
+ "id": "send-weekly-report-email",
+ "name": "Send Weekly Organization Report",
+ "type": "n8n-nodes-base.emailSend",
+ "typeVersion": 2.1,
+ "position": [
+ 2040,
+ 300
+ ],
+ "credentials": {
+ "smtp": {
+ "id": "vafGYgYzM9SbxQbW",
+ "name": "SMTP account"
+ }
+ }
+ }
+ ],
+ "connections": {
+ "Every Monday 07:20": {
+ "main": [
+ [
+ {
+ "node": "Load Latest Device Reports",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Load Latest Device Reports": {
+ "main": [
+ [
+ {
+ "node": "Build Organization HTML Reports",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Build Organization HTML Reports": {
+ "main": [
+ [
+ {
+ "node": "Store Weekly Organization Reports",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Empfaenger aus zentraler Zuordnung laden",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Empfaenger aus zentraler Zuordnung laden": {
+ "main": [
+ [
+ {
+ "node": "E-Mail vorbereiten",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "E-Mail vorbereiten": {
+ "main": [
+ [
+ {
+ "node": "Send Weekly Organization Report",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1",
+ "timezone": "Europe/Berlin"
+ },
+ "active": true,
+ "pinData": {},
+ "versionId": "af98f45b-192e-49c8-9a1e-e7b1fc7e00b2",
+ "meta": {
+ "templateCredsSetupCompleted": true
+ },
+ "tags": []
+}