From bec21292de646b5710b74428898553087f62554a Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Fri, 22 May 2026 23:04:33 +0000 Subject: [PATCH] homelab: sync post-migration repo and n8n runtime audit --- DEPLOYMENT_HANDOFF.md | 2 +- ai/litellm/config.yaml | 12 +- automation/bin/unifi_stage_firewall_groups.py | 134 +++ .../bin/unifi_stage_policy_engine_firewall.py | 201 +++++ dev/rackpeek/config.yaml | 12 +- docs/architecture/ARCHITECTURE_OVERVIEW.md | 2 +- docs/architecture/SERVICES_DIRECTORY.md | 4 +- docs/planning/DEPLOY_PIHOLE.md | 2 +- docs/servers/ROCINANTE.md | 8 +- .../doris-dashboard/bin/generate_dashboard.py | 4 +- ...icies-custom-2026-05-22-post-ui-probe.json | 76 ++ ...lgroup-baseline-2026-05-22-post-stage.json | 153 ++++ ...igration-remaining-checklist-2026-05-22.md | 91 ++ ...ifi-client-cleanup-shortlist-2026-05-22.md | 102 +++ .../unifi-client-rehome-results-2026-05-22.md | 28 + .../unifi-firewall-gap-list-2026-05-22.md | 218 +++++ ...firewall-host-group-proposal-2026-05-22.md | 249 ++++++ .../01-grafana-alert-ai-gotify-v1.4.json | 634 +++++++++++++- n8n-workflows/04-whisper-transcription.json | 662 +++++++++++++-- .../07-git-commit-summarizer-v1.0.json | 237 +++++- n8n-workflows/08-class-recording-rag.json | 629 ++++++++++++-- .../09-class-transcript-ingest-v1.1.json | 570 +++++++++++-- .../14-paperless-inbox-reminder.json | 373 ++++++++- n8n-workflows/15-n8n-health-monitor.json | 466 ++++++++++- n8n-workflows/16-nfs-watchdog-v1.2.json | 790 ++++++++++++++++-- n8n-workflows/17-paperless-intake-triage.json | 417 ++++++++- .../18-school-paperless-intake-v1.2.json | 215 ++++- ...-paperless-school-metadata-enrichment.json | 430 +++++++++- 28 files changed, 6291 insertions(+), 430 deletions(-) create mode 100644 automation/bin/unifi_stage_firewall_groups.py create mode 100644 automation/bin/unifi_stage_policy_engine_firewall.py create mode 100644 home/doris-dashboard/docs/baselines/unifi-firewall-policies-custom-2026-05-22-post-ui-probe.json create mode 100644 home/doris-dashboard/docs/baselines/unifi-firewallgroup-baseline-2026-05-22-post-stage.json create mode 100644 home/doris-dashboard/docs/network-migration-remaining-checklist-2026-05-22.md create mode 100644 home/doris-dashboard/docs/unifi-client-cleanup-shortlist-2026-05-22.md create mode 100644 home/doris-dashboard/docs/unifi-client-rehome-results-2026-05-22.md create mode 100644 home/doris-dashboard/docs/unifi-firewall-gap-list-2026-05-22.md create mode 100644 home/doris-dashboard/docs/unifi-firewall-host-group-proposal-2026-05-22.md diff --git a/DEPLOYMENT_HANDOFF.md b/DEPLOYMENT_HANDOFF.md index 6cdc5e5..efade28 100644 --- a/DEPLOYMENT_HANDOFF.md +++ b/DEPLOYMENT_HANDOFF.md @@ -58,7 +58,7 @@ Per HOMELAB_BUILDOUT_PLAN.md Phase 3: ## Machine IPs - **PlausibleDeniability (PD):** 10.5.30.6 — TrueNAS Scale, RTX 2080 Ti -- **ROCINANTE:** 10.5.1.112 — RTX 4090 +- **ROCINANTE:** 10.5.30.112 — RTX 4090 - **N.O.M.A.D.:** 10.5.30.7 — Ubuntu, GTX 1080 ## LiteLLM .env Secrets (on PD at /mnt/docker-ssd/docker/compose/ai/.env) diff --git a/ai/litellm/config.yaml b/ai/litellm/config.yaml index 1d0c78a..db17692 100644 --- a/ai/litellm/config.yaml +++ b/ai/litellm/config.yaml @@ -8,21 +8,21 @@ model_list: - model_name: "heavy" litellm_params: model: "ollama/qwen3.6:latest" - api_base: "http://10.5.1.112:11434" + api_base: "http://10.5.30.112:11434" timeout: 300 stream_timeout: 300 - model_name: "heavy" litellm_params: model: "ollama/qwen2.5:32b" - api_base: "http://10.5.1.112:11434" + api_base: "http://10.5.30.112:11434" timeout: 300 stream_timeout: 300 - model_name: "heavy" litellm_params: model: "ollama/gemma3:27b" - api_base: "http://10.5.1.112:11434" + api_base: "http://10.5.30.112:11434" timeout: 300 stream_timeout: 300 @@ -59,17 +59,17 @@ model_list: - model_name: "ollama/qwen3.6:latest" litellm_params: model: "ollama/qwen3.6:latest" - api_base: "http://10.5.1.112:11434" + api_base: "http://10.5.30.112:11434" - model_name: "ollama/qwen2.5:32b" litellm_params: model: "ollama/qwen2.5:32b" - api_base: "http://10.5.1.112:11434" + api_base: "http://10.5.30.112:11434" - model_name: "ollama/gemma3:27b" litellm_params: model: "ollama/gemma3:27b" - api_base: "http://10.5.1.112:11434" + api_base: "http://10.5.30.112:11434" - model_name: "ollama/qwen2.5:14b" litellm_params: diff --git a/automation/bin/unifi_stage_firewall_groups.py b/automation/bin/unifi_stage_firewall_groups.py new file mode 100644 index 0000000..ca81f6e --- /dev/null +++ b/automation/bin/unifi_stage_firewall_groups.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Stage UniFi firewall groups for the current network segmentation. + +Default mode is dry-run. Pass --apply to create/update/delete as needed. +Reads credentials from the runtime automation .env on PD by default. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from pathlib import Path +from typing import Any + +DEFAULT_RUNTIME_HELPER = Path('/mnt/docker-ssd/docker/compose/automation/bin/unifi_stage_low_risk_objects.py') +DEFAULT_ENV_PATH = Path('/mnt/docker-ssd/docker/compose/automation/.env') + + +def load_helper(path: Path): + spec = importlib.util.spec_from_file_location('unifi_stage', path) + if spec is None or spec.loader is None: + raise RuntimeError(f'Could not load helper module from {path}') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def desired_groups() -> list[dict[str, Any]]: + return [ + {'name': 'NET-MGMT', 'group_type': 'address-group', 'group_members': ['10.5.0.0/24']}, + {'name': 'NET-TRUSTED', 'group_type': 'address-group', 'group_members': ['10.5.1.0/24']}, + {'name': 'NET-SERVERS', 'group_type': 'address-group', 'group_members': ['10.5.30.0/24']}, + {'name': 'NET-IOT', 'group_type': 'address-group', 'group_members': ['10.5.10.0/24']}, + {'name': 'NET-GUEST', 'group_type': 'address-group', 'group_members': ['10.5.90.0/24']}, + {'name': 'NET-CAMERAS', 'group_type': 'address-group', 'group_members': ['10.5.20.0/24']}, + {'name': 'NET-LEGACY-CIA', 'group_type': 'address-group', 'group_members': ['192.168.1.0/24']}, + { + 'name': 'NET-RFC1918-ALL', + 'group_type': 'address-group', + 'group_members': [ + '10.5.0.0/24', + '10.5.1.0/24', + '10.5.10.0/24', + '10.5.20.0/24', + '10.5.30.0/24', + '10.5.90.0/24', + '192.168.1.0/24', + '192.168.2.0/24', + '192.168.3.0/24', + ], + }, + {'name': 'PORT-DNS', 'group_type': 'port-group', 'group_members': ['53']}, + {'name': 'PORT-DHCP', 'group_type': 'port-group', 'group_members': ['67-68']}, + {'name': 'PORT-NTP', 'group_type': 'port-group', 'group_members': ['123']}, + {'name': 'PORT-WEB-ADMIN', 'group_type': 'port-group', 'group_members': ['80', '443', '8443', '9443']}, + {'name': 'PORT-SSH', 'group_type': 'port-group', 'group_members': ['22']}, + {'name': 'PORT-MDNS', 'group_type': 'port-group', 'group_members': ['5353']}, + ] + + +TEMP_GROUP_NAMES = {'TEST-TMP', 'TEST-PORT-TMP', 'TEST-PORT-TMP2', 'TEST-PORT-TMP3'} + + +def main() -> int: + parser = argparse.ArgumentParser(description='Stage UniFi firewall groups.') + parser.add_argument('--helper', default=str(DEFAULT_RUNTIME_HELPER), help='Path to runtime UniFi helper on PD') + parser.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Path to runtime .env on PD') + parser.add_argument('--site', default='default') + parser.add_argument('--apply', action='store_true') + args = parser.parse_args() + + helper = load_helper(Path(args.helper)) + helper.load_env_file(Path(args.env_file)) + client = helper.UniFiClient( + base_url=os.environ.get('UNIFI_BASE_URL', 'https://10.5.0.1'), + username=os.environ['UNIFI_USERNAME'], + password=os.environ['UNIFI_PASSWORD'], + verify_tls=helper.env_bool('UNIFI_VERIFY_TLS', False), + timeout=20, + ) + client.login() + + groups = client.get(client.site_path(args.site, 'rest/firewallgroup')) + by_name = {g.get('name'): g for g in groups} + result: dict[str, Any] = {'mode': 'apply' if args.apply else 'dry-run', 'changes': []} + + for group in desired_groups(): + existing = by_name.get(group['name']) + if not existing: + result['changes'].append({'name': group['name'], 'action': 'create', 'payload': group}) + if args.apply: + status, payload = client._json_request('POST', client.site_path(args.site, 'rest/firewallgroup'), group) + if status >= 300: + result['changes'][-1]['error'] = {'status': status, 'payload': payload} + continue + + current_members = existing.get('group_members', []) or [] + desired_members = group.get('group_members', []) or [] + current_type = existing.get('group_type') + if current_type != group['group_type'] or current_members != desired_members: + updated = dict(existing) + updated['group_type'] = group['group_type'] + updated['group_members'] = desired_members + result['changes'].append({ + 'name': group['name'], + 'action': 'update', + 'before': {'group_type': current_type, 'group_members': current_members}, + 'after': {'group_type': group['group_type'], 'group_members': desired_members}, + }) + if args.apply: + status, payload = client._json_request('PUT', client.site_path(args.site, f"rest/firewallgroup/{existing['_id']}"), updated) + if status >= 300: + result['changes'][-1]['error'] = {'status': status, 'payload': payload} + else: + result['changes'].append({'name': group['name'], 'action': 'noop'}) + + for name in sorted(TEMP_GROUP_NAMES): + existing = by_name.get(name) + if not existing: + continue + result['changes'].append({'name': name, 'action': 'delete-temp'}) + if args.apply: + status, payload = client._json_request('DELETE', client.site_path(args.site, f"rest/firewallgroup/{existing['_id']}")) + if status >= 300: + result['changes'][-1]['error'] = {'status': status, 'payload': payload} + + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/automation/bin/unifi_stage_policy_engine_firewall.py b/automation/bin/unifi_stage_policy_engine_firewall.py new file mode 100644 index 0000000..e83504f --- /dev/null +++ b/automation/bin/unifi_stage_policy_engine_firewall.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Stage UniFi Policy Engine firewall policies for the current segmentation. + +Default mode is dry-run. Pass --apply to create/update desired custom policies. +Reads credentials from the runtime automation .env on PD by default. + +This helper targets the newer Policy Engine / zone-based firewall model via: + /proxy/network/v2/api/site//firewall/zone + /proxy/network/v2/api/site//firewall-policies +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from copy import deepcopy +from pathlib import Path +from typing import Any + +DEFAULT_RUNTIME_HELPER = Path('/mnt/docker-ssd/docker/compose/automation/bin/unifi_stage_low_risk_objects.py') +DEFAULT_ENV_PATH = Path('/mnt/docker-ssd/docker/compose/automation/.env') + + +def load_helper(path: Path): + spec = importlib.util.spec_from_file_location('unifi_stage', path) + if spec is None or spec.loader is None: + raise RuntimeError(f'Could not load helper module from {path}') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def desired_policies() -> list[dict[str, Any]]: + return [ + { + 'name': 'Allow Internal to Untrusted', + 'enabled': True, + 'action': 'ALLOW', + 'protocol': 'all', + 'ip_version': 'BOTH', + 'logging': False, + 'create_allow_respond': True, + 'connection_state_type': 'ALL', + 'connection_states': [], + 'match_ip_sec': False, + 'match_opposite_protocol': False, + 'icmp_typename': 'ANY', + 'icmp_v6_typename': 'ANY', + 'source_zone_name': 'Internal', + 'destination_zone_name': 'Untrusted', + 'schedule': {'mode': 'ALWAYS'}, + 'source': { + 'matching_target': 'ANY', + 'port_matching_type': 'ANY', + 'match_opposite_ports': False, + }, + 'destination': { + 'matching_target': 'ANY', + 'port_matching_type': 'ANY', + 'match_opposite_ports': False, + }, + } + ] + + +POLICY_MUTABLE_KEYS = { + 'name', + 'enabled', + 'action', + 'protocol', + 'ip_version', + 'logging', + 'create_allow_respond', + 'connection_state_type', + 'connection_states', + 'match_ip_sec', + 'match_opposite_protocol', + 'icmp_typename', + 'icmp_v6_typename', + 'source', + 'destination', + 'schedule', + 'description', +} + + +def canonical_policy(policy: dict[str, Any]) -> dict[str, Any]: + out = {key: deepcopy(policy.get(key)) for key in POLICY_MUTABLE_KEYS if key in policy} + out.setdefault('description', '') + out.setdefault('connection_state_type', 'ALL') + out.setdefault('connection_states', []) + out.setdefault('logging', False) + out.setdefault('match_ip_sec', False) + out.setdefault('match_opposite_protocol', False) + out.setdefault('icmp_typename', 'ANY') + out.setdefault('icmp_v6_typename', 'ANY') + out.setdefault('schedule', {'mode': 'ALWAYS'}) + return out + + +def resolve_zone_ids(zone_data: list[dict[str, Any]]) -> dict[str, str]: + by_name: dict[str, str] = {} + for zone in zone_data: + name = zone.get('name') + zid = zone.get('_id') + if name and zid: + by_name[name] = zid + return by_name + + +def materialize_policy(spec: dict[str, Any], zone_ids: dict[str, str]) -> dict[str, Any]: + source_zone = spec['source_zone_name'] + dest_zone = spec['destination_zone_name'] + if source_zone not in zone_ids: + raise KeyError(f'Missing source zone: {source_zone}') + if dest_zone not in zone_ids: + raise KeyError(f'Missing destination zone: {dest_zone}') + + policy = {k: deepcopy(v) for k, v in spec.items() if not k.endswith('_zone_name')} + policy['description'] = policy.get('description', '') + policy['source'] = deepcopy(policy['source']) + policy['destination'] = deepcopy(policy['destination']) + policy['source']['zone_id'] = zone_ids[source_zone] + policy['destination']['zone_id'] = zone_ids[dest_zone] + return policy + + +def main() -> int: + parser = argparse.ArgumentParser(description='Stage UniFi Policy Engine firewall policies.') + parser.add_argument('--helper', default=str(DEFAULT_RUNTIME_HELPER), help='Path to runtime UniFi helper on PD') + parser.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Path to runtime .env on PD') + parser.add_argument('--site', default='default') + parser.add_argument('--apply', action='store_true') + args = parser.parse_args() + + helper = load_helper(Path(args.helper)) + helper.load_env_file(Path(args.env_file)) + client = helper.UniFiClient( + base_url=os.environ.get('UNIFI_BASE_URL', 'https://10.5.0.1'), + username=os.environ['UNIFI_USERNAME'], + password=os.environ['UNIFI_PASSWORD'], + verify_tls=helper.env_bool('UNIFI_VERIFY_TLS', False), + timeout=20, + ) + client.login() + + zones_payload = client.get(f'/proxy/network/v2/api/site/{args.site}/firewall/zone') + zone_data_raw = zones_payload['data'] if isinstance(zones_payload, dict) and 'data' in zones_payload else zones_payload + zone_data = zone_data_raw if isinstance(zone_data_raw, list) else [] + zone_ids = resolve_zone_ids(zone_data) + + policies_path = f'/proxy/network/v2/api/site/{args.site}/firewall-policies' + policies_payload = client.get(policies_path) + policy_data_raw = policies_payload['data'] if isinstance(policies_payload, dict) and 'data' in policies_payload else policies_payload + policy_data = policy_data_raw if isinstance(policy_data_raw, list) else [] + custom_by_name = {p.get('name'): p for p in policy_data if not p.get('predefined') and p.get('name')} + + result: dict[str, Any] = { + 'mode': 'apply' if args.apply else 'dry-run', + 'zones': sorted(zone_ids.keys()), + 'changes': [], + } + + for desired in desired_policies(): + materialized = materialize_policy(desired, zone_ids) + existing = custom_by_name.get(materialized['name']) + desired_canonical = canonical_policy(materialized) + if existing is None: + result['changes'].append({'name': materialized['name'], 'action': 'create', 'payload': desired_canonical}) + if args.apply: + status, payload = client._json_request('POST', policies_path, desired_canonical) + if status >= 300: + result['changes'][-1]['error'] = {'status': status, 'payload': payload} + continue + + existing_canonical = canonical_policy(existing) + if existing_canonical != desired_canonical: + update_payload = deepcopy(existing) + for key, value in desired_canonical.items(): + update_payload[key] = deepcopy(value) + result['changes'].append({ + 'name': materialized['name'], + 'action': 'update', + 'before': existing_canonical, + 'after': desired_canonical, + }) + if args.apply: + status, payload = client._json_request('PUT', f"{policies_path}/{existing['_id']}", update_payload) + if status >= 300: + result['changes'][-1]['error'] = {'status': status, 'payload': payload} + else: + result['changes'].append({'name': materialized['name'], 'action': 'noop'}) + + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/dev/rackpeek/config.yaml b/dev/rackpeek/config.yaml index 3d99b7c..b3eef8d 100644 --- a/dev/rackpeek/config.yaml +++ b/dev/rackpeek/config.yaml @@ -120,7 +120,7 @@ resources: - ai-heavy-tier - degraded labels: - lan-ip: 10.5.1.112 + lan-ip: 10.5.30.112 notes: |- Heavy AI inference server. Current operational note from 2026-05-21: @@ -272,7 +272,7 @@ resources: type: Baremetal name: rocinante-inference os: baremetal - ip: 10.5.1.112 + ip: 10.5.30.112 runsOn: - rocinante @@ -495,10 +495,10 @@ resources: - kind: Service name: ollama-heavy-tier network: - ip: 10.5.1.112 + ip: 10.5.30.112 port: 11434 protocol: TCP - url: http://10.5.1.112:11434 + url: http://10.5.30.112:11434 tags: - degraded runsOn: @@ -507,10 +507,10 @@ resources: - kind: Service name: whisper-cuda network: - ip: 10.5.1.112 + ip: 10.5.30.112 port: 8787 protocol: TCP - url: http://10.5.1.112:8787 + url: http://10.5.30.112:8787 tags: - degraded runsOn: diff --git a/docs/architecture/ARCHITECTURE_OVERVIEW.md b/docs/architecture/ARCHITECTURE_OVERVIEW.md index 2569b44..9223bb9 100644 --- a/docs/architecture/ARCHITECTURE_OVERVIEW.md +++ b/docs/architecture/ARCHITECTURE_OVERVIEW.md @@ -9,7 +9,7 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep | **PlausibleDeniability (PD)** | TrueNAS Scale 25.10.2.1 | 10.5.30.6 | Primary Docker host — all compose stacks | | **Serenity** | Unraid 7.2.4 | 10.5.30.5 | NAS, ARR stack, CPU reranker | | **N.O.M.A.D.** | Ubuntu 25.10 | 10.5.30.7 | Offline knowledge, game servers, and standalone local services | -| **Rocinante** | (bare metal) | 10.5.1.112 | Heavy Ollama models (RTX 4090) | +| **Rocinante** | (bare metal) | 10.5.30.112 | Heavy Ollama models (RTX 4090) | ## Network diff --git a/docs/architecture/SERVICES_DIRECTORY.md b/docs/architecture/SERVICES_DIRECTORY.md index 51bdb9e..e4e7233 100644 --- a/docs/architecture/SERVICES_DIRECTORY.md +++ b/docs/architecture/SERVICES_DIRECTORY.md @@ -34,10 +34,10 @@ All homelab services, their hosts, ports, and current status. | OpenWebUI | PD | 8282 | ✅ Active | | Qdrant | PD | 6333 (HTTP) / 6334 (gRPC) | ✅ Active | | Whisper (faster-whisper, CPU) | N.O.M.A.D. (10.5.30.7) | 8786 | ✅ Active | -| Whisper (faster-whisper, CUDA large-v3) | Rocinante (10.5.1.112) | 8787 | ⚠️ Unreachable from N.O.M.A.D. during 2026-05-21 Doris validation | +| Whisper (faster-whisper, CUDA large-v3) | Rocinante (10.5.30.112) | 8787 | ⚠️ Unreachable from N.O.M.A.D. during 2026-05-21 Doris validation | | SearXNG | PD | 8888 | ✅ Active | | Ollama — light tier | PD (10.5.30.6) | 11434 | ✅ Active | -| Ollama — heavy tier | Rocinante (10.5.1.112) | 11434 | ⚠️ Unreachable from N.O.M.A.D. during 2026-05-21 Doris validation | +| Ollama — heavy tier | Rocinante (10.5.30.112) | 11434 | ⚠️ Unreachable from N.O.M.A.D. during 2026-05-21 Doris validation | | Reranker (TEI) | Serenity (10.5.30.5) | 9787 | ✅ Active | OpenClaw is no longer treated as an active PD service in the operator inventory. Only helper-artifact references remain under the school-intake planning docs. diff --git a/docs/planning/DEPLOY_PIHOLE.md b/docs/planning/DEPLOY_PIHOLE.md index 1605573..699c559 100644 --- a/docs/planning/DEPLOY_PIHOLE.md +++ b/docs/planning/DEPLOY_PIHOLE.md @@ -520,7 +520,7 @@ Add in Pi-hole (primary) → Local DNS → DNS Records: |--------|----| | pd.lan | 10.5.30.6 | | nomad.lan | 10.5.30.7 | -| rocinante.lan | 10.5.1.112 | +| rocinante.lan | 10.5.30.112 | | rpi4.lan | 10.5.1.X | Nebula Sync will propagate these to all replicas. diff --git a/docs/servers/ROCINANTE.md b/docs/servers/ROCINANTE.md index c53407f..4e05771 100644 --- a/docs/servers/ROCINANTE.md +++ b/docs/servers/ROCINANTE.md @@ -6,7 +6,7 @@ Heavy AI inference server. | Component | Details | |-----------|---------| -| **IP** | 10.5.1.112 | +| **IP** | 10.5.30.112 | | **GPU** | NVIDIA RTX 4090 (24 GB VRAM) | | **Role** | Heavy AI inference (Ollama) | @@ -23,13 +23,13 @@ Ollama runs on port `11434` and hosts the large-model ("heavy") tier. | `gemma3:27b` | Multimodal / vision | > **LiteLLM tier:** Rocinante is the **"heavy"** tier in LiteLLM `config.yaml`. -> Heavy-tier requests from OpenWebUI and n8n route to `http://10.5.1.112:11434`. +> Heavy-tier requests from OpenWebUI and n8n route to `http://10.5.30.112:11434`. ## Current operational note -- During Doris validation from N.O.M.A.D. on 2026-05-21, `10.5.1.112` did not answer ping and did not accept connections on `11434` or `8787`. +- During Doris validation from N.O.M.A.D. on 2026-05-21, `10.5.30.112` did not answer ping and did not accept connections on `11434` or `8787`. - Treat Rocinante-hosted Ollama heavy-tier traffic and CUDA Whisper as currently unreachable from N.O.M.A.D. until host/network reachability is restored. -- Re-validate from N.O.M.A.D. with `curl http://10.5.1.112:11434/api/tags` and `curl http://10.5.1.112:8787/health` after recovery. +- Re-validate from N.O.M.A.D. with `curl http://10.5.30.112:11434/api/tags` and `curl http://10.5.30.112:8787/health` after recovery. ## Notes diff --git a/home/doris-dashboard/bin/generate_dashboard.py b/home/doris-dashboard/bin/generate_dashboard.py index f279138..2f4b7db 100755 --- a/home/doris-dashboard/bin/generate_dashboard.py +++ b/home/doris-dashboard/bin/generate_dashboard.py @@ -73,10 +73,10 @@ SERVICE_DIRECTORY = [ {'name': 'OpenWebUI', 'url': 'https://openwebui.paccoco.com', 'health_url': 'http://10.5.30.6:8282', 'purpose': 'Chat UI for the lab models, docs, and retrieval workflows.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8282'}, {'name': 'Qdrant', 'url': 'http://10.5.30.6:6333/dashboard', 'health_url': 'http://10.5.30.6:6333/healthz', 'purpose': 'Vector store for Project NOMAD and local retrieval systems.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '6333/6334'}, {'name': 'Whisper (CPU)', 'url': 'http://10.5.30.7:8786/docs', 'health_url': 'http://10.5.30.7:8786/health', 'purpose': 'Fast local CPU transcription endpoint on NOMAD.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8786'}, - {'name': 'Whisper (CUDA)', 'url': 'http://10.5.1.112:8787/docs', 'health_url': 'http://10.5.1.112:8787/health', 'purpose': 'Heavy GPU transcription endpoint on Rocinante.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '8787'}, + {'name': 'Whisper (CUDA)', 'url': 'http://10.5.30.112:8787/docs', 'health_url': 'http://10.5.30.112:8787/health', 'purpose': 'Heavy GPU transcription endpoint on Rocinante.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '8787'}, {'name': 'SearXNG', 'url': 'http://10.5.30.6:8888', 'health_url': 'http://10.5.30.6:8888', 'purpose': 'Private metasearch box for research and agent-side browsing.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8888'}, {'name': 'Ollama — light tier', 'url': 'http://10.5.30.6:11434/api/tags', 'health_url': 'http://10.5.30.6:11434/api/tags', 'purpose': 'PD light-tier Ollama runtime backing cheaper/default model paths.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '11434'}, - {'name': 'Ollama — heavy tier', 'url': 'http://10.5.1.112:11434/api/tags', 'health_url': 'http://10.5.1.112:11434/api/tags', 'purpose': 'Rocinante heavy-tier Ollama runtime for larger model requests.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '11434'}, + {'name': 'Ollama — heavy tier', 'url': 'http://10.5.30.112:11434/api/tags', 'health_url': 'http://10.5.30.112:11434/api/tags', 'purpose': 'Rocinante heavy-tier Ollama runtime for larger model requests.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '11434'}, {'name': 'Reranker (TEI)', 'url': 'http://10.5.30.5:9787', 'health_url': 'http://10.5.30.5:9787', 'purpose': 'Serenity-hosted reranker used by retrieval flows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'SERENITY', 'port': '9787'}, ], }, diff --git a/home/doris-dashboard/docs/baselines/unifi-firewall-policies-custom-2026-05-22-post-ui-probe.json b/home/doris-dashboard/docs/baselines/unifi-firewall-policies-custom-2026-05-22-post-ui-probe.json new file mode 100644 index 0000000..3ea3e2c --- /dev/null +++ b/home/doris-dashboard/docs/baselines/unifi-firewall-policies-custom-2026-05-22-post-ui-probe.json @@ -0,0 +1,76 @@ +{ + "custom_policies": [ + { + "_id": "6963d6bb1131084f05461b71", + "action": "ALLOW", + "connection_state_type": "ALL", + "connection_states": [], + "create_allow_respond": true, + "description": "", + "destination": { + "match_opposite_ports": false, + "matching_target": "ANY", + "port_matching_type": "ANY", + "zone_id": "6963d5a91131084f05461a0d" + }, + "enabled": true, + "icmp_typename": "ANY", + "icmp_v6_typename": "ANY", + "index": 10000, + "ip_version": "BOTH", + "logging": false, + "match_ip_sec": false, + "match_opposite_protocol": false, + "name": "Allow Internal to Untrusted", + "predefined": false, + "protocol": "all", + "schedule": { + "mode": "ALWAYS" + }, + "source": { + "match_opposite_ports": false, + "matching_target": "ANY", + "port_matching_type": "ANY", + "zone_id": "6963d42a1131084f054618df" + } + }, + { + "_id": "6a10cbca0ab9980e4eac4553", + "action": "BLOCK", + "connection_state_type": "ALL", + "connection_states": [], + "create_allow_respond": false, + "description": "", + "destination": { + "match_opposite_ports": false, + "matching_target": "ANY", + "port_matching_type": "ANY", + "zone_id": "6963d5a91131084f05461a0d" + }, + "enabled": false, + "icmp_typename": "ANY", + "icmp_v6_typename": "ANY", + "index": 10001, + "ip_version": "BOTH", + "logging": false, + "match_ip_sec": false, + "match_opposite_protocol": false, + "name": "DORIS-TEMP", + "predefined": false, + "protocol": "all", + "schedule": { + "date": "2026-05-22", + "mode": "ONE_TIME_ONLY", + "time_range_end": "12:00", + "time_range_start": "09:00" + }, + "source": { + "match_opposite_ports": false, + "matching_target": "ANY", + "port_matching_type": "ANY", + "zone_id": "6963d42a1131084f054618df" + } + } + ], + "policy_count": 109 +} diff --git a/home/doris-dashboard/docs/baselines/unifi-firewallgroup-baseline-2026-05-22-post-stage.json b/home/doris-dashboard/docs/baselines/unifi-firewallgroup-baseline-2026-05-22-post-stage.json new file mode 100644 index 0000000..b0681a6 --- /dev/null +++ b/home/doris-dashboard/docs/baselines/unifi-firewallgroup-baseline-2026-05-22-post-stage.json @@ -0,0 +1,153 @@ +[ + { + "_id": "6a10c5fe0ab9980e4eac37be", + "external_id": "535d893d-90ef-45ac-8d84-fc0b9d711b22", + "group_members": [ + "10.5.0.0/24" + ], + "group_type": "address-group", + "name": "NET-MGMT", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5fe0ab9980e4eac37c1", + "external_id": "146a4b9d-86c7-4d15-afef-2ea02374e9be", + "group_members": [ + "10.5.1.0/24" + ], + "group_type": "address-group", + "name": "NET-TRUSTED", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5fe0ab9980e4eac37c4", + "external_id": "81e1423a-74ce-43cf-9b42-33f641452ac7", + "group_members": [ + "10.5.30.0/24" + ], + "group_type": "address-group", + "name": "NET-SERVERS", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5fe0ab9980e4eac37c7", + "external_id": "e5a36792-c65d-434b-9fef-5259bee6e653", + "group_members": [ + "10.5.10.0/24" + ], + "group_type": "address-group", + "name": "NET-IOT", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5fe0ab9980e4eac37ca", + "external_id": "49c1cd44-998e-4c36-bfc4-42eeb5ce9a11", + "group_members": [ + "10.5.90.0/24" + ], + "group_type": "address-group", + "name": "NET-GUEST", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5ff0ab9980e4eac37cd", + "external_id": "e933b5f7-ecc0-4e93-a9e7-3924f507e0bf", + "group_members": [ + "10.5.20.0/24" + ], + "group_type": "address-group", + "name": "NET-CAMERAS", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5ff0ab9980e4eac37cf", + "external_id": "6a05ad73-6fda-4a3d-b1bf-c4eb6ffffee3", + "group_members": [ + "192.168.1.0/24" + ], + "group_type": "address-group", + "name": "NET-LEGACY-CIA", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5ff0ab9980e4eac37d3", + "external_id": "2fac4800-7ec2-48d1-8ecb-c3c357330c1e", + "group_members": [ + "53" + ], + "group_type": "port-group", + "name": "PORT-DNS", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5ff0ab9980e4eac37d6", + "external_id": "3ac9f355-c4c4-4121-b437-47f2ebd72c49", + "group_members": [ + "67-68" + ], + "group_type": "port-group", + "name": "PORT-DHCP", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5ff0ab9980e4eac37d9", + "external_id": "e361fb19-a9b8-4d14-b117-18356ad79c9b", + "group_members": [ + "123" + ], + "group_type": "port-group", + "name": "PORT-NTP", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5ff0ab9980e4eac37dc", + "external_id": "4ae091e5-0139-4e10-9e88-6b0abe950538", + "group_members": [ + "80", + "443", + "8443", + "9443" + ], + "group_type": "port-group", + "name": "PORT-WEB-ADMIN", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5ff0ab9980e4eac37df", + "external_id": "db376d79-c9aa-4f3a-b040-a6a76c195733", + "group_members": [ + "22" + ], + "group_type": "port-group", + "name": "PORT-SSH", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c5ff0ab9980e4eac37e2", + "external_id": "0af620e5-ef70-41a2-bc57-d9fed44c4bcb", + "group_members": [ + "5353" + ], + "group_type": "port-group", + "name": "PORT-MDNS", + "site_id": "6963c5321131084f05460911" + }, + { + "_id": "6a10c6580ab9980e4eac3914", + "external_id": "3f903463-6305-4c09-ba0c-85b729268a99", + "group_members": [ + "10.5.0.0/24", + "10.5.1.0/24", + "10.5.10.0/24", + "10.5.20.0/24", + "10.5.30.0/24", + "10.5.90.0/24", + "192.168.1.0/24", + "192.168.2.0/24", + "192.168.3.0/24" + ], + "group_type": "address-group", + "name": "NET-RFC1918-ALL", + "site_id": "6963c5321131084f05460911" + } +] diff --git a/home/doris-dashboard/docs/network-migration-remaining-checklist-2026-05-22.md b/home/doris-dashboard/docs/network-migration-remaining-checklist-2026-05-22.md new file mode 100644 index 0000000..4fa7873 --- /dev/null +++ b/home/doris-dashboard/docs/network-migration-remaining-checklist-2026-05-22.md @@ -0,0 +1,91 @@ +# Network Migration Remaining Checklist + +Current intent: only safe, low-drama follow-up work remains. The easy-value client cleanup already completed should not be re-run. + +## Already completed +- Servers network/profile exists in UniFi +- Protect WiFi Chime `upstairs landing` moved from Management -> Camera +- Protect WiFi Chime `Living Room` moved from Management -> Camera +- `LG_Smart_Laundry2_open` moved from Trusted -> IoT +- `MyQ-29B` moved from Old IoT -> IoT +- `LG_Smart_Dryer2_open` moved from Old IoT -> IoT +- `Samsung-FamilyHub` moved from Old IoT -> IoT +- `Main-Floor` ecobee moved from Old IoT -> IoT +- `Upstairs` ecobee moved from Old IoT -> IoT +- These 8 client moves were verified live from UniFi `stat/sta` +- Firewall object/group scaffolding exists +- Basic custom outbound policy scaffolding exists (`Allow Internal to Untrusted`) + +## Safe to do now +1. Documentation cleanup + - treat older pre-move docs as historical snapshots, not live next-step instructions + - use this file as the current remaining-work reference + +2. Finalize remaining task list + - keep Google/cast pilot explicitly deferred until John is on the laptop + - keep unknown Legacy CIA devices explicitly quarantine-first + - keep Intellirocks-in-Trusted explicitly flagged for later identification/move decision + +3. Non-disruptive firewall planning cleanup + - compare staged firewall objects/policies against desired rule order + - produce a precise missing-rules gap list before any live rule apply + +## Still pending before the migration is truly finished + +### A. Google/cast validation batch +Do later, with user present on laptop: +- pilot one Google/cast-class device only +- validate Plex playback +- validate Plex discovery/casting +- validate Dispatcharr once relevant +- if ugly, revert that one device and defer the rest + +Likely candidates still needing that treatment: +- `dc:e5:5b:8f:57:d2` (Google client currently in Trusted) +- `Google-Home-Mini` +- `3c:8d:20:f3:92:36` +- `90:ca:fa:b6:7f:6e` + +### B. Remaining Trusted-lane cleanup +- `d4:ad:fc:f2:df:d2` likely does not belong in Trusted +- decide: identify better, move to IoT, or quarantine later + +### C. Legacy CIA quarantine closeout +Leave quarantined unless identified better: +- `5c:61:99:41:73:40` +- `60:74:f4:54:fd:ec` +- `60:74:f4:7b:6a:11` +- `c0:f5:35:20:5d:94` +- `d4:ad:fc:60:90:6a` +- `d4:ad:fc:ea:7f:65` + +### D. Real firewall enforcement +Still needed if the design is to be truly enforced rather than just staged: +- stateful baseline rules +- management shield +- explicit Trusted admin -> Management allows +- Trusted -> Servers allow +- Guest internet-only enforcement +- IoT narrow internal access only +- Camera narrow internal access only +- Legacy CIA quarantine-only posture +- broad deny structure for restricted lanes + +Important distinction: +- groups/objects exist +- at least one custom outbound policy exists +- the full inter-VLAN segmentation matrix is not yet fully enforced + +### E. Final cleanup +- SSID simplification once Google/cast behavior is understood +- final validation sweep +- confirm no temporary panic exceptions remain +- document any intentionally deferred weird devices + +## Suggested execution order from here +1. Produce firewall gap list +2. Wait for laptop session +3. Run one-device Google/cast pilot +4. Decide on Trusted Intellirocks device +5. Finish firewall enforcement carefully +6. Do final SSID simplification and closeout diff --git a/home/doris-dashboard/docs/unifi-client-cleanup-shortlist-2026-05-22.md b/home/doris-dashboard/docs/unifi-client-cleanup-shortlist-2026-05-22.md new file mode 100644 index 0000000..95cd282 --- /dev/null +++ b/home/doris-dashboard/docs/unifi-client-cleanup-shortlist-2026-05-22.md @@ -0,0 +1,102 @@ +# UniFi Client Cleanup Shortlist + +Live basis: +- Captured from PD with `python3 automation/bin/unifi_network.py --raw` +- Capture time: 2026-05-22 21:45:20 UTC +- Scope: active clients only + +## What looks correct already +- Servers lane: `PlausibleDeniability`, `Serenity`, `Nomad` +- Camera lane: `front-doorbell` +- Trusted human/operator lane: `Rocinante`, `FlyingDutchman`, `Pixel-7`, `Pixel-9-Pro-XL`, `Valkyrie`, `steamdeck` +- Trusted tooling/device that is probably intentional: `MeshMonitor (MT)` + +## Immediate cleanup candidates + +### 1. Move out of Trusted first +These are the clearest remaining misclassifications, and the smart-appliance targets are now approved for `IoT`. + +- `LG_Smart_Laundry2_open` (`10.5.1.184`, `60:ab:14:5f:b4:ba`) + - Current lane: `Trusted` + - Approved target: `IoT` + - Identification confidence: high + - Why: named LG appliance endpoint and sibling to `LG_Smart_Dryer2_open`; does not belong in the human/operator lane. + +- `dc:e5:5b:8f:57:d2` (`10.5.1.132`, Google) + - Current lane: `Trusted` + - Likely identity: Google cast/speaker/display-class client + - Likely target: `IoT`, but late-batch after discovery validation + - Why: + - Google vendor fingerprint + - same family as the three Google clients still on `Old IoT` + - consumer Wi-Fi client on the compat Trusted SSID, not a human endpoint + - heavy traffic pattern is consistent with a media/cast-style household device + - Caution: treat like the other Google/cast discovery-sensitive devices. Do not broaden policy just to appease casting. + +- `d4:ad:fc:f2:df:d2` (`10.5.1.154`, Shenzhen Intellirocks Tech) + - Current lane: `Trusted` + - Likely identity: sibling to the two unnamed Intellirocks devices already on `Old IoT`; likely smart-home / embedded device class + - Likely target: `IoT` or quarantine later if still unidentified + - Why: vendor-family clustering strongly suggests it belongs with the other low-trust embedded devices, not with human/operator endpoints. + - Caution: identify before moving if possible; if unknown, do not promote it by leaving it in Trusted. + +## Management offenders +These are now identified and should not remain on Management. + +- `58:d6:1f:54:e5:6d` (`10.5.0.123`, hostname `espressif`) + - Identified as: UniFi Protect WiFi Chime + - Friendly name from controller fingerprint: `upstairs landing` + - Best-fit target lane: `Camera` / security + +- `58:d6:1f:54:e5:af` (`10.5.0.189`, hostname `espressif`) + - Identified as: UniFi Protect WiFi Chime + - Friendly name from controller fingerprint: `Living Room` + - Best-fit target lane: `Camera` / security + +Notes: +- Both are Wi-Fi clients on `UniFi Wireless`. +- UniFi fingerprint metadata reports `product_line=unifi-protect` and `product_model=Protect WiFi Chime`. +- This means they are not mystery ESP junk anymore; they are known Protect accessories that should be treated like camera/security estate, not management-plane clients. + +## Old IoT move-now set +These still look like the cleanest deliberate moves from `CIA Via` into `IoT`. + +1. `MyQ-29B` (`192.168.1.130`) +2. `LG_Smart_Dryer2_open` (`192.168.1.186`) +3. `Samsung-FamilyHub` (`192.168.1.149`) +4. `Main-Floor` ecobee (`192.168.1.102`) +5. `Upstairs` ecobee (`192.168.1.131`) + +## Old IoT move-later / discovery-sensitive +These are probably IoT-class eventually, but not the first things to touch if the goal is a calm cleanup. + +- `Google-Home-Mini` (`192.168.1.185`) +- `3c:8d:20:f3:92:36` (`192.168.1.192`, Google) +- `90:ca:fa:b6:7f:6e` (`192.168.1.129`, Google) + +## Old IoT quarantine-first leftovers +Leave these in legacy quarantine unless/until they are identified better. + +- `5c:61:99:41:73:40` (`192.168.1.172`) +- `60:74:f4:54:fd:ec` (`192.168.1.136`) +- `60:74:f4:7b:6a:11` (`192.168.1.117`) +- `c0:f5:35:20:5d:94` (`192.168.1.183`) +- `d4:ad:fc:60:90:6a` (`192.168.1.100`) +- `d4:ad:fc:ea:7f:65` (`192.168.1.101`) + +## Recommended next move order +If doing a low-drama cleanup pass, use this order: + +1. Move `LG_Smart_Laundry2_open` out of `Trusted` into `IoT` +2. Re-home the two identified Protect chimes out of `Management` into `Camera` / security +3. Migrate the approved Old IoT move-now set into `IoT`, one app-validated batch at a time +4. Leave Google/cast-class gear for later unless everything else is stable +5. Keep unknown leftovers quarantined; do not “clean them up” by granting `Trusted` + +## Bottom line +The clearest remaining lane problems are now: +- one approved LG appliance still sitting in `Trusted` +- one likely Google cast/display-class client in `Trusted` +- one likely Intellirocks smart-home client in `Trusted` +- two now-identified Protect chimes still sitting in `Management` +- the approved appliance/thermostat/MyQ devices still lingering in `Old IoT` diff --git a/home/doris-dashboard/docs/unifi-client-rehome-results-2026-05-22.md b/home/doris-dashboard/docs/unifi-client-rehome-results-2026-05-22.md new file mode 100644 index 0000000..bb535ab --- /dev/null +++ b/home/doris-dashboard/docs/unifi-client-rehome-results-2026-05-22.md @@ -0,0 +1,28 @@ +# UniFi Client Rehome Results + +Executed from PD against the live UniFi controller using per-client virtual network overrides plus targeted `kick-sta` reconnects. + +## Requested changes completed + +### Trusted -> IoT +- `LG_Smart_Laundry2_open` (`60:ab:14:5f:b4:ba`) -> `IoT` -> `10.5.10.141` + +### Management -> Camera +- Protect WiFi Chime `upstairs landing` (`58:d6:1f:54:e5:6d`) -> `Camera` -> `10.5.20.191` +- Protect WiFi Chime `Living Room` (`58:d6:1f:54:e5:af`) -> `Camera` -> `10.5.20.8` + +### Old IoT -> IoT +- `MyQ-29B` (`0c:95:05:0b:76:65`) -> `IoT` -> `10.5.10.88` +- `LG_Smart_Dryer2_open` (`60:ab:14:94:e4:aa`) -> `IoT` -> `10.5.10.11` +- `Samsung-FamilyHub` (`fc:03:9f:e5:64:50`) -> `IoT` -> `10.5.10.117` +- `Main-Floor` ecobee (`44:61:32:1d:ee:8f`) -> `IoT` -> `10.5.10.160` +- `Upstairs` ecobee (`44:61:32:3f:0b:4c`) -> `IoT` -> `10.5.10.64` + +## Validation result +All 8 targeted clients were re-read live from `stat/sta` and matched their intended target lanes. + +## Notes +- Rehome mechanism used: `PUT /rest/user/` with `network_id`, `virtual_network_override_enabled=true`, and `virtual_network_override_id=` +- Reconnect mechanism used: `POST /cmd/stamgr` with `cmd=kick-sta` +- `LG_Smart_Laundry2_open` had a temporary probe note during discovery; it was cleared during the live apply +- Some clients still show their original SSID names in UniFi telemetry even after landing on the target virtual network override; the authoritative validation point for this pass was the live `network` / `network_id` state diff --git a/home/doris-dashboard/docs/unifi-firewall-gap-list-2026-05-22.md b/home/doris-dashboard/docs/unifi-firewall-gap-list-2026-05-22.md new file mode 100644 index 0000000..d8463cf --- /dev/null +++ b/home/doris-dashboard/docs/unifi-firewall-gap-list-2026-05-22.md @@ -0,0 +1,218 @@ +# UniFi Firewall Gap List + +Purpose: compare the intended segmentation design against the current staged UniFi firewall artifacts without making any live traffic changes. + +Evidence used: +- Desired design: `home/doris-dashboard/docs/network-firewall-rule-order.md` +- Current groups baseline: `home/doris-dashboard/docs/baselines/unifi-firewallgroup-baseline-2026-05-22-post-stage.json` +- Current custom policy baseline: `home/doris-dashboard/docs/baselines/unifi-firewall-policies-custom-2026-05-22-post-ui-probe.json` + +## Executive summary + +Current state is scaffolding, not finished enforcement. + +What exists now: +- network/address groups for the major lanes +- a handful of port groups +- one enabled custom outbound-style policy: `Allow Internal to Untrusted` +- one disabled temp policy: `DORIS-TEMP` + +What does not yet exist in the captured custom-policy baseline: +- the actual inter-VLAN segmentation matrix +- the management shield +- the quarantine posture for Legacy CIA +- the lane-specific allow/deny structure described in the design doc + +So the honest answer is: +- groups/objects: mostly present +- real custom segmentation policy: largely still missing + +## 1. Present in the current staged baseline + +### Network groups present +- `NET-MGMT` +- `NET-TRUSTED` +- `NET-SERVERS` +- `NET-IOT` +- `NET-GUEST` +- `NET-CAMERAS` +- `NET-LEGACY-CIA` +- `NET-RFC1918-ALL` + +### Port groups present +- `PORT-DNS` +- `PORT-DHCP` +- `PORT-NTP` +- `PORT-WEB-ADMIN` +- `PORT-SSH` +- `PORT-MDNS` + +### Custom policies present +1. `Allow Internal to Untrusted` + - enabled + - effectively basic internal -> internet/outside allowance scaffolding +2. `DORIS-TEMP` + - disabled + - temporary/non-production artifact, not part of the final design + +## 2. Missing object inventory compared to the design + +The rule-order design expects these host/device groups, but they do not appear in the captured firewall-group baseline: +- `HOST-ADMIN-TRUSTED` +- `HOST-CORE-SERVICES` +- `HOST-PROTECT-SERVICES` +- `HOST-DNS` +- `HOST-NTP` +- `HOST-IOT-HELPERS` +- `HOST-CAMERA-HELPERS` +- `HOST-LEGACY-EXCEPTIONS` + +The design also mentions port groups not seen in the captured baseline: +- `PORT-PROTECT` +- `PORT-CAST` + +Impact: +- exact narrow allow rules for management, Protect, helper traffic, and Google/cast exceptions cannot be cleanly expressed yet using the intended group model + +## 3. Missing rule sections compared to the design + +### Section A: Core state handling +Missing or not evidenced in the captured custom-policy baseline: +- `ALLOW Established/Related` +- `DROP Invalid` + +### Section B: Management protection +Missing or not evidenced: +- `ALLOW Trusted Admin -> Management Admin Surfaces` +- `ALLOW Trusted Admin -> Gateway Infra Utilities` +- `DROP IoT -> Management` +- `DROP Cameras -> Management` +- `DROP Guest -> Management` +- `DROP Legacy CIA -> Management` +- `DROP Any Internal -> Management` + +Meaning: +- the intended management shield is not yet represented in the captured custom-policy set + +### Section C: Trusted human lane +Missing or not evidenced: +- `ALLOW Trusted -> Servers Approved Access` +- `ALLOW Trusted -> Cameras Admin/Viewer Access` +- `ALLOW Trusted -> IoT Control Exceptions` + +Meaning: +- the design’s explicit human/operator access model is not yet staged in a visible way + +### Section D: Server/helper traffic +Missing or not evidenced: +- `ALLOW Servers -> IoT Approved Helpers` +- `ALLOW Cameras -> Protect Services` +- `ALLOW IoT -> Approved Server Helpers` +- `ALLOW Legacy CIA -> Approved One-Off Exception` + +Meaning: +- no captured evidence yet of the narrow internal service exceptions the design wants + +### Section E: DNS/NTP baseline for restricted lanes +Missing or not evidenced: +- `ALLOW IoT -> DNS` +- `ALLOW Cameras -> DNS` +- `ALLOW Legacy CIA -> DNS` +- `ALLOW IoT -> NTP` +- `ALLOW Cameras -> NTP` +- `ALLOW Legacy CIA -> NTP` + +Meaning: +- the restricted-lane minimum-function posture is not yet fully expressed in custom rules + +### Section F: Internet access for constrained lanes +Partially present at best: +- there is one broad custom policy, `Allow Internal to Untrusted` + +Still missing as lane-specific explicit policy: +- `ALLOW Guest -> Internet` +- `ALLOW IoT -> Internet` +- `ALLOW Cameras -> Internet Updates` +- `ALLOW Legacy CIA -> Internet` + +Meaning: +- outbound access is only evidenced in a broad/global way, not in the lane-specific shape called for by the design + +### Section G: Broad internal denies for restricted lanes +Missing or not evidenced: +- `DROP Guest -> RFC1918/Internal` +- `DROP IoT -> Trusted` +- `DROP IoT -> Servers` +- `DROP IoT -> Cameras` +- `DROP Cameras -> Trusted` +- `DROP Cameras -> Servers` +- `DROP Cameras -> IoT` +- `DROP Legacy CIA -> Trusted` +- `DROP Legacy CIA -> Servers` +- `DROP Legacy CIA -> Cameras` +- `DROP Legacy CIA -> IoT` + +Meaning: +- the core segmentation barriers between the restricted lanes and the rest of the network are not yet evidenced in the captured policy set + +### Section H: Optional discovery exceptions +Correctly absent for now: +- no evidence of broad Google/cast discovery exception rules +- this is good; the design explicitly says these should only appear after real failure testing + +## 4. Practical interpretation + +If the captured baselines are still current, then the environment appears to be in this state: + +1. The naming/object foundation is mostly there. +2. The network has at least one custom outbound-style policy. +3. The actual inter-VLAN enforcement plan is still largely unimplemented. +4. The current state is safer than random ad-hoc rules, but it is not yet the finished segmentation design. + +## 5. Safe next implementation order + +Before any live firewall apply, the safest order is: + +1. Create the missing host groups + - `HOST-ADMIN-TRUSTED` + - `HOST-DNS` + - `HOST-NTP` + - `HOST-PROTECT-SERVICES` + - helper/exception groups as needed + +2. Add the minimum safe rule skeleton first + - `ALLOW Established/Related` + - `DROP Invalid` + - management shield block set + - explicit Trusted admin -> Management allows + - Trusted -> Servers allow + +3. Add the restricted-lane minimum-function rules + - DNS + - NTP + - outbound internet as appropriate + +4. Add the broad internal deny matrix for Guest / IoT / Camera / Legacy CIA + +5. Only after that, consider narrow discovery exceptions if the Google/cast pilot proves they are actually needed + +## 6. What is safe to say right now + +Accurate phrasing: +- firewall scaffolding exists +- the object/group layer is mostly staged +- a basic outbound custom policy exists +- the full inter-VLAN segmentation matrix is not yet fully implemented + +Inaccurate phrasing to avoid: +- “the firewall is done” +- “inter-VLAN isolation is fully in place” +- “Legacy CIA is already fully quarantined by policy” + +## 7. Recommended next operator action + +Next safe action, still non-disruptive: +- define the missing host groups and map real devices/services into them on paper or in staging notes first + +Next live-action phase after that: +- apply the minimum safe rule skeleton in a rollback-friendly order, validating management reachability after each step diff --git a/home/doris-dashboard/docs/unifi-firewall-host-group-proposal-2026-05-22.md b/home/doris-dashboard/docs/unifi-firewall-host-group-proposal-2026-05-22.md new file mode 100644 index 0000000..c23356b --- /dev/null +++ b/home/doris-dashboard/docs/unifi-firewall-host-group-proposal-2026-05-22.md @@ -0,0 +1,249 @@ +# UniFi Firewall Host-Group Membership Proposal + +Purpose: propose concrete membership for the missing host/device groups referenced by the firewall design, using current repo documentation and the recent UniFi cleanup state. This is a planning artifact only; it does not apply live network changes. + +Evidence used: +- `docs/architecture/SERVICES_DIRECTORY.md` +- `docs/architecture/NETWORKING_MODEL.md` +- `docs/servers/PLAUSIBLEDENABILITY.md` +- `docs/servers/SERENITY.md` +- `docs/servers/ROCINANTE.md` +- `pihole/README.md` +- `home/doris-dashboard/docs/unifi-client-cleanup-shortlist-2026-05-22.md` +- memory note: UniFi is `10.5.0.1`; PD is `10.5.30.6`; Serenity Pi-hole HA VIP is `10.5.30.53/24` + +## Executive summary + +These groups should be split into three confidence bands: + +- High confidence: safe to define now from documented inventory +- Medium confidence: likely right, but validate in UniFi/UI before live enforcement +- Low confidence / leave empty for now: do not guess; create the group but keep it empty until a real dependent flow proves it is needed + +## 1. Proposed group membership + +### HOST-ADMIN-TRUSTED +Purpose: +- devices allowed to initiate management/admin traffic into the Management lane + +Proposed members: +- Rocinante trusted endpoint/client +- John primary phone: `Pixel-9-Pro-XL` +- Optional: `Pixel-7` if you really want phone-based admin + +Do NOT include by default: +- `FlyingDutchman` +- `steamdeck` +- `Valkyrie` +- random laptops/TVs/tablets just because they are on Trusted + +Confidence: +- Medium + +Reasoning: +- the cleanup shortlist says these human/operator devices are intentionally on Trusted +- Rocinante is the explicit operator station in the cutover docs +- the firewall design says this group should be small and deliberate + +Implementation note: +- if UniFi requires IPs instead of clean client objects for this group, resolve the current IP/MACs from live client state before creating the actual group + +### HOST-CORE-SERVICES +Purpose: +- core homelab service hosts on the Servers lane + +Proposed members: +- PlausibleDeniability / PD -> `10.5.30.6` +- Serenity -> `10.5.30.5` +- N.O.M.A.D. -> `10.5.30.7` +- Rocinante -> `10.5.30.112` + +Confidence: +- High + +Reasoning: +- all four are documented infrastructure hosts +- these are the obvious server endpoints repeatedly referenced across the repo + +### HOST-PROTECT-SERVICES +Purpose: +- target for camera/security devices that need to talk to the Protect/NVR side + +Proposed members: +- UDM Pro / UniFi gateway-controller -> `10.5.0.1` + +Confidence: +- Medium + +Reasoning: +- the docs clearly identify `10.5.0.1` as the UDM Pro / controller +- the chimes and doorbell are UniFi Protect accessories +- no separate UNVR/NVR host is documented anywhere obvious in the repo inventory + +Caution: +- verify in UniFi before hard enforcement whether Protect is actually hosted on the UDM Pro in this environment or whether there is another Protect endpoint not yet documented + +### HOST-DNS +Purpose: +- approved DNS resolvers for restricted lanes + +Proposed members: +- Pi-hole HA VIP -> `10.5.30.53` +- Optional explicit backing nodes if you want belt-and-suspenders: + - PD Pi-hole primary -> `10.5.30.6` + - NOMAD Pi-hole replica admin host -> `10.5.30.7` + +Recommended practical membership: +- start with just `10.5.30.53` + +Confidence: +- High for VIP +- Medium for adding the backing hosts directly + +Reasoning: +- the repo explicitly calls `10.5.30.53` the VIP for client DNS +- using the VIP keeps the policy clean and decoupled from backend failover details + +### HOST-NTP +Purpose: +- approved NTP targets for restricted lanes + +Proposed members: +- leave empty for now if clients are intended to use public NTP directly + +Alternative if you insist on local NTP later: +- add the actual documented local NTP server only after verifying one exists and is intended for clients + +Confidence: +- Low / intentionally unresolved + +Reasoning: +- the firewall design explicitly says this can be omitted if public NTP is used +- the repo docs do not currently provide a clear dedicated client-facing LAN NTP service +- the Pi-hole docs explicitly note Pi-hole NTP is disabled on PD because host `chronyd` owns UDP 123, but that alone is not enough proof that PD should become the universal client NTP target + +Recommendation: +- for first-pass enforcement, model NTP as outbound internet allowed where needed rather than pretending a local NTP service is definitely part of the design + +### HOST-IOT-HELPERS +Purpose: +- specific server-side helpers that IoT devices are allowed to contact + +Proposed members for first pass: +- Home Assistant on PD -> `10.5.30.6` service port `8123` +- Kima Hub on PD -> `10.5.30.6` service port `3333` + +Possible later additions only if proven necessary: +- any MQTT broker actually used by IoT devices +- any local appliance helper/integration endpoint that demonstrably breaks without local access + +Confidence: +- Medium + +Reasoning: +- Home Assistant and Kima Hub are the only clearly documented smart-home/helper services in the repo inventory +- these are plausible “approved server helpers” for IoT +- do not add Plex, Tautulli, or general app hosts here by default just because they exist + +Caution: +- no explicit MQTT broker is clearly documented in the current inventory as a central IoT dependency; do not invent one into this group + +### HOST-CAMERA-HELPERS +Purpose: +- server-side helpers cameras/security devices are allowed to contact beyond pure internet access + +Proposed members: +- same initial target as HOST-PROTECT-SERVICES: + - UDM Pro / Protect endpoint -> `10.5.0.1` + +Confidence: +- Medium + +Reasoning: +- until a separate Protect/NVR host is documented, the safest concrete starting point is the documented UniFi gateway/controller + +### HOST-LEGACY-EXCEPTIONS +Purpose: +- explicit ugly one-off quarantine exceptions for legacy devices + +Proposed members: +- none initially; create the group empty + +Confidence: +- High for “empty by default” + +Reasoning: +- the design explicitly treats Legacy CIA as hospice, not production +- there is no documented exception that has already earned inclusion +- empty is safer than pretending one-offs are already justified + +## 2. Port-group follow-up proposal + +The firewall gap list noted two useful missing port groups. + +### PORT-PROTECT +Proposed status: +- define later, only after verifying the actual required Protect ports for chime/doorbell traffic in this environment + +Confidence: +- Low now + +Reasoning: +- the repo docs do not yet give a trusted exact Protect port list for this design +- better to leave broad camera policy undone than to guess wrong and strand security devices + +### PORT-CAST +Proposed status: +- do not define yet + +Confidence: +- High for deferral + +Reasoning: +- the design explicitly says cast/discovery rules should only appear after real failure testing +- wait for the Google/cast pilot + +## 3. Suggested first-pass implementation set + +If you want the cleanest minimal live enforcement pass later, the least-controversial groups to create/use first are: + +1. `HOST-CORE-SERVICES` + - `10.5.30.5` + - `10.5.30.6` + - `10.5.30.7` + - `10.5.30.112` + +2. `HOST-DNS` + - `10.5.30.53` + +3. `HOST-ADMIN-TRUSTED` + - only the one or two devices John truly admins from + +4. `HOST-PROTECT-SERVICES` + - `10.5.0.1` pending verification + +5. `HOST-IOT-HELPERS` + - `10.5.30.6` only, if using Home Assistant / Kima Hub as the first-pass helper target + +And leave these empty until proven: +- `HOST-NTP` +- `HOST-CAMERA-HELPERS` if different from Protect +- `HOST-LEGACY-EXCEPTIONS` + +## 4. Validation questions before live rule apply + +Before turning these into real enforced firewall objects, verify: + +1. Which exact Trusted client(s) should truly admin Management? +2. Is Protect actually on the UDM Pro at `10.5.0.1`, or is there a separate undocumented host? +3. Do IoT devices need Home Assistant and/or Kima Hub locally right now, or can they live on cloud-only plus DNS/internet first? +4. Is there a real client-facing local NTP service, or should NTP just be allowed outbound? +5. Are there any Legacy CIA one-off exceptions that have actually earned existence? If not, keep that group empty. + +## 5. Blunt recommendation + +If we want a safe first real enforcement pass later: +- definitely create/use `HOST-CORE-SERVICES`, `HOST-DNS`, and a very small `HOST-ADMIN-TRUSTED` +- probably create `HOST-PROTECT-SERVICES` as `10.5.0.1` after one verification step +- treat `HOST-NTP`, `PORT-PROTECT`, and `PORT-CAST` as intentionally unresolved until verified +- keep `HOST-LEGACY-EXCEPTIONS` empty unless a specific ugly legacy device proves it needs one diff --git a/n8n-workflows/01-grafana-alert-ai-gotify-v1.4.json b/n8n-workflows/01-grafana-alert-ai-gotify-v1.4.json index be1dad3..f6bdedb 100644 --- a/n8n-workflows/01-grafana-alert-ai-gotify-v1.4.json +++ b/n8n-workflows/01-grafana-alert-ai-gotify-v1.4.json @@ -1,5 +1,11 @@ { + "updatedAt": "2026-05-22T21:55:07.241Z", + "createdAt": "2026-05-09T19:58:19.095Z", + "id": "fmywVBIanfxOfDlI", "name": "Grafana Alert → AI → Gotify v1.4", + "description": null, + "active": true, + "isArchived": false, "nodes": [ { "parameters": { @@ -7,44 +13,57 @@ "path": "grafana-alert", "options": {} }, - "id": "a6e7dfb1-8e9b-4048-b028-4383c30a7e0d", + "id": "0871c06f-a76f-43fe-93f0-45d48f5d6b1a", "name": "Grafana Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, - "position": [0, 0], + "position": [ + 0, + 0 + ], "webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3" }, { "parameters": { "jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a =>\n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];" }, - "id": "ea216058-69db-42cc-88d0-8f67e1b8951b", + "id": "134ce285-5e4e-41c9-ab50-50f429592065", "name": "Parse Alert Payload", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [224, 0] + "position": [ + 224, + 0 + ] }, { "parameters": { "jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n" }, - "id": "df26d9ca-c72c-4dee-9327-14a0f5fcf3a0", + "id": "dabc016f-5258-4e55-bd2c-9d0eddc5e5e4", "name": "Build Fingerprint", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [448, 0] + "position": [ + 448, + 0 + ] }, { "parameters": { "operation": "executeQuery", - "query": "={{ 'SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = \\'' + $json.fingerprint + '\\' AND last_seen > NOW() - INTERVAL \\'30 minutes\\' LIMIT 1;' }}", + "query": "{{ 'SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = \\'' + $json.fingerprint + '\\' AND last_seen > NOW() - INTERVAL \\'30 minutes\\' LIMIT 1;' }}", "options": {} }, - "id": "3fd78a53-7885-4739-a4b7-b441a27e993e", + "id": "48f75ff7-46ca-42b3-a8fa-b70e00ccf11b", "name": "Lookup Dedup", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, - "position": [672, 0], + "position": [ + 672, + 0 + ], + "alwaysOutputData": true, "credentials": { "postgres": { "id": "n9svoXemqSZoNNUB", @@ -57,11 +76,14 @@ "parameters": { "jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, shouldNotify: suppress ? 'no' : 'yes', suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n" }, - "id": "c34b4b0e-1a47-4624-b1c4-9ef6b5cdeb6c", + "id": "71143216-2d33-4f42-8204-25c4933e4e9f", "name": "Check Should Notify", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [896, 0] + "position": [ + 896, + 0 + ] }, { "parameters": { @@ -69,7 +91,8 @@ "options": { "caseSensitive": true, "leftValue": "", - "typeValidation": "strict" + "typeValidation": "strict", + "version": 1 }, "conditions": [ { @@ -83,35 +106,45 @@ } ], "combinator": "and" - } + }, + "options": {} }, - "id": "29929dc8-17f6-4cc8-90c2-c501eaba8be5", + "id": "280b1128-1e31-4e3e-bf25-f3c186395462", "name": "Should Notify?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, - "position": [1120, 0] + "position": [ + 1120, + 0 + ] }, { "parameters": { "jsCode": "const d = $input.first().json;\nconst first = (d.parsed || [])[0] || {};\nreturn [{ json: {\n fingerprint: d.fingerprint,\n dedupAlertName: first.alertName || 'unknown',\n dedupHost: first.host || 'unknown'\n} }];" }, - "id": "54705c76-512d-4479-889f-dc0a3c24d1b3", + "id": "0fd4eaf5-c883-4732-8144-d188552d5efa", "name": "Prepare Dedup Data", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1344, 0] + "position": [ + 1344, + 0 + ] }, { "parameters": { "operation": "executeQuery", - "query": "={{ 'INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES (\\'' + $json.fingerprint + '\\', NOW(), \\'' + $json.dedupAlertName + '\\', \\'' + $json.dedupHost + '\\') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();' }}", + "query": "{{ 'INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES (\\'' + $json.fingerprint + '\\', NOW(), \\'' + $json.dedupAlertName + '\\', \\'' + $json.dedupHost + '\\') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();' }}", "options": {} }, - "id": "25a14a6a-db17-49ca-a6d2-6ed2f97d2889", + "id": "04297e85-5a83-420d-a05d-f2ea23c5aa5e", "name": "Upsert Dedup Record", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, - "position": [1568, 0], + "position": [ + 1568, + 0 + ], "credentials": { "postgres": { "id": "n9svoXemqSZoNNUB", @@ -124,11 +157,14 @@ "parameters": { "jsCode": "// Build the LiteLLM request body from Parse Alert Payload data.\n// Doing this in a Code node (instead of inline in the HTTP node) ensures\n// the expression resolves cleanly — same pattern as RSS Digest which is stable.\nconst alertData = $('Parse Alert Payload').first().json;\nconst alertText = alertData.alertText || '(no alert text received)';\n\nreturn [{\n json: {\n aiBody: {\n model: 'medium',\n messages: [\n {\n role: 'system',\n content: 'You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief — this goes to a phone notification.'\n },\n {\n role: 'user',\n content: 'Summarize this Grafana alert:\\n\\n' + alertText\n }\n ],\n max_tokens: 200,\n temperature: 0.3\n }\n }\n}];" }, - "id": "b1c2d3e4-f5a6-7890-abcd-ef1234567890", + "id": "3e5ea479-7184-45ff-93c7-328968380b9f", "name": "Prepare AI Request", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1792, 0] + "position": [ + 1792, + 0 + ] }, { "parameters": { @@ -143,7 +179,7 @@ }, { "name": "Authorization", - "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" + "value": "=Bearer {{ $env.LITELLM_API_KEY }}" } ] }, @@ -159,22 +195,28 @@ "timeout": 120000 } }, - "id": "8ef1aa9d-d5b1-41ea-abc6-703b6491ea33", + "id": "799c6592-9ef4-456a-90c1-4b42fa07855d", "name": "LiteLLM Summarize", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [2016, 0], + "position": [ + 2016, + 0 + ], "continueOnFail": true }, { "parameters": { "jsCode": "const response = $input.first().json;\nconst alertData = $('Parse Alert Payload').first().json;\n\nlet summary;\nif (response.error || response.errorMessage) {\n summary = 'AI summary unavailable — check LiteLLM.';\n} else {\n summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\n}\n\nconst title = alertData.hasFiring\n ? `🔥 Alert Firing (${alertData.alertCount})`\n : `✅ Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];" }, - "id": "2e969bca-8458-4477-a358-664fd1c50c4f", + "id": "2de2e9cc-8dfe-42e2-9b40-fc518ae7f8ec", "name": "Format Notification", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2240, 0] + "position": [ + 2240, + 0 + ] }, { "parameters": { @@ -187,55 +229,143 @@ "contentType": "text/plain" } }, - "id": "65c2b53a-6e03-4460-9aa6-5d967d244de0", + "id": "1d1a4e02-fb80-4a3e-95ce-a9431ddb1c43", "name": "Push to Gotify", "type": "n8n-nodes-base.gotify", "typeVersion": 1, - "position": [2464, 0], + "position": [ + 2464, + 0 + ], "credentials": { "gotifyApi": { "id": "DXdYZDfVZecDTaNU", - "name": "Gotify account" + "name": "Gotify Grafana Warning" } } } ], "connections": { "Grafana Webhook": { - "main": [[{ "node": "Parse Alert Payload", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Parse Alert Payload", + "type": "main", + "index": 0 + } + ] + ] }, "Parse Alert Payload": { - "main": [[{ "node": "Build Fingerprint", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Build Fingerprint", + "type": "main", + "index": 0 + } + ] + ] }, "Build Fingerprint": { - "main": [[{ "node": "Lookup Dedup", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Lookup Dedup", + "type": "main", + "index": 0 + } + ] + ] }, "Lookup Dedup": { - "main": [[{ "node": "Check Should Notify", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Check Should Notify", + "type": "main", + "index": 0 + } + ] + ] }, "Check Should Notify": { - "main": [[{ "node": "Should Notify?", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Should Notify?", + "type": "main", + "index": 0 + } + ] + ] }, "Should Notify?": { "main": [ - [{ "node": "Prepare Dedup Data", "type": "main", "index": 0 }], - [] + [ + { + "node": "Prepare Dedup Data", + "type": "main", + "index": 0 + } + ] ] }, "Prepare Dedup Data": { - "main": [[{ "node": "Upsert Dedup Record", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Upsert Dedup Record", + "type": "main", + "index": 0 + } + ] + ] }, "Upsert Dedup Record": { - "main": [[{ "node": "Prepare AI Request", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Prepare AI Request", + "type": "main", + "index": 0 + } + ] + ] }, "Prepare AI Request": { - "main": [[{ "node": "LiteLLM Summarize", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "LiteLLM Summarize", + "type": "main", + "index": 0 + } + ] + ] }, "LiteLLM Summarize": { - "main": [[{ "node": "Format Notification", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Format Notification", + "type": "main", + "index": 0 + } + ] + ] }, "Format Notification": { - "main": [[{ "node": "Push to Gotify", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Push to Gotify", + "type": "main", + "index": 0 + } + ] + ] } }, "settings": { @@ -243,8 +373,420 @@ "binaryMode": "separate" }, "staticData": null, - "meta": { - "templateCredsSetupCompleted": true - }, - "pinData": {} + "meta": null, + "pinData": {}, + "versionId": "b4e4c7e1-41d4-4a3b-a3f8-1d2d243bc863", + "activeVersionId": "b4e4c7e1-41d4-4a3b-a3f8-1d2d243bc863", + "versionCounter": 37, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-09T19:58:19.095Z", + "createdAt": "2026-05-09T19:58:19.095Z", + "role": "workflow:owner", + "workflowId": "fmywVBIanfxOfDlI", + "projectId": "dxCRnBdX5uJizCGa", + "project": { + "updatedAt": "2026-05-06T01:10:37.484Z", + "createdAt": "2026-05-06T01:08:07.721Z", + "id": "dxCRnBdX5uJizCGa", + "name": "Wilfred Fizzlepoof ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-05-22T21:55:07.243Z", + "createdAt": "2026-05-22T21:55:07.243Z", + "versionId": "b4e4c7e1-41d4-4a3b-a3f8-1d2d243bc863", + "workflowId": "fmywVBIanfxOfDlI", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "grafana-alert", + "options": {} + }, + "id": "0871c06f-a76f-43fe-93f0-45d48f5d6b1a", + "name": "Grafana Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ], + "webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3" + }, + { + "parameters": { + "jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a =>\n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];" + }, + "id": "134ce285-5e4e-41c9-ab50-50f429592065", + "name": "Parse Alert Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 224, + 0 + ] + }, + { + "parameters": { + "jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n" + }, + "id": "dabc016f-5258-4e55-bd2c-9d0eddc5e5e4", + "name": "Build Fingerprint", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 448, + 0 + ] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "{{ 'SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = \\'' + $json.fingerprint + '\\' AND last_seen > NOW() - INTERVAL \\'30 minutes\\' LIMIT 1;' }}", + "options": {} + }, + "id": "48f75ff7-46ca-42b3-a8fa-b70e00ccf11b", + "name": "Lookup Dedup", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [ + 672, + 0 + ], + "alwaysOutputData": true, + "credentials": { + "postgres": { + "id": "n9svoXemqSZoNNUB", + "name": "Postgres account" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, shouldNotify: suppress ? 'no' : 'yes', suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n" + }, + "id": "71143216-2d33-4f42-8204-25c4933e4e9f", + "name": "Check Should Notify", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 896, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 1 + }, + "conditions": [ + { + "id": "cc41d77c-b659-4c6f-8e71-96a13dced242", + "leftValue": "={{ $json.shouldNotify }}", + "rightValue": "yes", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "280b1128-1e31-4e3e-bf25-f3c186395462", + "name": "Should Notify?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 1120, + 0 + ] + }, + { + "parameters": { + "jsCode": "const d = $input.first().json;\nconst first = (d.parsed || [])[0] || {};\nreturn [{ json: {\n fingerprint: d.fingerprint,\n dedupAlertName: first.alertName || 'unknown',\n dedupHost: first.host || 'unknown'\n} }];" + }, + "id": "0fd4eaf5-c883-4732-8144-d188552d5efa", + "name": "Prepare Dedup Data", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1344, + 0 + ] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "{{ 'INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES (\\'' + $json.fingerprint + '\\', NOW(), \\'' + $json.dedupAlertName + '\\', \\'' + $json.dedupHost + '\\') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();' }}", + "options": {} + }, + "id": "04297e85-5a83-420d-a05d-f2ea23c5aa5e", + "name": "Upsert Dedup Record", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [ + 1568, + 0 + ], + "credentials": { + "postgres": { + "id": "n9svoXemqSZoNNUB", + "name": "Postgres account" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "// Build the LiteLLM request body from Parse Alert Payload data.\n// Doing this in a Code node (instead of inline in the HTTP node) ensures\n// the expression resolves cleanly — same pattern as RSS Digest which is stable.\nconst alertData = $('Parse Alert Payload').first().json;\nconst alertText = alertData.alertText || '(no alert text received)';\n\nreturn [{\n json: {\n aiBody: {\n model: 'medium',\n messages: [\n {\n role: 'system',\n content: 'You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief — this goes to a phone notification.'\n },\n {\n role: 'user',\n content: 'Summarize this Grafana alert:\\n\\n' + alertText\n }\n ],\n max_tokens: 200,\n temperature: 0.3\n }\n }\n}];" + }, + "id": "3e5ea479-7184-45ff-93c7-328968380b9f", + "name": "Prepare AI Request", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1792, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.6:4000/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Authorization", + "value": "=Bearer {{ $env.LITELLM_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.aiBody) }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 120000 + } + }, + "id": "799c6592-9ef4-456a-90c1-4b42fa07855d", + "name": "LiteLLM Summarize", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2016, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const response = $input.first().json;\nconst alertData = $('Parse Alert Payload').first().json;\n\nlet summary;\nif (response.error || response.errorMessage) {\n summary = 'AI summary unavailable — check LiteLLM.';\n} else {\n summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\n}\n\nconst title = alertData.hasFiring\n ? `🔥 Alert Firing (${alertData.alertCount})`\n : `✅ Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];" + }, + "id": "2de2e9cc-8dfe-42e2-9b40-fc518ae7f8ec", + "name": "Format Notification", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2240, + 0 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "1d1a4e02-fb80-4a3e-95ce-a9431ddb1c43", + "name": "Push to Gotify", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 2464, + 0 + ], + "credentials": { + "gotifyApi": { + "id": "DXdYZDfVZecDTaNU", + "name": "Gotify Grafana Warning" + } + } + } + ], + "connections": { + "Grafana Webhook": { + "main": [ + [ + { + "node": "Parse Alert Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Alert Payload": { + "main": [ + [ + { + "node": "Build Fingerprint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Fingerprint": { + "main": [ + [ + { + "node": "Lookup Dedup", + "type": "main", + "index": 0 + } + ] + ] + }, + "Lookup Dedup": { + "main": [ + [ + { + "node": "Check Should Notify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Should Notify": { + "main": [ + [ + { + "node": "Should Notify?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Should Notify?": { + "main": [ + [ + { + "node": "Prepare Dedup Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Dedup Data": { + "main": [ + [ + { + "node": "Upsert Dedup Record", + "type": "main", + "index": 0 + } + ] + ] + }, + "Upsert Dedup Record": { + "main": [ + [ + { + "node": "Prepare AI Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare AI Request": { + "main": [ + [ + { + "node": "LiteLLM Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "LiteLLM Summarize": { + "main": [ + [ + { + "node": "Format Notification", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Notification": { + "main": [ + [ + { + "node": "Push to Gotify", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-22T21:55:07.301Z", + "id": 319, + "workflowId": "fmywVBIanfxOfDlI", + "versionId": "b4e4c7e1-41d4-4a3b-a3f8-1d2d243bc863", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-22T21:55:07.325Z", + "id": 320, + "workflowId": "fmywVBIanfxOfDlI", + "versionId": "b4e4c7e1-41d4-4a3b-a3f8-1d2d243bc863", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] + } } diff --git a/n8n-workflows/04-whisper-transcription.json b/n8n-workflows/04-whisper-transcription.json index f0c29d5..302c233 100644 --- a/n8n-workflows/04-whisper-transcription.json +++ b/n8n-workflows/04-whisper-transcription.json @@ -1,7 +1,7 @@ { - "updatedAt": "2026-05-06T23:30:36.237Z", - "createdAt": "2026-05-06T23:30:36.237Z", - "id": "QEVnqyN4h5kcpVuJ", + "updatedAt": "2026-05-22T22:47:04.512Z", + "createdAt": "2026-05-09T22:31:02.480Z", + "id": "9qfbZJJmSJqmg5sX", "name": "Whisper Audio Transcription", "description": null, "active": true, @@ -17,7 +17,7 @@ "rawBody": true } }, - "id": "91945dd9-42f8-4343-b131-9e46272f04bf", + "id": "83da1668-bc21-4d8f-bf35-f0fbf45168c2", "name": "Transcription Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, @@ -65,7 +65,7 @@ }, "options": {} }, - "id": "fc726196-f62d-4ff4-9be6-a84b9dbfff9e", + "id": "98a7fb4d-fd91-451d-b5d8-00e8382742ef", "name": "Whisper Transcribe", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -78,7 +78,7 @@ "parameters": { "jsCode": "const result = $input.first().json;\n\nconst segments = result.segments || [];\nconst fullText = result.text || segments.map(s => s.text).join(' ');\nconst duration = result.duration || segments[segments.length - 1]?.end || 0;\n\n// Format timestamps\nconst timestamped = segments.map(s => {\n const start = new Date(s.start * 1000).toISOString().substr(11, 8);\n return `[${start}] ${s.text.trim()}`;\n}).join('\\n');\n\nreturn [{\n json: {\n text: fullText.trim(),\n timestamped_text: timestamped,\n duration_seconds: Math.round(duration),\n segment_count: segments.length,\n language: result.language || 'en'\n }\n}];" }, - "id": "1b6f017f-849d-456f-9e4b-a0bced1d8e9f", + "id": "44c39528-48a3-4769-9f46-302b5a4a15e2", "name": "Format Transcript", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -97,7 +97,7 @@ "rawBody": true } }, - "id": "c2f8a2dd-cbbc-4b62-ae24-0f4a95bdf862", + "id": "5f400dfe-a426-4462-a7a8-62d6bd809d21", "name": "Transcribe + Summarize Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, @@ -141,7 +141,7 @@ }, "options": {} }, - "id": "ee25bf40-99ef-472b-928d-6ef63516094c", + "id": "83ef29fb-596f-4621-9c3e-3f930389838e", "name": "Whisper Transcribe 2", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -172,7 +172,7 @@ "jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a meeting/audio note summarizer. Given a transcription, provide:\\n1. A brief summary (2-3 sentences)\\n2. Key points as a bullet list\\n3. Any action items mentioned\\n\\nRespond in JSON with keys: summary, key_points (array), action_items (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this transcription:\\n\\n{{ $json.text }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}", "options": {} }, - "id": "eda24425-dc26-4b4b-858e-9ae0d5e1a5e4", + "id": "66078f0a-2ed6-4f19-bbde-4ac186975bbb", "name": "AI Summarize Audio", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -185,7 +185,7 @@ "parameters": { "jsCode": "const llmResponse = $input.first().json;\nconst transcript = $('Whisper Transcribe 2').first().json;\nlet analysis;\n\ntry {\n analysis = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n analysis = { summary: llmResponse.choices?.[0]?.message?.content || 'Summary failed', key_points: [], action_items: [] };\n}\n\nreturn [{\n json: {\n transcript: transcript.text || '',\n duration_seconds: Math.round(transcript.duration || 0),\n language: transcript.language || 'en',\n summary: analysis.summary,\n key_points: analysis.key_points || [],\n action_items: analysis.action_items || []\n }\n}];" }, - "id": "cf41669b-8bf7-433b-b390-10765954b0a9", + "id": "318664ee-dec9-4ed3-bb2b-65ae04f05119", "name": "Format Summary Response", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -196,9 +196,9 @@ }, { "parameters": { - "jsCode": "const t = $input.first().json;\nconst mins = Math.floor(t.duration_seconds / 60);\nconst secs = t.duration_seconds % 60;\nreturn [{ json: {\n title: '\ud83c\udf99\ufe0f Transcription Complete',\n message: `Duration: ${mins}m ${secs}s \u00b7 ${t.segment_count} segments \u00b7 Language: ${t.language}`,\n priority: 3\n}}];\n" + "jsCode": "const t = $input.first().json;\nconst mins = Math.floor(t.duration_seconds / 60);\nconst secs = t.duration_seconds % 60;\nreturn [{ json: {\n title: '🎙️ Transcription Complete',\n message: `Duration: ${mins}m ${secs}s · ${t.segment_count} segments · Language: ${t.language}`,\n priority: 3\n}}];\n" }, - "id": "5c692355-2a46-4c66-860a-c02a21c73597", + "id": "3d9e7895-5486-430d-a812-fe0eade6297e", "name": "Format Transcription Notify", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -218,7 +218,7 @@ "contentType": "text/plain" } }, - "id": "0e96240d-50e2-49f7-be62-4f57451e0543", + "id": "b84d1549-0675-4fc7-8c44-023e6bc59d57", "name": "Notify Transcription Done", "type": "n8n-nodes-base.gotify", "typeVersion": 1, @@ -228,7 +228,7 @@ ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", + "id": "MzvuWWDoIzIBNW5k", "name": "Whisper" } } @@ -237,7 +237,7 @@ "parameters": { "jsCode": "const t = $('Format Transcript').first().json;\nconst title = `Transcript ${new Date().toLocaleDateString('en-US')}`;\nconst content = `Transcript\\nDate: ${new Date().toISOString()}\\nDuration: ${t.duration_seconds}s\\nLanguage: ${t.language}\\n\\n${t.timestamped_text || t.text}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName: `${title}.txt` }}, json: { title } }];\n" }, - "id": "da9f4d8d-8c5b-413a-badd-71287dbe0a3c", + "id": "f34ac00a-73fb-4bc9-80e2-cceff281f091", "name": "Prepare Transcript Doc", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -256,7 +256,7 @@ "parameters": [ { "name": "Authorization", - "value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}" + "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" } ] }, @@ -280,7 +280,7 @@ ] } }, - "id": "7c84da5a-41e2-4b38-9e58-19acfd73f81d", + "id": "1ce7fdc1-ba26-4254-86ed-5042a05e665b", "name": "Save Transcript to Paperless", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -292,9 +292,9 @@ }, { "parameters": { - "jsCode": "const s = $input.first().json;\nconst mins = Math.floor(s.duration_seconds / 60);\nconst points = (s.key_points || []).slice(0,3).map((p,i) => `${i+1}. ${p}`).join('\\n');\nreturn [{ json: {\n title: '\ud83c\udf99\ufe0f Transcription + Summary Complete',\n message: `${mins}m audio\\n\\nSummary: ${s.summary}\\n\\nKey points:\\n${points}`,\n priority: 4\n}}];\n" + "jsCode": "const s = $input.first().json;\nconst mins = Math.floor(s.duration_seconds / 60);\nconst points = (s.key_points || []).slice(0,3).map((p,i) => `${i+1}. ${p}`).join('\\n');\nreturn [{ json: {\n title: '🎙️ Transcription + Summary Complete',\n message: `${mins}m audio\\n\\nSummary: ${s.summary}\\n\\nKey points:\\n${points}`,\n priority: 4\n}}];\n" }, - "id": "e55363e4-ff9b-4df4-b40a-fd23227c23e8", + "id": "da6d6f1d-6e61-4c20-a511-3c1e21f67722", "name": "Format Summary Notify", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -314,7 +314,7 @@ "contentType": "text/plain" } }, - "id": "b5defea9-35a0-4a22-8769-ed9496a022f6", + "id": "9628d545-5a5a-4c83-95fa-9cceb63b9124", "name": "Notify Summary Done", "type": "n8n-nodes-base.gotify", "typeVersion": 1, @@ -324,7 +324,7 @@ ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", + "id": "MzvuWWDoIzIBNW5k", "name": "Whisper" } } @@ -333,7 +333,7 @@ "parameters": { "jsCode": "const s = $('Format Summary Response').first().json;\nconst title = `Audio Summary ${new Date().toLocaleDateString('en-US')}`;\nconst points = (s.key_points || []).map((p,i) => `${i+1}. ${p}`).join('\\n');\nconst actions = (s.action_items || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Audio Transcription & Summary`, `Date: ${new Date().toISOString()}`,\n `Duration: ${Math.floor(s.duration_seconds/60)}m ${s.duration_seconds%60}s`,\n `Language: ${s.language}`, '', `Summary`, `=======`, s.summary,\n '', `Key Points`, `==========`, points,\n ...(actions ? ['', `Action Items`, `============`, actions] : []),\n '', `Full Transcript`, `==============`, s.transcript\n].join('\\n');\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title } }];\n" }, - "id": "1f31f608-7985-4a9a-afcf-6cf4e44150ba", + "id": "f3935208-9dcf-460a-99b1-d41df92ac6b3", "name": "Prepare Summary Doc", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -352,7 +352,7 @@ "parameters": [ { "name": "Authorization", - "value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}" + "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" } ] }, @@ -376,7 +376,7 @@ ] } }, - "id": "e9eaee2f-d08e-4a76-af53-ed824ebfe68f", + "id": "f4b94dc6-6cb3-4ad4-a390-3a8bd35daac2", "name": "Save Summary to Paperless", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -537,38 +537,20 @@ "binaryMode": "separate" }, "staticData": null, - "meta": null, + "meta": { + "templateCredsSetupCompleted": true + }, "pinData": {}, - "versionId": "20275daf-b924-4311-9a7f-116a99911764", - "activeVersionId": "20275daf-b924-4311-9a7f-116a99911764", - "versionCounter": 12, + "versionId": "81c25ef2-9dc3-4110-b1af-cd3b82056261", + "activeVersionId": "81c25ef2-9dc3-4110-b1af-cd3b82056261", + "versionCounter": 31, "triggerCount": 2, - "tags": [ - { - "updatedAt": "2026-05-06T22:39:59.719Z", - "createdAt": "2026-05-06T22:39:59.719Z", - "id": "GLLrEIR8I7eA4ydv", - "name": "transcription" - }, - { - "updatedAt": "2026-05-06T22:39:59.716Z", - "createdAt": "2026-05-06T22:39:59.716Z", - "id": "tuPxDoKFqpVrrQoX", - "name": "whisper" - }, - { - "updatedAt": "2026-05-06T22:38:51.678Z", - "createdAt": "2026-05-06T22:38:51.678Z", - "id": "S9FxzVfHLsHmyzyt", - "name": "ai" - } - ], "shared": [ { - "updatedAt": "2026-05-06T23:30:36.237Z", - "createdAt": "2026-05-06T23:30:36.237Z", + "updatedAt": "2026-05-09T22:31:02.480Z", + "createdAt": "2026-05-09T22:31:02.480Z", "role": "workflow:owner", - "workflowId": "QEVnqyN4h5kcpVuJ", + "workflowId": "9qfbZJJmSJqmg5sX", "projectId": "dxCRnBdX5uJizCGa", "project": { "updatedAt": "2026-05-06T01:10:37.484Z", @@ -582,8 +564,578 @@ } } ], - "versionMetadata": { - "name": "Version 20275daf", - "description": "" + "tags": [ + { + "updatedAt": "2026-05-06T22:39:59.719Z", + "createdAt": "2026-05-06T22:39:59.719Z", + "id": "GLLrEIR8I7eA4ydv", + "name": "transcription" + }, + { + "updatedAt": "2026-05-06T22:38:51.678Z", + "createdAt": "2026-05-06T22:38:51.678Z", + "id": "S9FxzVfHLsHmyzyt", + "name": "ai" + }, + { + "updatedAt": "2026-05-06T22:39:59.716Z", + "createdAt": "2026-05-06T22:39:59.716Z", + "id": "tuPxDoKFqpVrrQoX", + "name": "whisper" + } + ], + "activeVersion": { + "updatedAt": "2026-05-22T22:47:04.513Z", + "createdAt": "2026-05-22T22:47:04.513Z", + "versionId": "81c25ef2-9dc3-4110-b1af-cd3b82056261", + "workflowId": "9qfbZJJmSJqmg5sX", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "transcribe", + "responseMode": "lastNode", + "responseData": "allEntries", + "options": { + "rawBody": true + } + }, + "id": "83da1668-bc21-4d8f-bf35-f0fbf45168c2", + "name": "Transcription Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ], + "webhookId": "f50eec94-1e56-4189-88ee-51b701c1d889" + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.6:8786/v1/audio/transcriptions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "parameterType": "formBinaryData", + "name": "file", + "inputDataFieldName": "data" + }, + { + "name": "model", + "value": "Systran/faster-whisper-large-v3" + }, + { + "name": "language", + "value": "en" + }, + { + "name": "response_format", + "value": "verbose_json" + } + ] + }, + "options": {} + }, + "id": "98a7fb4d-fd91-451d-b5d8-00e8382742ef", + "name": "Whisper Transcribe", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 0 + ] + }, + { + "parameters": { + "jsCode": "const result = $input.first().json;\n\nconst segments = result.segments || [];\nconst fullText = result.text || segments.map(s => s.text).join(' ');\nconst duration = result.duration || segments[segments.length - 1]?.end || 0;\n\n// Format timestamps\nconst timestamped = segments.map(s => {\n const start = new Date(s.start * 1000).toISOString().substr(11, 8);\n return `[${start}] ${s.text.trim()}`;\n}).join('\\n');\n\nreturn [{\n json: {\n text: fullText.trim(),\n timestamped_text: timestamped,\n duration_seconds: Math.round(duration),\n segment_count: segments.length,\n language: result.language || 'en'\n }\n}];" + }, + "id": "44c39528-48a3-4769-9f46-302b5a4a15e2", + "name": "Format Transcript", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 448, + 0 + ] + }, + { + "parameters": { + "httpMethod": "POST", + "path": "transcribe/summarize", + "responseMode": "lastNode", + "responseData": "allEntries", + "options": { + "rawBody": true + } + }, + "id": "5f400dfe-a426-4462-a7a8-62d6bd809d21", + "name": "Transcribe + Summarize Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 304 + ], + "webhookId": "69b4b2e8-9486-4aa3-b9db-fb8e6bdae98e" + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.6:8786/v1/audio/transcriptions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "parameterType": "formBinaryData", + "name": "file", + "inputDataFieldName": "data" + }, + { + "name": "model", + "value": "Systran/faster-whisper-large-v3" + }, + { + "name": "language", + "value": "en" + } + ] + }, + "options": {} + }, + "id": "83ef29fb-596f-4621-9c3e-3f930389838e", + "name": "Whisper Transcribe 2", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 304 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.6:4000/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Authorization", + "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a meeting/audio note summarizer. Given a transcription, provide:\\n1. A brief summary (2-3 sentences)\\n2. Key points as a bullet list\\n3. Any action items mentioned\\n\\nRespond in JSON with keys: summary, key_points (array), action_items (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this transcription:\\n\\n{{ $json.text }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}", + "options": {} + }, + "id": "66078f0a-2ed6-4f19-bbde-4ac186975bbb", + "name": "AI Summarize Audio", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 448, + 304 + ] + }, + { + "parameters": { + "jsCode": "const llmResponse = $input.first().json;\nconst transcript = $('Whisper Transcribe 2').first().json;\nlet analysis;\n\ntry {\n analysis = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n analysis = { summary: llmResponse.choices?.[0]?.message?.content || 'Summary failed', key_points: [], action_items: [] };\n}\n\nreturn [{\n json: {\n transcript: transcript.text || '',\n duration_seconds: Math.round(transcript.duration || 0),\n language: transcript.language || 'en',\n summary: analysis.summary,\n key_points: analysis.key_points || [],\n action_items: analysis.action_items || []\n }\n}];" + }, + "id": "318664ee-dec9-4ed3-bb2b-65ae04f05119", + "name": "Format Summary Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 672, + 304 + ] + }, + { + "parameters": { + "jsCode": "const t = $input.first().json;\nconst mins = Math.floor(t.duration_seconds / 60);\nconst secs = t.duration_seconds % 60;\nreturn [{ json: {\n title: '🎙️ Transcription Complete',\n message: `Duration: ${mins}m ${secs}s · ${t.segment_count} segments · Language: ${t.language}`,\n priority: 3\n}}];\n" + }, + "id": "3d9e7895-5486-430d-a812-fe0eade6297e", + "name": "Format Transcription Notify", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 672, + 0 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "b84d1549-0675-4fc7-8c44-023e6bc59d57", + "name": "Notify Transcription Done", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 896, + 0 + ], + "credentials": { + "gotifyApi": { + "id": "MzvuWWDoIzIBNW5k", + "name": "Whisper" + } + } + }, + { + "parameters": { + "jsCode": "const t = $('Format Transcript').first().json;\nconst title = `Transcript ${new Date().toLocaleDateString('en-US')}`;\nconst content = `Transcript\\nDate: ${new Date().toISOString()}\\nDuration: ${t.duration_seconds}s\\nLanguage: ${t.language}\\n\\n${t.timestamped_text || t.text}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName: `${title}.txt` }}, json: { title } }];\n" + }, + "id": "f34ac00a-73fb-4bc9-80e2-cceff281f091", + "name": "Prepare Transcript Doc", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1120, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://paperless.paccoco.com/api/documents/post_document/", + "options": {}, + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" + } + ] + }, + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "name": "document", + "value": "data", + "parameterType": "formBinaryData" + }, + { + "name": "title", + "value": "={{ $json.title }}" + }, + { + "name": "tags[]", + "value": "${PAPERLESS_TAG_TRANSCRIPTION}" + } + ] + } + }, + "id": "1ce7fdc1-ba26-4254-86ed-5042a05e665b", + "name": "Save Transcript to Paperless", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1344, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const s = $input.first().json;\nconst mins = Math.floor(s.duration_seconds / 60);\nconst points = (s.key_points || []).slice(0,3).map((p,i) => `${i+1}. ${p}`).join('\\n');\nreturn [{ json: {\n title: '🎙️ Transcription + Summary Complete',\n message: `${mins}m audio\\n\\nSummary: ${s.summary}\\n\\nKey points:\\n${points}`,\n priority: 4\n}}];\n" + }, + "id": "da6d6f1d-6e61-4c20-a511-3c1e21f67722", + "name": "Format Summary Notify", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 896, + 304 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "9628d545-5a5a-4c83-95fa-9cceb63b9124", + "name": "Notify Summary Done", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 1120, + 304 + ], + "credentials": { + "gotifyApi": { + "id": "MzvuWWDoIzIBNW5k", + "name": "Whisper" + } + } + }, + { + "parameters": { + "jsCode": "const s = $('Format Summary Response').first().json;\nconst title = `Audio Summary ${new Date().toLocaleDateString('en-US')}`;\nconst points = (s.key_points || []).map((p,i) => `${i+1}. ${p}`).join('\\n');\nconst actions = (s.action_items || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Audio Transcription & Summary`, `Date: ${new Date().toISOString()}`,\n `Duration: ${Math.floor(s.duration_seconds/60)}m ${s.duration_seconds%60}s`,\n `Language: ${s.language}`, '', `Summary`, `=======`, s.summary,\n '', `Key Points`, `==========`, points,\n ...(actions ? ['', `Action Items`, `============`, actions] : []),\n '', `Full Transcript`, `==============`, s.transcript\n].join('\\n');\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title } }];\n" + }, + "id": "f3935208-9dcf-460a-99b1-d41df92ac6b3", + "name": "Prepare Summary Doc", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1344, + 304 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://paperless.paccoco.com/api/documents/post_document/", + "options": {}, + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" + } + ] + }, + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "name": "document", + "value": "data", + "parameterType": "formBinaryData" + }, + { + "name": "title", + "value": "={{ $json.title }}" + }, + { + "name": "tags[]", + "value": "${PAPERLESS_TAG_TRANSCRIPTION}" + } + ] + } + }, + "id": "f4b94dc6-6cb3-4ad4-a390-3a8bd35daac2", + "name": "Save Summary to Paperless", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1568, + 304 + ], + "continueOnFail": true + } + ], + "connections": { + "Transcription Webhook": { + "main": [ + [ + { + "node": "Whisper Transcribe", + "type": "main", + "index": 0 + } + ] + ] + }, + "Whisper Transcribe": { + "main": [ + [ + { + "node": "Format Transcript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Transcribe + Summarize Webhook": { + "main": [ + [ + { + "node": "Whisper Transcribe 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Whisper Transcribe 2": { + "main": [ + [ + { + "node": "AI Summarize Audio", + "type": "main", + "index": 0 + } + ] + ] + }, + "AI Summarize Audio": { + "main": [ + [ + { + "node": "Format Summary Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Transcript": { + "main": [ + [ + { + "node": "Format Transcription Notify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Transcription Notify": { + "main": [ + [ + { + "node": "Notify Transcription Done", + "type": "main", + "index": 0 + } + ] + ] + }, + "Notify Transcription Done": { + "main": [ + [ + { + "node": "Prepare Transcript Doc", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Transcript Doc": { + "main": [ + [ + { + "node": "Save Transcript to Paperless", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Summary Response": { + "main": [ + [ + { + "node": "Format Summary Notify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Summary Notify": { + "main": [ + [ + { + "node": "Notify Summary Done", + "type": "main", + "index": 0 + } + ] + ] + }, + "Notify Summary Done": { + "main": [ + [ + { + "node": "Prepare Summary Doc", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Summary Doc": { + "main": [ + [ + { + "node": "Save Summary to Paperless", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-22T22:47:04.563Z", + "id": 343, + "workflowId": "9qfbZJJmSJqmg5sX", + "versionId": "81c25ef2-9dc3-4110-b1af-cd3b82056261", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-22T22:47:04.608Z", + "id": 344, + "workflowId": "9qfbZJJmSJqmg5sX", + "versionId": "81c25ef2-9dc3-4110-b1af-cd3b82056261", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] } -} \ No newline at end of file +} diff --git a/n8n-workflows/07-git-commit-summarizer-v1.0.json b/n8n-workflows/07-git-commit-summarizer-v1.0.json index ab1c980..6bfc486 100644 --- a/n8n-workflows/07-git-commit-summarizer-v1.0.json +++ b/n8n-workflows/07-git-commit-summarizer-v1.0.json @@ -1,8 +1,8 @@ { - "updatedAt": "2026-05-07T00:49:43.226Z", + "updatedAt": "2026-05-22T22:47:04.407Z", "createdAt": "2026-05-07T00:49:43.226Z", "id": "CFYWbBRx9RPf4HjS", - "name": "Git Commit \u2192 AI Summary \u2192 Gotify v1.0", + "name": "Git Commit → AI Summary → Gotify v1.0", "description": null, "active": true, "isArchived": false, @@ -69,7 +69,7 @@ }, { "parameters": { - "jsCode": "const response = $input.first().json;\nconst gitData = $('Parse Git Payload').first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not summarize';\n\nconst title = `\ud83d\udd00 ${gitData.repo}:${gitData.branch} (${gitData.commitCount} commit${gitData.commitCount !== 1 ? 's' : ''})`;\n\nreturn [{\n json: {\n title,\n message: `${summary}\\n\\nPushed by: ${gitData.pusher}`,\n priority: 3\n }\n}];" + "jsCode": "const response = $input.first().json;\nconst gitData = $('Parse Git Payload').first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not summarize';\n\nconst title = `🔀 ${gitData.repo}:${gitData.branch} (${gitData.commitCount} commit${gitData.commitCount !== 1 ? 's' : ''})`;\n\nreturn [{\n json: {\n title,\n message: `${summary}\\n\\nPushed by: ${gitData.pusher}`,\n priority: 3\n }\n}];" }, "id": "94f5c120-0ae6-4a23-9556-d4f564f9f967", "name": "Format Notification", @@ -101,8 +101,8 @@ ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", - "name": "Git Commits" + "id": "GX35U3Xrp0LyoxjL", + "name": "Git Commit" } } } @@ -160,34 +160,14 @@ "staticData": null, "meta": null, "pinData": {}, - "versionId": "61fca126-b5dd-4425-b0ee-c621d3ed58af", - "activeVersionId": "61fca126-b5dd-4425-b0ee-c621d3ed58af", - "versionCounter": 12, + "versionId": "d0a38817-f868-4838-8db1-2f99841f9996", + "activeVersionId": "d0a38817-f868-4838-8db1-2f99841f9996", + "versionCounter": 51, "triggerCount": 1, - "tags": [ - { - "updatedAt": "2026-05-07T00:49:41.640Z", - "createdAt": "2026-05-07T00:49:41.640Z", - "id": "36CVfQVO41QQyOmh", - "name": "notifications" - }, - { - "updatedAt": "2026-05-07T00:49:41.638Z", - "createdAt": "2026-05-07T00:49:41.638Z", - "id": "2oumYE52ygvhrieZ", - "name": "git" - }, - { - "updatedAt": "2026-05-06T22:38:51.678Z", - "createdAt": "2026-05-06T22:38:51.678Z", - "id": "S9FxzVfHLsHmyzyt", - "name": "ai" - } - ], "shared": [ { - "updatedAt": "2026-05-07T00:49:43.226Z", - "createdAt": "2026-05-07T00:49:43.226Z", + "updatedAt": "2026-05-09T19:08:31.281Z", + "createdAt": "2026-05-09T19:08:31.281Z", "role": "workflow:owner", "workflowId": "CFYWbBRx9RPf4HjS", "projectId": "dxCRnBdX5uJizCGa", @@ -203,8 +183,199 @@ } } ], - "versionMetadata": { - "name": "Version 61fca126", - "description": "" + "tags": [ + { + "updatedAt": "2026-05-07T00:49:41.638Z", + "createdAt": "2026-05-07T00:49:41.638Z", + "id": "2oumYE52ygvhrieZ", + "name": "git" + }, + { + "updatedAt": "2026-05-07T00:49:41.640Z", + "createdAt": "2026-05-07T00:49:41.640Z", + "id": "36CVfQVO41QQyOmh", + "name": "notifications" + }, + { + "updatedAt": "2026-05-06T22:38:51.678Z", + "createdAt": "2026-05-06T22:38:51.678Z", + "id": "S9FxzVfHLsHmyzyt", + "name": "ai" + } + ], + "activeVersion": { + "updatedAt": "2026-05-22T22:47:04.409Z", + "createdAt": "2026-05-22T22:47:04.409Z", + "versionId": "d0a38817-f868-4838-8db1-2f99841f9996", + "workflowId": "CFYWbBRx9RPf4HjS", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "git/push", + "options": {} + }, + "id": "fe377ac9-adcf-427d-a096-679f5469f8d8", + "name": "Git Push Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ], + "webhookId": "d1d31942-6fe8-48a2-aab7-7eefaf800420" + }, + { + "parameters": { + "jsCode": "const data = $input.first().json;\nconst body = data.body || data;\n\n// Works with Gitea, Forgejo, GitHub, and GitLab webhook payloads\nconst repo = body.repository?.full_name || body.repository?.name || body.project?.path_with_namespace || 'unknown-repo';\nconst branch = (body.ref || '').replace('refs/heads/', '');\nconst pusher = body.pusher?.name || body.pusher?.login || body.user_name || body.sender?.login || 'unknown';\n\n// Extract commits\nconst commits = (body.commits || []).map(c => ({\n id: (c.id || c.sha || '').substring(0, 7),\n message: (c.message || '').split('\\n')[0],\n author: c.author?.name || c.author?.username || 'unknown',\n added: (c.added || []).length,\n modified: (c.modified || []).length,\n removed: (c.removed || []).length\n}));\n\nconst commitSummary = commits.map(c =>\n `${c.id}: ${c.message} (by ${c.author}) [+${c.added} ~${c.modified} -${c.removed}]`\n).join('\\n');\n\nreturn [{\n json: {\n repo,\n branch,\n pusher,\n commitCount: commits.length,\n commitSummary,\n compareUrl: body.compare_url || body.compare || ''\n }\n}];" + }, + "id": "8dda4f14-9563-490f-89b3-aef80f64ba0b", + "name": "Parse Git Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 224, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.6:4000/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Authorization", + "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"model\": \"light\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a git commit summarizer. Given a list of commits pushed to a repo, write a 1-2 sentence summary of what changed. Be concise and technical. Focus on the purpose of the changes, not just listing files.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Repository: {{ $json.repo }}\\nBranch: {{ $json.branch }}\\nPushed by: {{ $json.pusher }}\\n\\nCommits ({{ $json.commitCount }}):\\n{{ $json.commitSummary }}\"\n }\n ],\n \"max_tokens\": 150,\n \"temperature\": 0.2\n}", + "options": {} + }, + "id": "c6b2b71b-2612-4ef7-8561-8adccbfa9be8", + "name": "AI Summarize Commits", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 448, + 0 + ] + }, + { + "parameters": { + "jsCode": "const response = $input.first().json;\nconst gitData = $('Parse Git Payload').first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not summarize';\n\nconst title = `🔀 ${gitData.repo}:${gitData.branch} (${gitData.commitCount} commit${gitData.commitCount !== 1 ? 's' : ''})`;\n\nreturn [{\n json: {\n title,\n message: `${summary}\\n\\nPushed by: ${gitData.pusher}`,\n priority: 3\n }\n}];" + }, + "id": "94f5c120-0ae6-4a23-9556-d4f564f9f967", + "name": "Format Notification", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 672, + 0 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "4f731683-574c-4a39-84fe-025280193ba1", + "name": "Push to Gotify", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 880, + 0 + ], + "credentials": { + "gotifyApi": { + "id": "GX35U3Xrp0LyoxjL", + "name": "Git Commit" + } + } + } + ], + "connections": { + "Git Push Webhook": { + "main": [ + [ + { + "node": "Parse Git Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Git Payload": { + "main": [ + [ + { + "node": "AI Summarize Commits", + "type": "main", + "index": 0 + } + ] + ] + }, + "AI Summarize Commits": { + "main": [ + [ + { + "node": "Format Notification", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Notification": { + "main": [ + [ + { + "node": "Push to Gotify", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-22T22:47:04.449Z", + "id": 341, + "workflowId": "CFYWbBRx9RPf4HjS", + "versionId": "d0a38817-f868-4838-8db1-2f99841f9996", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-22T22:47:04.472Z", + "id": 342, + "workflowId": "CFYWbBRx9RPf4HjS", + "versionId": "d0a38817-f868-4838-8db1-2f99841f9996", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] } } diff --git a/n8n-workflows/08-class-recording-rag.json b/n8n-workflows/08-class-recording-rag.json index c90d4dc..9103405 100644 --- a/n8n-workflows/08-class-recording-rag.json +++ b/n8n-workflows/08-class-recording-rag.json @@ -1,8 +1,8 @@ { - "updatedAt": "2026-05-07T01:16:50.340Z", - "createdAt": "2026-05-07T01:16:50.340Z", - "id": "91I278E72FAaQenD", - "name": "Class Recording \u2192 Transcribe \u2192 RAG", + "updatedAt": "2026-05-22T21:55:33.272Z", + "createdAt": "2026-05-10T02:04:29.809Z", + "id": "g9JRtBA5lR0OAwVo", + "name": "Class Recording → Transcribe → RAG", "description": null, "active": true, "isArchived": false, @@ -17,7 +17,7 @@ "rawBody": true } }, - "id": "e475f17b-8560-4376-852e-a613ba6b300e", + "id": "cfc95f6c-cec1-4f2b-9746-761762e99afb", "name": "Class Upload Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, @@ -29,9 +29,9 @@ }, { "parameters": { - "jsCode": "const data = $input.first().json;\nconst body = data.body || data;\nconst incomingBinary = $input.first().binary || {};\n\n// Class name comes from:\n// 1. Form field 'class_name' in multipart upload\n// 2. Query parameter ?class=CS101\n// 3. Header X-Class-Name\nconst className = body.class_name || data.headers?.['x-class-name'] || data.query?.class || 'unclassified';\n\n// Lecture title from:\n// 1. Form field 'lecture_title'\n// 2. Query param ?title=...\n// 3. Header X-Lecture-Title\n// 4. Form field 'filename' (original filename)\nconst rawTitle = body.lecture_title || data.headers?.['x-lecture-title'] || data.query?.title || body.filename || 'Untitled Lecture';\nconst lectureTitle = rawTitle.replace(/\\.(wav|mp3|m4a|ogg)$/i, '').replace(/[-_]/g, ' ');\n\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n uploadedAt: new Date().toISOString()\n },\n binary: incomingBinary\n}];" + "jsCode": "const data = $input.first().json;\nconst body = data.body || data;\nconst incomingBinary = $input.first().binary || {};\n\nconst className = body.class_name || data.headers?.['x-class-name'] || data.query?.class || 'unclassified';\nconst rawTitle = body.lecture_title || data.headers?.['x-lecture-title'] || data.query?.title || body.filename || 'Untitled Lecture';\nconst lectureTitle = rawTitle.replace(/\\.(wav|mp3|m4a|ogg)$/i, '').replace(/[-_]/g, ' ');\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n uploadedAt: new Date().toISOString()\n },\n binary: incomingBinary\n}];" }, - "id": "24e4a726-ab50-4767-a144-1751616c781d", + "id": "2ed58ecd-a1cd-45e2-a80c-faf35da1a5b1", "name": "Extract Class Info", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -80,7 +80,7 @@ "timeout": 600000 } }, - "id": "fcd1062d-8f60-4214-85ee-f53b914abcbb", + "id": "23663476-1dbd-45fa-b9df-e27a2f230dae", "name": "Whisper Transcribe", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -93,7 +93,7 @@ "parameters": { "jsCode": "const result = $input.first().json;\nconst classInfo = $('Extract Class Info').first().json;\n\nconst segments = result.segments || [];\nconst fullText = result.text || segments.map(s => s.text).join(' ');\nconst duration = result.duration || segments[segments.length - 1]?.end || 0;\n\nconst timestamped = segments.map(s => {\n const mins = Math.floor(s.start / 60);\n const secs = Math.floor(s.start % 60);\n return `[${mins}:${String(secs).padStart(2, '0')}] ${s.text.trim()}`;\n}).join('\\n');\n\nreturn [{\n json: {\n className: classInfo.className,\n classSlug: classInfo.classSlug,\n lectureTitle: classInfo.lectureTitle,\n fullText: fullText.trim(),\n timestampedText: timestamped,\n durationMinutes: Math.round(duration / 60),\n segmentCount: segments.length\n }\n}];" }, - "id": "ecf09ad2-327e-4a82-90f3-95da5590e0ac", + "id": "d61a4f61-4934-46de-a334-2493c0617b7e", "name": "Format Transcript", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -124,7 +124,7 @@ "jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an academic note-taking assistant. Given a class lecture transcription, provide:\\n1. A concise summary (3-5 sentences)\\n2. Key concepts covered (as a list)\\n3. Important definitions or formulas\\n4. Any assignments or deadlines mentioned\\n\\nRespond in JSON with keys: summary, key_concepts (array), definitions (array of {term, definition}), assignments (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Class: {{ $json.className }}\\nLecture: {{ $json.lectureTitle }}\\n\\nTranscription (first 3000 chars):\\n{{ $json.fullText.substring(0, 3000) }}\"\n }\n ],\n \"max_tokens\": 600,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}", "options": {} }, - "id": "e7407d06-f7f4-4105-a817-055d4703d59d", + "id": "d61b60ea-3e3e-4776-a715-c042a0da574c", "name": "AI Extract Notes", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -137,7 +137,7 @@ "parameters": { "jsCode": "const llmResponse = $input.first().json;\nconst transcriptData = $('Format Transcript').first().json;\n\nlet notes;\ntry {\n notes = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n notes = { summary: 'Could not parse notes', key_concepts: [], definitions: [], assignments: [] };\n}\n\nconst ragText = [\n `Class: ${transcriptData.className}`,\n `Lecture: ${transcriptData.lectureTitle}`,\n `Duration: ${transcriptData.durationMinutes} minutes`,\n `Summary: ${notes.summary}`,\n `Key Concepts: ${(notes.key_concepts || []).join(', ')}`,\n '',\n 'Full Transcript:',\n transcriptData.fullText\n].join('\\n');\n\nreturn [{\n json: {\n className: transcriptData.className,\n classSlug: transcriptData.classSlug,\n lectureTitle: transcriptData.lectureTitle,\n durationMinutes: transcriptData.durationMinutes,\n notes,\n ragText,\n source: `class-${transcriptData.classSlug}-${Date.now()}`,\n metadata: {\n class_name: transcriptData.className,\n class_slug: transcriptData.classSlug,\n lecture_title: transcriptData.lectureTitle,\n duration_minutes: transcriptData.durationMinutes,\n type: 'class_recording',\n recorded_at: new Date().toISOString()\n }\n }\n}];" }, - "id": "71b0807d-03ba-45b2-b75b-b6fbeee5687e", + "id": "b89dc0d0-ae38-4056-b5d4-4ed1a01ca2d8", "name": "Prepare for RAG", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -150,7 +150,7 @@ "parameters": { "jsCode": "const input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 500;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n if ((current + ' ' + sentence).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + sentence;\n } else {\n current = current ? current + ' ' + sentence : sentence;\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: {\n chunk,\n chunkIndex: i,\n totalChunks: chunks.length,\n source,\n metadata\n }\n}));" }, - "id": "9e4bbe70-72b0-4955-848f-0d1b62ae94a4", + "id": "0e2248b6-30fa-4e5b-8b41-61fda6864c8a", "name": "Chunk Text", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -184,7 +184,7 @@ } } }, - "id": "4ae1ab11-c76f-4478-84e0-7bcdfb20b83b", + "id": "eda997ea-bd9a-4698-ba71-09aa73b6b0cb", "name": "Get Embedding", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -197,7 +197,7 @@ "parameters": { "jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];" }, - "id": "d4232b02-23fc-4cbd-ac2d-2fdbfbe9583a", + "id": "fa0b1600-5e53-44cc-aea9-34bb30417a9d", "name": "Prepare Qdrant Point", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -224,25 +224,25 @@ "jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}", "options": {} }, - "id": "845a2aa0-2546-431d-96e8-b36875d1fe7d", + "id": "df7ac34b-37a0-4492-8387-f30a43800ec7", "name": "Upsert to Qdrant", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 1984, - 0 + 1904, + -208 ] }, { "parameters": { - "jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `\ud83c\udf93 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];" + "jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `🎓 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];" }, - "id": "fmt08-0001-0000-0000-000000000008", + "id": "17d050cb-4c6f-488c-835c-f0b5cb43df0f", "name": "Format Gotify Message", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1968, + 2208, 0 ] }, @@ -257,45 +257,44 @@ "contentType": "text/plain" } }, - "id": "5b31b660-c414-40d1-82a5-d68511203133", + "id": "dd07e577-2bed-4100-9b37-f79b8dd59db3", "name": "Notify via Gotify", "type": "n8n-nodes-base.gotify", "typeVersion": 1, "position": [ - 2208, + 2448, 0 ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", - "name": "Class Recordings" + "id": "MzvuWWDoIzIBNW5k", + "name": "Whisper" } } }, { "parameters": { - "jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defs = Object.entries(notes.definitions || {}).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n].join('\\n');\nconst title = `${rag.className} \u2014 ${rag.lectureTitle}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title, className: rag.className } }];\n" + "jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defs = Object.entries(notes.definitions || {}).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n].join('\\n');\nconst title = `${rag.className} — ${rag.lectureTitle}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title, className: rag.className } }];\n" }, - "id": "95aea3a7-3b15-4196-9e4a-dc8f6ed8d49e", + "id": "ed77ded0-36ae-469a-8144-904a7105adda", "name": "Prepare Notes Doc", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2208, - -200 + -208 ] }, { "parameters": { "method": "POST", "url": "https://paperless.paccoco.com/api/documents/post_document/", - "options": {}, "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", - "value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}" + "value": "Bearer ${PAPERLESS_API_TOKEN}" } ] }, @@ -304,9 +303,8 @@ "bodyParameters": { "parameters": [ { - "name": "document", - "value": "data", - "parameterType": "formBinaryData" + "parameterType": "formBinaryData", + "name": "document" }, { "name": "title", @@ -317,15 +315,16 @@ "value": "${PAPERLESS_TAG_LECTURE_NOTES}" } ] - } + }, + "options": {} }, - "id": "c902a153-ab26-4518-af2a-585f0b72fe6c", + "id": "df288dd8-584b-476d-bbe0-a4fb9f55904e", "name": "Save Notes to Paperless", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2432, - -200 + -208 ], "continueOnFail": true } @@ -474,44 +473,20 @@ "binaryMode": "separate" }, "staticData": null, - "meta": null, + "meta": { + "templateCredsSetupCompleted": true + }, "pinData": {}, - "versionId": "d64293a7-5537-4b01-a11b-6b838d6cacbf", - "activeVersionId": "d64293a7-5537-4b01-a11b-6b838d6cacbf", - "versionCounter": 11, + "versionId": "987fe225-b99f-421a-a76e-b839a0a9bef5", + "activeVersionId": "987fe225-b99f-421a-a76e-b839a0a9bef5", + "versionCounter": 46, "triggerCount": 1, - "tags": [ - { - "updatedAt": "2026-05-07T00:49:56.081Z", - "createdAt": "2026-05-07T00:49:56.081Z", - "id": "Q9lCYtCOuy9XmeyL", - "name": "school" - }, - { - "updatedAt": "2026-05-06T22:39:59.716Z", - "createdAt": "2026-05-06T22:39:59.716Z", - "id": "tuPxDoKFqpVrrQoX", - "name": "whisper" - }, - { - "updatedAt": "2026-05-06T22:39:33.284Z", - "createdAt": "2026-05-06T22:39:33.284Z", - "id": "7uuEyQrIlcuruZBk", - "name": "rag" - }, - { - "updatedAt": "2026-05-06T22:38:51.678Z", - "createdAt": "2026-05-06T22:38:51.678Z", - "id": "S9FxzVfHLsHmyzyt", - "name": "ai" - } - ], "shared": [ { - "updatedAt": "2026-05-07T01:16:50.340Z", - "createdAt": "2026-05-07T01:16:50.340Z", + "updatedAt": "2026-05-10T02:04:29.809Z", + "createdAt": "2026-05-10T02:04:29.809Z", "role": "workflow:owner", - "workflowId": "91I278E72FAaQenD", + "workflowId": "g9JRtBA5lR0OAwVo", "projectId": "dxCRnBdX5uJizCGa", "project": { "updatedAt": "2026-05-06T01:10:37.484Z", @@ -525,8 +500,520 @@ } } ], - "versionMetadata": { - "name": "Version d64293a7", - "description": "" + "tags": [ + { + "updatedAt": "2026-05-06T22:39:33.284Z", + "createdAt": "2026-05-06T22:39:33.284Z", + "id": "7uuEyQrIlcuruZBk", + "name": "rag" + }, + { + "updatedAt": "2026-05-07T00:49:56.081Z", + "createdAt": "2026-05-07T00:49:56.081Z", + "id": "Q9lCYtCOuy9XmeyL", + "name": "school" + }, + { + "updatedAt": "2026-05-06T22:38:51.678Z", + "createdAt": "2026-05-06T22:38:51.678Z", + "id": "S9FxzVfHLsHmyzyt", + "name": "ai" + }, + { + "updatedAt": "2026-05-06T22:39:59.716Z", + "createdAt": "2026-05-06T22:39:59.716Z", + "id": "tuPxDoKFqpVrrQoX", + "name": "whisper" + } + ], + "activeVersion": { + "updatedAt": "2026-05-22T21:55:33.273Z", + "createdAt": "2026-05-22T21:55:33.273Z", + "versionId": "987fe225-b99f-421a-a76e-b839a0a9bef5", + "workflowId": "g9JRtBA5lR0OAwVo", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "class/upload", + "responseMode": "lastNode", + "responseData": "allEntries", + "options": { + "rawBody": true + } + }, + "id": "cfc95f6c-cec1-4f2b-9746-761762e99afb", + "name": "Class Upload Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ], + "webhookId": "d0ee8226-db56-477e-8a3f-572a9129aea9" + }, + { + "parameters": { + "jsCode": "const data = $input.first().json;\nconst body = data.body || data;\nconst incomingBinary = $input.first().binary || {};\n\nconst className = body.class_name || data.headers?.['x-class-name'] || data.query?.class || 'unclassified';\nconst rawTitle = body.lecture_title || data.headers?.['x-lecture-title'] || data.query?.title || body.filename || 'Untitled Lecture';\nconst lectureTitle = rawTitle.replace(/\\.(wav|mp3|m4a|ogg)$/i, '').replace(/[-_]/g, ' ');\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n uploadedAt: new Date().toISOString()\n },\n binary: incomingBinary\n}];" + }, + "id": "2ed58ecd-a1cd-45e2-a80c-faf35da1a5b1", + "name": "Extract Class Info", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 224, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.7:8786/v1/audio/transcriptions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "parameterType": "formBinaryData", + "name": "file", + "inputDataFieldName": "data" + }, + { + "name": "model", + "value": "Systran/faster-whisper-large-v3" + }, + { + "name": "language", + "value": "en" + }, + { + "name": "response_format", + "value": "verbose_json" + } + ] + }, + "options": { + "timeout": 600000 + } + }, + "id": "23663476-1dbd-45fa-b9df-e27a2f230dae", + "name": "Whisper Transcribe", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 448, + 0 + ] + }, + { + "parameters": { + "jsCode": "const result = $input.first().json;\nconst classInfo = $('Extract Class Info').first().json;\n\nconst segments = result.segments || [];\nconst fullText = result.text || segments.map(s => s.text).join(' ');\nconst duration = result.duration || segments[segments.length - 1]?.end || 0;\n\nconst timestamped = segments.map(s => {\n const mins = Math.floor(s.start / 60);\n const secs = Math.floor(s.start % 60);\n return `[${mins}:${String(secs).padStart(2, '0')}] ${s.text.trim()}`;\n}).join('\\n');\n\nreturn [{\n json: {\n className: classInfo.className,\n classSlug: classInfo.classSlug,\n lectureTitle: classInfo.lectureTitle,\n fullText: fullText.trim(),\n timestampedText: timestamped,\n durationMinutes: Math.round(duration / 60),\n segmentCount: segments.length\n }\n}];" + }, + "id": "d61a4f61-4934-46de-a334-2493c0617b7e", + "name": "Format Transcript", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 672, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.6:4000/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Authorization", + "value": "={{ \"Bearer \" + $env.LITELLM_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an academic note-taking assistant. Given a class lecture transcription, provide:\\n1. A concise summary (3-5 sentences)\\n2. Key concepts covered (as a list)\\n3. Important definitions or formulas\\n4. Any assignments or deadlines mentioned\\n\\nRespond in JSON with keys: summary, key_concepts (array), definitions (array of {term, definition}), assignments (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Class: {{ $json.className }}\\nLecture: {{ $json.lectureTitle }}\\n\\nTranscription (first 3000 chars):\\n{{ $json.fullText.substring(0, 3000) }}\"\n }\n ],\n \"max_tokens\": 600,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}", + "options": {} + }, + "id": "d61b60ea-3e3e-4776-a715-c042a0da574c", + "name": "AI Extract Notes", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 880, + 0 + ] + }, + { + "parameters": { + "jsCode": "const llmResponse = $input.first().json;\nconst transcriptData = $('Format Transcript').first().json;\n\nlet notes;\ntry {\n notes = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n notes = { summary: 'Could not parse notes', key_concepts: [], definitions: [], assignments: [] };\n}\n\nconst ragText = [\n `Class: ${transcriptData.className}`,\n `Lecture: ${transcriptData.lectureTitle}`,\n `Duration: ${transcriptData.durationMinutes} minutes`,\n `Summary: ${notes.summary}`,\n `Key Concepts: ${(notes.key_concepts || []).join(', ')}`,\n '',\n 'Full Transcript:',\n transcriptData.fullText\n].join('\\n');\n\nreturn [{\n json: {\n className: transcriptData.className,\n classSlug: transcriptData.classSlug,\n lectureTitle: transcriptData.lectureTitle,\n durationMinutes: transcriptData.durationMinutes,\n notes,\n ragText,\n source: `class-${transcriptData.classSlug}-${Date.now()}`,\n metadata: {\n class_name: transcriptData.className,\n class_slug: transcriptData.classSlug,\n lecture_title: transcriptData.lectureTitle,\n duration_minutes: transcriptData.durationMinutes,\n type: 'class_recording',\n recorded_at: new Date().toISOString()\n }\n }\n}];" + }, + "id": "b89dc0d0-ae38-4056-b5d4-4ed1a01ca2d8", + "name": "Prepare for RAG", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1104, + 0 + ] + }, + { + "parameters": { + "jsCode": "const input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 500;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n if ((current + ' ' + sentence).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + sentence;\n } else {\n current = current ? current + ' ' + sentence : sentence;\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: {\n chunk,\n chunkIndex: i,\n totalChunks: chunks.length,\n source,\n metadata\n }\n}));" + }, + "id": "0e2248b6-30fa-4e5b-8b41-61fda6864c8a", + "name": "Chunk Text", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1328, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.7:11434/v1/embeddings", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ ({ model: 'nomic-embed-text:v1.5', input: $json.chunk }) }}", + "options": { + "batching": { + "batch": { + "batchSize": 5, + "batchInterval": 500 + } + } + } + }, + "id": "eda997ea-bd9a-4698-ba71-09aa73b6b0cb", + "name": "Get Embedding", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1552, + 0 + ] + }, + { + "parameters": { + "jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];" + }, + "id": "fa0b1600-5e53-44cc-aea9-34bb30417a9d", + "name": "Prepare Qdrant Point", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1760, + 0 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "http://10.5.30.6:6333/collections/knowledge_base/points", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}", + "options": {} + }, + "id": "df7ac34b-37a0-4492-8387-f30a43800ec7", + "name": "Upsert to Qdrant", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1904, + -208 + ] + }, + { + "parameters": { + "jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `🎓 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];" + }, + "id": "17d050cb-4c6f-488c-835c-f0b5cb43df0f", + "name": "Format Gotify Message", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2208, + 0 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "dd07e577-2bed-4100-9b37-f79b8dd59db3", + "name": "Notify via Gotify", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 2448, + 0 + ], + "credentials": { + "gotifyApi": { + "id": "MzvuWWDoIzIBNW5k", + "name": "Whisper" + } + } + }, + { + "parameters": { + "jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defs = Object.entries(notes.definitions || {}).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n].join('\\n');\nconst title = `${rag.className} — ${rag.lectureTitle}`;\nreturn [{ binary: { document: { data: Buffer.from(content,'utf-8').toString('base64'), mimeType:'text/plain', fileName:`${title}.txt` }}, json: { title, className: rag.className } }];\n" + }, + "id": "ed77ded0-36ae-469a-8144-904a7105adda", + "name": "Prepare Notes Doc", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2208, + -208 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://paperless.paccoco.com/api/documents/post_document/", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "Bearer ${PAPERLESS_API_TOKEN}" + } + ] + }, + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "parameterType": "formBinaryData", + "name": "document" + }, + { + "name": "title", + "value": "={{ $json.title }}" + }, + { + "name": "tags[]", + "value": "${PAPERLESS_TAG_LECTURE_NOTES}" + } + ] + }, + "options": {} + }, + "id": "df288dd8-584b-476d-bbe0-a4fb9f55904e", + "name": "Save Notes to Paperless", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2432, + -208 + ], + "continueOnFail": true + } + ], + "connections": { + "Class Upload Webhook": { + "main": [ + [ + { + "node": "Extract Class Info", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Class Info": { + "main": [ + [ + { + "node": "Whisper Transcribe", + "type": "main", + "index": 0 + } + ] + ] + }, + "Whisper Transcribe": { + "main": [ + [ + { + "node": "Format Transcript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Transcript": { + "main": [ + [ + { + "node": "AI Extract Notes", + "type": "main", + "index": 0 + } + ] + ] + }, + "AI Extract Notes": { + "main": [ + [ + { + "node": "Prepare for RAG", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare for RAG": { + "main": [ + [ + { + "node": "Chunk Text", + "type": "main", + "index": 0 + } + ] + ] + }, + "Chunk Text": { + "main": [ + [ + { + "node": "Get Embedding", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Embedding": { + "main": [ + [ + { + "node": "Prepare Qdrant Point", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Qdrant Point": { + "main": [ + [ + { + "node": "Upsert to Qdrant", + "type": "main", + "index": 0 + } + ] + ] + }, + "Upsert to Qdrant": { + "main": [ + [ + { + "node": "Format Gotify Message", + "type": "main", + "index": 0 + }, + { + "node": "Prepare Notes Doc", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Gotify Message": { + "main": [ + [ + { + "node": "Notify via Gotify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Notes Doc": { + "main": [ + [ + { + "node": "Save Notes to Paperless", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-22T21:55:33.319Z", + "id": 333, + "workflowId": "g9JRtBA5lR0OAwVo", + "versionId": "987fe225-b99f-421a-a76e-b839a0a9bef5", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-22T21:55:33.338Z", + "id": 334, + "workflowId": "g9JRtBA5lR0OAwVo", + "versionId": "987fe225-b99f-421a-a76e-b839a0a9bef5", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] } } diff --git a/n8n-workflows/09-class-transcript-ingest-v1.1.json b/n8n-workflows/09-class-transcript-ingest-v1.1.json index 087aadd..dcc1a10 100644 --- a/n8n-workflows/09-class-transcript-ingest-v1.1.json +++ b/n8n-workflows/09-class-transcript-ingest-v1.1.json @@ -1,5 +1,11 @@ { + "updatedAt": "2026-05-22T21:55:33.626Z", + "createdAt": "2026-05-10T02:04:47.115Z", + "id": "sg7nRrhim0omh8oQ", "name": "Class Transcript Ingest v1.1", + "description": null, + "active": true, + "isArchived": false, "nodes": [ { "parameters": { @@ -8,13 +14,13 @@ "responseMode": "responseNode", "options": {} }, - "id": "a1b2c3d4-0001-0001-0001-000000000001", + "id": "050e1ebb-161f-45a9-8059-35ed79b66adc", "name": "Class Ingest Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [ - 0, - 0 + -224, + 208 ], "webhookId": "class-ingest-rocinante-v1" }, @@ -24,26 +30,26 @@ "responseBody": "={ \"status\": \"accepted\" }", "options": {} }, - "id": "a1b2c3d4-0001-0001-0001-000000000002", + "id": "5f7bdb15-59bf-45f3-a649-e822cf67a375", "name": "Respond Accepted", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1, "position": [ - 224, - -200 + 0, + 0 ] }, { "parameters": { "jsCode": "const body = $input.first().json.body || $input.first().json;\n\nconst className = body.class_name || 'unclassified';\nconst lectureTitle = body.lecture_title || 'Untitled Lecture';\nconst fullText = body.transcript || '';\nconst timestampedText = body.timestamped_transcript || fullText;\nconst durationMinutes = Math.round((body.duration_seconds || 0) / 60);\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n fullText,\n timestampedText,\n durationMinutes\n }\n}];" }, - "id": "a1b2c3d4-0001-0001-0001-000000000003", + "id": "2735cdda-dfd5-4673-b9e6-e31cc2887e8f", "name": "Extract Class Info", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 224, - 0 + 0, + 208 ] }, { @@ -59,7 +65,7 @@ }, { "name": "Authorization", - "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" + "value": "=Bearer {{ $env.LITELLM_API_KEY }}" } ] }, @@ -68,39 +74,39 @@ "jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an academic note-taking assistant. Given a class lecture transcription, provide:\\n1. A concise summary (3-5 sentences)\\n2. Key concepts covered (as a list)\\n3. Important definitions or formulas\\n4. Any assignments or deadlines mentioned\\n\\nRespond in JSON with keys: summary, key_concepts (array), definitions (array of {term, definition}), assignments (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Class: {{ $json.className }}\\nLecture: {{ $json.lectureTitle }}\\n\\nTranscription (first 4000 chars):\\n{{ $json.fullText.substring(0, 4000) }}\"\n }\n ],\n \"max_tokens\": 600,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}", "options": {} }, - "id": "a1b2c3d4-0001-0001-0001-000000000004", + "id": "2b504d0a-608a-43b3-986d-38611bf09184", "name": "AI Extract Notes", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 448, - 0 + 224, + 208 ] }, { "parameters": { "jsCode": "const llmResponse = $input.first().json;\nconst classInfo = $('Extract Class Info').first().json;\n\nlet notes;\ntry {\n notes = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n notes = { summary: 'Could not parse notes', key_concepts: [], definitions: [], assignments: [] };\n}\n\nconst ragText = [\n `Class: ${classInfo.className}`,\n `Lecture: ${classInfo.lectureTitle}`,\n `Duration: ${classInfo.durationMinutes} minutes`,\n `Summary: ${notes.summary}`,\n `Key Concepts: ${(notes.key_concepts || []).join(', ')}`,\n '',\n 'Full Transcript:',\n classInfo.fullText\n].join('\\n');\n\nreturn [{\n json: {\n className: classInfo.className,\n classSlug: classInfo.classSlug,\n lectureTitle: classInfo.lectureTitle,\n durationMinutes: classInfo.durationMinutes,\n fullText: classInfo.fullText,\n notes,\n ragText,\n source: `class-${classInfo.classSlug}-${Date.now()}`,\n metadata: {\n class_name: classInfo.className,\n class_slug: classInfo.classSlug,\n lecture_title: classInfo.lectureTitle,\n duration_minutes: classInfo.durationMinutes,\n type: 'class_recording',\n recorded_at: new Date().toISOString()\n }\n }\n}];" }, - "id": "a1b2c3d4-0001-0001-0001-000000000005", + "id": "eabc3cef-6005-4f13-a738-f5144dd887bd", "name": "Prepare for RAG", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 672, - 0 + 448, + 208 ] }, { "parameters": { "jsCode": "const input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 400;\nconst MAX_CHUNK = 600;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n // If a single sentence exceeds MAX_CHUNK, split it by words first\n const parts = [];\n if (sentence.length > MAX_CHUNK) {\n const words = sentence.split(' ');\n let part = '';\n for (const word of words) {\n if ((part + ' ' + word).length > CHUNK_SIZE && part.length > 0) {\n parts.push(part.trim());\n part = word;\n } else {\n part = part ? part + ' ' + word : word;\n }\n }\n if (part.trim()) parts.push(part.trim());\n } else {\n parts.push(sentence);\n }\n\n for (const part of parts) {\n if ((current + ' ' + part).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + part;\n } else {\n current = current ? current + ' ' + part : part;\n }\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: { chunk, chunkIndex: i, totalChunks: chunks.length, source, metadata }\n}));" }, - "id": "a1b2c3d4-0001-0001-0001-000000000006", + "id": "27e49a3d-1e3d-4915-973c-f9fead428abf", "name": "Chunk Text", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 896, - 0 + 672, + 208 ] }, { @@ -128,26 +134,26 @@ } } }, - "id": "a1b2c3d4-0001-0001-0001-000000000007", + "id": "edad418d-f4cc-4c45-a47f-dfa056436f04", "name": "Get Embedding", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 1120, - 0 + 896, + 208 ] }, { "parameters": { "jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];" }, - "id": "a1b2c3d4-0001-0001-0001-000000000008", + "id": "d2ffc045-043c-4292-aeb3-1ef4cf7d98aa", "name": "Prepare Qdrant Point", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1344, - 0 + 1120, + 208 ] }, { @@ -168,26 +174,26 @@ "jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}", "options": {} }, - "id": "a1b2c3d4-0001-0001-0001-000000000009", + "id": "18790ae4-91e9-4b0f-b7f8-7d6a2e3e53f6", "name": "Upsert to Qdrant", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 1568, - 0 + 1344, + 208 ] }, { "parameters": { - "jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `\ud83c\udf93 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];" + "jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `🎓 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];" }, - "id": "a1b2c3d4-0001-0001-0001-000000000010", + "id": "41fb5e91-ba0d-402e-b23f-85707e5b1890", "name": "Format Gotify Message", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1552, - -200 + 1328, + 0 ] }, { @@ -201,32 +207,32 @@ "contentType": "text/plain" } }, - "id": "a1b2c3d4-0001-0001-0001-000000000011", + "id": "714c0cc6-8023-4988-9e4a-60719263615f", "name": "Notify via Gotify", "type": "n8n-nodes-base.gotify", "typeVersion": 1, "position": [ - 1776, - -200 + 1552, + 0 ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", - "name": "Class Recordings" + "id": "MzvuWWDoIzIBNW5k", + "name": "Whisper" } } }, { "parameters": { - "jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defList = notes.definitions || [];\nconst defs = Array.isArray(defList)\n ? defList.map(d => `${d.term}: ${d.definition}`).join('\\n')\n : Object.entries(defList).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\n\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n '', `Full Transcript`, `================`, rag.fullText || '',\n].join('\\n');\n\nconst title = `${rag.className} \u2014 ${rag.lectureTitle}`;\nreturn [{\n binary: {\n document: {\n data: Buffer.from(content, 'utf-8').toString('base64'),\n mimeType: 'text/plain',\n fileName: `${title}.txt`\n }\n },\n json: { title, className: rag.className }\n}];" + "jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defList = notes.definitions || [];\nconst defs = Array.isArray(defList)\n ? defList.map(d => `${d.term}: ${d.definition}`).join('\\n')\n : Object.entries(defList).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\n\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n '', `Full Transcript`, `================`, rag.fullText || '',\n].join('\\n');\n\nconst title = `${rag.className} — ${rag.lectureTitle}`;\nreturn [{\n binary: {\n document: {\n data: Buffer.from(content, 'utf-8').toString('base64'),\n mimeType: 'text/plain',\n fileName: `${title}.txt`\n }\n },\n json: { title, className: rag.className }\n}];" }, - "id": "a1b2c3d4-0001-0001-0001-000000000012", + "id": "ff1febf5-881b-47c8-b247-e11f94ca7c5c", "name": "Prepare Notes Doc", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1776, - 200 + 1552, + 400 ] }, { @@ -269,13 +275,13 @@ } } }, - "id": "a1b2c3d4-0001-0001-0001-000000000013", + "id": "ed40f5a1-a165-40ec-adb1-9569884ab5bb", "name": "Save Notes to Paperless", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 2000, - 200 + 1776, + 400 ], "continueOnFail": true } @@ -407,17 +413,481 @@ "binaryMode": "separate" }, "staticData": null, - "meta": null, + "meta": { + "templateCredsSetupCompleted": true + }, "pinData": {}, + "versionId": "93f547cc-ab08-4080-919f-1d24cb1936ec", + "activeVersionId": "93f547cc-ab08-4080-919f-1d24cb1936ec", + "versionCounter": 23, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-10T02:04:47.115Z", + "createdAt": "2026-05-10T02:04:47.115Z", + "role": "workflow:owner", + "workflowId": "sg7nRrhim0omh8oQ", + "projectId": "dxCRnBdX5uJizCGa", + "project": { + "updatedAt": "2026-05-06T01:10:37.484Z", + "createdAt": "2026-05-06T01:08:07.721Z", + "id": "dxCRnBdX5uJizCGa", + "name": "Wilfred Fizzlepoof ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + } + ], "tags": [ { - "name": "school" - }, - { + "updatedAt": "2026-05-06T22:39:33.284Z", + "createdAt": "2026-05-06T22:39:33.284Z", + "id": "7uuEyQrIlcuruZBk", "name": "rag" }, { + "updatedAt": "2026-05-07T00:49:56.081Z", + "createdAt": "2026-05-07T00:49:56.081Z", + "id": "Q9lCYtCOuy9XmeyL", + "name": "school" + }, + { + "updatedAt": "2026-05-06T22:38:51.678Z", + "createdAt": "2026-05-06T22:38:51.678Z", + "id": "S9FxzVfHLsHmyzyt", "name": "ai" } - ] -} \ No newline at end of file + ], + "activeVersion": { + "updatedAt": "2026-05-22T21:55:33.628Z", + "createdAt": "2026-05-22T21:55:33.628Z", + "versionId": "93f547cc-ab08-4080-919f-1d24cb1936ec", + "workflowId": "sg7nRrhim0omh8oQ", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "class/ingest", + "responseMode": "responseNode", + "options": {} + }, + "id": "050e1ebb-161f-45a9-8059-35ed79b66adc", + "name": "Class Ingest Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + -224, + 208 + ], + "webhookId": "class-ingest-rocinante-v1" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={ \"status\": \"accepted\" }", + "options": {} + }, + "id": "5f7bdb15-59bf-45f3-a649-e822cf67a375", + "name": "Respond Accepted", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "jsCode": "const body = $input.first().json.body || $input.first().json;\n\nconst className = body.class_name || 'unclassified';\nconst lectureTitle = body.lecture_title || 'Untitled Lecture';\nconst fullText = body.transcript || '';\nconst timestampedText = body.timestamped_transcript || fullText;\nconst durationMinutes = Math.round((body.duration_seconds || 0) / 60);\nconst classSlug = className.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');\n\nreturn [{\n json: {\n className,\n classSlug,\n lectureTitle,\n fullText,\n timestampedText,\n durationMinutes\n }\n}];" + }, + "id": "2735cdda-dfd5-4673-b9e6-e31cc2887e8f", + "name": "Extract Class Info", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 0, + 208 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.6:4000/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Authorization", + "value": "=Bearer {{ $env.LITELLM_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an academic note-taking assistant. Given a class lecture transcription, provide:\\n1. A concise summary (3-5 sentences)\\n2. Key concepts covered (as a list)\\n3. Important definitions or formulas\\n4. Any assignments or deadlines mentioned\\n\\nRespond in JSON with keys: summary, key_concepts (array), definitions (array of {term, definition}), assignments (array)\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Class: {{ $json.className }}\\nLecture: {{ $json.lectureTitle }}\\n\\nTranscription (first 4000 chars):\\n{{ $json.fullText.substring(0, 4000) }}\"\n }\n ],\n \"max_tokens\": 600,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}", + "options": {} + }, + "id": "2b504d0a-608a-43b3-986d-38611bf09184", + "name": "AI Extract Notes", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 208 + ] + }, + { + "parameters": { + "jsCode": "const llmResponse = $input.first().json;\nconst classInfo = $('Extract Class Info').first().json;\n\nlet notes;\ntry {\n notes = JSON.parse(llmResponse.choices?.[0]?.message?.content || '{}');\n} catch(e) {\n notes = { summary: 'Could not parse notes', key_concepts: [], definitions: [], assignments: [] };\n}\n\nconst ragText = [\n `Class: ${classInfo.className}`,\n `Lecture: ${classInfo.lectureTitle}`,\n `Duration: ${classInfo.durationMinutes} minutes`,\n `Summary: ${notes.summary}`,\n `Key Concepts: ${(notes.key_concepts || []).join(', ')}`,\n '',\n 'Full Transcript:',\n classInfo.fullText\n].join('\\n');\n\nreturn [{\n json: {\n className: classInfo.className,\n classSlug: classInfo.classSlug,\n lectureTitle: classInfo.lectureTitle,\n durationMinutes: classInfo.durationMinutes,\n fullText: classInfo.fullText,\n notes,\n ragText,\n source: `class-${classInfo.classSlug}-${Date.now()}`,\n metadata: {\n class_name: classInfo.className,\n class_slug: classInfo.classSlug,\n lecture_title: classInfo.lectureTitle,\n duration_minutes: classInfo.durationMinutes,\n type: 'class_recording',\n recorded_at: new Date().toISOString()\n }\n }\n}];" + }, + "id": "eabc3cef-6005-4f13-a738-f5144dd887bd", + "name": "Prepare for RAG", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 448, + 208 + ] + }, + { + "parameters": { + "jsCode": "const input = $input.first().json;\nconst text = input.ragText || '';\nconst source = input.source;\nconst metadata = input.metadata;\n\nconst CHUNK_SIZE = 400;\nconst MAX_CHUNK = 600;\nconst OVERLAP = 50;\n\nconst chunks = [];\nconst sentences = text.split(/(?<=[.!?])\\s+/);\nlet current = '';\n\nfor (const sentence of sentences) {\n // If a single sentence exceeds MAX_CHUNK, split it by words first\n const parts = [];\n if (sentence.length > MAX_CHUNK) {\n const words = sentence.split(' ');\n let part = '';\n for (const word of words) {\n if ((part + ' ' + word).length > CHUNK_SIZE && part.length > 0) {\n parts.push(part.trim());\n part = word;\n } else {\n part = part ? part + ' ' + word : word;\n }\n }\n if (part.trim()) parts.push(part.trim());\n } else {\n parts.push(sentence);\n }\n\n for (const part of parts) {\n if ((current + ' ' + part).length > CHUNK_SIZE && current.length > 0) {\n chunks.push(current.trim());\n const words = current.split(' ');\n current = words.slice(-OVERLAP).join(' ') + ' ' + part;\n } else {\n current = current ? current + ' ' + part : part;\n }\n }\n}\nif (current.trim()) chunks.push(current.trim());\n\nreturn chunks.map((chunk, i) => ({\n json: { chunk, chunkIndex: i, totalChunks: chunks.length, source, metadata }\n}));" + }, + "id": "27e49a3d-1e3d-4915-973c-f9fead428abf", + "name": "Chunk Text", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 672, + 208 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.6:11434/v1/embeddings", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"model\": \"nomic-embed-text\",\n \"input\": {{ JSON.stringify($json.chunk) }}\n}", + "options": { + "batching": { + "batch": { + "batchSize": 5, + "batchInterval": 500 + } + } + } + }, + "id": "edad418d-f4cc-4c45-a47f-dfa056436f04", + "name": "Get Embedding", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 896, + 208 + ] + }, + { + "parameters": { + "jsCode": "const embeddingResponse = $input.first().json;\nconst chunkData = $('Chunk Text').item;\nconst embedding = embeddingResponse.data?.[0]?.embedding || [];\n\nconst pointId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = Math.random() * 16 | 0;\n return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n});\n\nreturn [{\n json: {\n id: pointId,\n vector: embedding,\n payload: {\n text: chunkData.json.chunk,\n source: chunkData.json.source,\n chunkIndex: chunkData.json.chunkIndex,\n totalChunks: chunkData.json.totalChunks,\n metadata: chunkData.json.metadata,\n ingested_at: new Date().toISOString()\n }\n }\n}];" + }, + "id": "d2ffc045-043c-4292-aeb3-1ef4cf7d98aa", + "name": "Prepare Qdrant Point", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1120, + 208 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "http://10.5.30.6:6333/collections/knowledge_base/points", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"points\": [\n {\n \"id\": \"{{ $json.id }}\",\n \"vector\": {{ JSON.stringify($json.vector) }},\n \"payload\": {{ JSON.stringify($json.payload) }}\n }\n ]\n}", + "options": {} + }, + "id": "18790ae4-91e9-4b0f-b7f8-7d6a2e3e53f6", + "name": "Upsert to Qdrant", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1344, + 208 + ] + }, + { + "parameters": { + "jsCode": "const rag = $('Prepare for RAG').first().json;\nreturn [{\n json: {\n title: `🎓 ${rag.className} Lecture Processed`,\n message: `${rag.lectureTitle} (${rag.durationMinutes}min)\\nSummary: ${rag.notes.summary}\\nKey concepts: ${(rag.notes.key_concepts || []).slice(0, 5).join(', ')}`,\n priority: 5\n }\n}];" + }, + "id": "41fb5e91-ba0d-402e-b23f-85707e5b1890", + "name": "Format Gotify Message", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1328, + 0 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "714c0cc6-8023-4988-9e4a-60719263615f", + "name": "Notify via Gotify", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 1552, + 0 + ], + "credentials": { + "gotifyApi": { + "id": "MzvuWWDoIzIBNW5k", + "name": "Whisper" + } + } + }, + { + "parameters": { + "jsCode": "const rag = $('Prepare for RAG').first().json;\nconst notes = rag.notes || {};\nconst concepts = (notes.key_concepts || []).map((c,i) => `${i+1}. ${c}`).join('\\n');\nconst defList = notes.definitions || [];\nconst defs = Array.isArray(defList)\n ? defList.map(d => `${d.term}: ${d.definition}`).join('\\n')\n : Object.entries(defList).map(([k,v]) => `${k}: ${v}`).join('\\n');\nconst assignments = (notes.assignments || []).map((a,i) => `${i+1}. ${a}`).join('\\n');\n\nconst content = [\n `Lecture Notes: ${rag.lectureTitle}`,\n `Class: ${rag.className}`,\n `Duration: ${rag.durationMinutes} minutes`,\n `Date: ${new Date().toISOString()}`, '',\n `Summary`, `=======`, notes.summary || '', '',\n `Key Concepts`, `=============`, concepts,\n ...(defs ? ['', `Definitions`, `===========`, defs] : []),\n ...(assignments ? ['', `Assignments`, `===========`, assignments] : []),\n '', `Full Transcript`, `================`, rag.fullText || '',\n].join('\\n');\n\nconst title = `${rag.className} — ${rag.lectureTitle}`;\nreturn [{\n binary: {\n document: {\n data: Buffer.from(content, 'utf-8').toString('base64'),\n mimeType: 'text/plain',\n fileName: `${title}.txt`\n }\n },\n json: { title, className: rag.className }\n}];" + }, + "id": "ff1febf5-881b-47c8-b247-e11f94ca7c5c", + "name": "Prepare Notes Doc", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1552, + 400 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://paperless.paccoco.com/api/documents/post_document/", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "=Token {{ $env.PAPERLESS_API_TOKEN }}" + } + ] + }, + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "parameterType": "formBinaryData", + "name": "document", + "inputDataFieldName": "document" + }, + { + "name": "title", + "value": "={{ $json.title }}" + }, + { + "name": "tags[]", + "value": "={{ $env.PAPERLESS_TAG_LECTURE_NOTES }}" + } + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "text" + } + } + } + }, + "id": "ed40f5a1-a165-40ec-adb1-9569884ab5bb", + "name": "Save Notes to Paperless", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1776, + 400 + ], + "continueOnFail": true + } + ], + "connections": { + "Class Ingest Webhook": { + "main": [ + [ + { + "node": "Extract Class Info", + "type": "main", + "index": 0 + }, + { + "node": "Respond Accepted", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Class Info": { + "main": [ + [ + { + "node": "AI Extract Notes", + "type": "main", + "index": 0 + } + ] + ] + }, + "AI Extract Notes": { + "main": [ + [ + { + "node": "Prepare for RAG", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare for RAG": { + "main": [ + [ + { + "node": "Chunk Text", + "type": "main", + "index": 0 + } + ] + ] + }, + "Chunk Text": { + "main": [ + [ + { + "node": "Get Embedding", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Embedding": { + "main": [ + [ + { + "node": "Prepare Qdrant Point", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Qdrant Point": { + "main": [ + [ + { + "node": "Upsert to Qdrant", + "type": "main", + "index": 0 + } + ] + ] + }, + "Upsert to Qdrant": { + "main": [ + [ + { + "node": "Format Gotify Message", + "type": "main", + "index": 0 + }, + { + "node": "Prepare Notes Doc", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Gotify Message": { + "main": [ + [ + { + "node": "Notify via Gotify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Notes Doc": { + "main": [ + [ + { + "node": "Save Notes to Paperless", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-22T21:55:33.659Z", + "id": 339, + "workflowId": "sg7nRrhim0omh8oQ", + "versionId": "93f547cc-ab08-4080-919f-1d24cb1936ec", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-22T21:55:33.678Z", + "id": 340, + "workflowId": "sg7nRrhim0omh8oQ", + "versionId": "93f547cc-ab08-4080-919f-1d24cb1936ec", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] + } +} diff --git a/n8n-workflows/14-paperless-inbox-reminder.json b/n8n-workflows/14-paperless-inbox-reminder.json index efdc758..08f2378 100644 --- a/n8n-workflows/14-paperless-inbox-reminder.json +++ b/n8n-workflows/14-paperless-inbox-reminder.json @@ -1,5 +1,9 @@ { + "updatedAt": "2026-05-18T17:37:47.374Z", + "createdAt": "2026-05-10T02:20:42.354Z", + "id": "IL9hNpWdzJfGar9V", "name": "Paperless Inbox Reminder", + "description": null, "active": true, "isArchived": false, "nodes": [ @@ -19,7 +23,7 @@ ] } }, - "id": "41404371-29ed-4660-9989-a5cbd890555b", + "id": "2a6f25ee-9a71-4024-9bda-881c0f9c65e2", "name": "Every Monday 9 AM", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, @@ -64,7 +68,7 @@ }, "options": {} }, - "id": "fdcd5728-6d27-445f-961b-18f204297469", + "id": "542f642c-8714-4d70-b09a-142819cd4f90", "name": "Fetch Inbox Docs", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -75,9 +79,9 @@ }, { "parameters": { - "jsCode": "const data = $input.first().json;\nconst docs = data.results || [];\nconst total = data.count || 0;\n\n// Oldest 5 for the reminder body\nconst oldest = docs.slice(0, 5).map(d => {\n const date = d.created ? d.created.substring(0, 10) : 'unknown date';\n return ` \u2022 ${d.title || 'Untitled'} (added ${date})`;\n});\n\nreturn [{\n json: {\n total,\n hasItems: total > 0,\n oldestList: oldest.join('\\n'),\n remaining: Math.max(0, total - 5)\n }\n}];" + "jsCode": "const data = $input.first().json;\nconst docs = data.results || [];\nconst total = data.count || 0;\n\n// Oldest 5 for the reminder body\nconst oldest = docs.slice(0, 5).map(d => {\n const date = d.created ? d.created.substring(0, 10) : 'unknown date';\n return ` • ${d.title || 'Untitled'} (added ${date})`;\n});\n\nreturn [{\n json: {\n total,\n hasItems: total > 0,\n oldestList: oldest.join('\\n'),\n remaining: Math.max(0, total - 5)\n }\n}];" }, - "id": "8fbe6be0-1ed3-4126-8f21-c3f4efb161c6", + "id": "97e50c04-0e2a-42cd-ad14-6adc48fd6c19", "name": "Parse Response", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -108,7 +112,7 @@ "combinator": "and" } }, - "id": "e24344a1-e857-462c-b96a-842169e83a6d", + "id": "2d7aee2a-bd53-44fe-8663-041a04d1ac28", "name": "Has Inbox Items?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, @@ -119,9 +123,9 @@ }, { "parameters": { - "jsCode": "const d = $input.first().json;\nconst extra = d.remaining > 0 ? `\\n \u2026 and ${d.remaining} more` : '';\nconst body = `${d.total} untagged document${d.total !== 1 ? 's' : ''} in Paperless inbox:\\n${d.oldestList}${extra}\\n\\nhttps://paperless.paccoco.com`;\nreturn [{ json: { title: '\ud83d\udce5 Paperless Inbox Needs Attention', message: body, priority: 5 } }];" + "jsCode": "const d = $input.first().json;\nconst extra = d.remaining > 0 ? `\\n … and ${d.remaining} more` : '';\nconst body = `${d.total} untagged document${d.total !== 1 ? 's' : ''} in Paperless inbox:\\n${d.oldestList}${extra}\\n\\nhttps://paperless.paccoco.com`;\nreturn [{ json: { title: '📥 Paperless Inbox Needs Attention', message: body, priority: 5 } }];" }, - "id": "897af9c9-4429-445f-8160-a03a14a318a3", + "id": "ca64d429-5340-4bff-aaf1-80ed56f31a18", "name": "Format Reminder", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -132,9 +136,9 @@ }, { "parameters": { - "jsCode": "return [{ json: { title: '\u2705 Paperless Inbox Clear', message: 'No untagged documents \u2014 inbox is clean.', priority: 1 } }];" + "jsCode": "return [{ json: { title: '✅ Paperless Inbox Clear', message: 'No untagged documents — inbox is clean.', priority: 1 } }];" }, - "id": "bd8fe88e-7876-45dd-8344-983674fba79d", + "id": "ce983e8c-5e6b-4265-a3ce-54601d5613cf", "name": "Format Clear", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -154,7 +158,7 @@ "contentType": "text/plain" } }, - "id": "74860e7f-42e3-448f-9e46-5cfb6f12cdc2", + "id": "5ad3b20b-1a87-47c6-9b1f-3b6bbee0f942", "name": "Push to Gotify (Reminder)", "type": "n8n-nodes-base.gotify", "typeVersion": 1, @@ -164,7 +168,7 @@ ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", + "id": "tf7TwAsI8E3d0X0L", "name": "Reminders" } } @@ -180,7 +184,7 @@ "contentType": "text/plain" } }, - "id": "b1dbd39b-87c3-43ec-9cec-f63eabbd0fa5", + "id": "1c6ed90d-6350-4259-b9dd-63f978185b4f", "name": "Push to Gotify (Clear)", "type": "n8n-nodes-base.gotify", "typeVersion": 1, @@ -190,7 +194,7 @@ ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", + "id": "tf7TwAsI8E3d0X0L", "name": "Reminders" } } @@ -275,18 +279,353 @@ "executionOrder": "v1", "binaryMode": "separate" }, - "staticData": null, - "meta": null, + "staticData": { + "node:Every Monday 9 AM": { + "recurrenceRules": [] + } + }, + "meta": { + "templateCredsSetupCompleted": true + }, "pinData": {}, + "versionId": "b2988080-f935-41d6-9dd9-630d9a3ac8cf", + "activeVersionId": "b2988080-f935-41d6-9dd9-630d9a3ac8cf", + "versionCounter": 28, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-10T02:20:42.354Z", + "createdAt": "2026-05-10T02:20:42.354Z", + "role": "workflow:owner", + "workflowId": "IL9hNpWdzJfGar9V", + "projectId": "dxCRnBdX5uJizCGa", + "project": { + "updatedAt": "2026-05-06T01:10:37.484Z", + "createdAt": "2026-05-06T01:08:07.721Z", + "id": "dxCRnBdX5uJizCGa", + "name": "Wilfred Fizzlepoof ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + } + ], "tags": [ { + "updatedAt": "2026-05-09T18:01:37.325Z", + "createdAt": "2026-05-09T18:01:37.325Z", + "id": "GkSPhkZk6jOi8ERF", "name": "reminders" }, { + "updatedAt": "2026-05-06T22:39:46.476Z", + "createdAt": "2026-05-06T22:39:46.476Z", + "id": "NxHjuO4MMd5kdPR7", "name": "paperless" }, { + "updatedAt": "2026-05-09T18:01:37.295Z", + "createdAt": "2026-05-09T18:01:37.295Z", + "id": "PYzt74cSdu6cC6P1", "name": "scheduled" } - ] -} \ No newline at end of file + ], + "activeVersion": { + "updatedAt": "2026-05-18T17:37:47.375Z", + "createdAt": "2026-05-18T17:37:47.375Z", + "versionId": "b2988080-f935-41d6-9dd9-630d9a3ac8cf", + "workflowId": "IL9hNpWdzJfGar9V", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "weeks", + "weeksInterval": 1, + "triggerAtDay": [ + 1 + ], + "triggerAtHour": 9, + "triggerAtMinute": 0 + } + ] + } + }, + "id": "2a6f25ee-9a71-4024-9bda-881c0f9c65e2", + "name": "Every Monday 9 AM", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "https://paperless.paccoco.com/api/documents/", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}" + } + ] + }, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "is_tagged", + "value": "0" + }, + { + "name": "page_size", + "value": "25" + }, + { + "name": "ordering", + "value": "created" + }, + { + "name": "fields", + "value": "id,title,created,document_type,tags" + } + ] + }, + "options": {} + }, + "id": "542f642c-8714-4d70-b09a-142819cd4f90", + "name": "Fetch Inbox Docs", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 0 + ] + }, + { + "parameters": { + "jsCode": "const data = $input.first().json;\nconst docs = data.results || [];\nconst total = data.count || 0;\n\n// Oldest 5 for the reminder body\nconst oldest = docs.slice(0, 5).map(d => {\n const date = d.created ? d.created.substring(0, 10) : 'unknown date';\n return ` • ${d.title || 'Untitled'} (added ${date})`;\n});\n\nreturn [{\n json: {\n total,\n hasItems: total > 0,\n oldestList: oldest.join('\\n'),\n remaining: Math.max(0, total - 5)\n }\n}];" + }, + "id": "97e50c04-0e2a-42cd-ad14-6adc48fd6c19", + "name": "Parse Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 448, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "efdd4f81-1976-4d26-a565-d110bfeba1df", + "leftValue": "={{ $json.hasItems }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equal" + } + } + ], + "combinator": "and" + } + }, + "id": "2d7aee2a-bd53-44fe-8663-041a04d1ac28", + "name": "Has Inbox Items?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 672, + 0 + ] + }, + { + "parameters": { + "jsCode": "const d = $input.first().json;\nconst extra = d.remaining > 0 ? `\\n … and ${d.remaining} more` : '';\nconst body = `${d.total} untagged document${d.total !== 1 ? 's' : ''} in Paperless inbox:\\n${d.oldestList}${extra}\\n\\nhttps://paperless.paccoco.com`;\nreturn [{ json: { title: '📥 Paperless Inbox Needs Attention', message: body, priority: 5 } }];" + }, + "id": "ca64d429-5340-4bff-aaf1-80ed56f31a18", + "name": "Format Reminder", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 896, + -120 + ] + }, + { + "parameters": { + "jsCode": "return [{ json: { title: '✅ Paperless Inbox Clear', message: 'No untagged documents — inbox is clean.', priority: 1 } }];" + }, + "id": "ce983e8c-5e6b-4265-a3ce-54601d5613cf", + "name": "Format Clear", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 896, + 120 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "5ad3b20b-1a87-47c6-9b1f-3b6bbee0f942", + "name": "Push to Gotify (Reminder)", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 1120, + -120 + ], + "credentials": { + "gotifyApi": { + "id": "tf7TwAsI8E3d0X0L", + "name": "Reminders" + } + } + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "1c6ed90d-6350-4259-b9dd-63f978185b4f", + "name": "Push to Gotify (Clear)", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 1120, + 120 + ], + "credentials": { + "gotifyApi": { + "id": "tf7TwAsI8E3d0X0L", + "name": "Reminders" + } + } + } + ], + "connections": { + "Every Monday 9 AM": { + "main": [ + [ + { + "node": "Fetch Inbox Docs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Inbox Docs": { + "main": [ + [ + { + "node": "Parse Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Response": { + "main": [ + [ + { + "node": "Has Inbox Items?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has Inbox Items?": { + "main": [ + [ + { + "node": "Format Reminder", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Clear", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Reminder": { + "main": [ + [ + { + "node": "Push to Gotify (Reminder)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Clear": { + "main": [ + [ + { + "node": "Push to Gotify (Clear)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-18T17:37:47.410Z", + "id": 315, + "workflowId": "IL9hNpWdzJfGar9V", + "versionId": "b2988080-f935-41d6-9dd9-630d9a3ac8cf", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-18T17:37:47.438Z", + "id": 316, + "workflowId": "IL9hNpWdzJfGar9V", + "versionId": "b2988080-f935-41d6-9dd9-630d9a3ac8cf", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] + } +} diff --git a/n8n-workflows/15-n8n-health-monitor.json b/n8n-workflows/15-n8n-health-monitor.json index 52478e8..f6eab3a 100644 --- a/n8n-workflows/15-n8n-health-monitor.json +++ b/n8n-workflows/15-n8n-health-monitor.json @@ -1,5 +1,9 @@ { + "updatedAt": "2026-05-22T21:51:06.493Z", + "createdAt": "2026-05-10T02:25:40.618Z", + "id": "BLJVcnMRNMdswJQN", "name": "n8n Self-Health Monitor", + "description": null, "active": true, "isArchived": false, "nodes": [ @@ -14,7 +18,7 @@ ] } }, - "id": "dabcb0ae-d513-410e-b779-4570df5c4a35", + "id": "53bd1074-cb61-4c5e-80ec-625ef9310ea2", "name": "Every 15 Minutes", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, @@ -55,7 +59,7 @@ }, "options": {} }, - "id": "806d75e9-b95a-499a-b997-a7f7dc046775", + "id": "ce33dd4c-6a76-4a65-9f61-55e2d2b64148", "name": "Fetch Failed Executions", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, @@ -68,7 +72,7 @@ "parameters": { "jsCode": "const data = $input.first().json;\nconst executions = data.data || [];\nconst oneHourAgo = Date.now() - (60 * 60 * 1000);\n\n// Filter to failures in the past hour\nconst recent = executions.filter(e => {\n const ts = new Date(e.startedAt || e.stoppedAt || 0).getTime();\n return ts >= oneHourAgo;\n});\n\n// Group by workflow name + error message for dedup key\nconst failures = recent.map(e => ({\n executionId: e.id,\n workflowId: e.workflowId,\n workflowName: e.workflowData?.name || `Workflow ${e.workflowId}`,\n startedAt: e.startedAt,\n stoppedAt: e.stoppedAt,\n errorMessage: e.data?.resultData?.error?.message || 'Unknown error'\n}));\n\n// Deduplicate by workflowName (report once per workflow per check)\nconst seen = new Set();\nconst unique = failures.filter(f => {\n if (seen.has(f.workflowName)) return false;\n seen.add(f.workflowName);\n return true;\n});\n\nreturn [{ json: { hasFailures: unique.length > 0, count: unique.length, failures: unique } }];" }, - "id": "f5d47910-5047-4c4f-aa84-a6907aa38551", + "id": "83b9a030-b10c-4a59-a8d0-a632b4cfe5d2", "name": "Filter Recent Failures", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -99,7 +103,7 @@ "combinator": "and" } }, - "id": "7c298433-ab89-435e-8955-73da6b986f46", + "id": "fea1e46a-e3af-4d60-9f21-a79593fdef71", "name": "Has Failures?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, @@ -110,9 +114,9 @@ }, { "parameters": { - "jsCode": "const d = $input.first().json;\n// Fingerprint = sorted workflow names joined \u2014 stable across retriggers\nconst fingerprint = (d.failures || [])\n .map(f => f.workflowName)\n .sort()\n .join('|') || 'unknown';\nreturn [{ json: { ...d, fingerprint } }];" + "jsCode": "const d = $input.first().json;\n// Fingerprint = sorted workflow names joined — stable across retriggers\nconst fingerprint = (d.failures || [])\n .map(f => f.workflowName)\n .sort()\n .join('|') || 'unknown';\nreturn [{ json: { ...d, fingerprint } }];" }, - "id": "d3609d45-ba9f-41d3-a9bd-8c558a719f10", + "id": "cf9ad5c7-163c-4f06-8300-48499e70072c", "name": "Build Fingerprint", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -127,7 +131,7 @@ "query": "SELECT fingerprint, last_seen FROM n8n_health_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '1 hour' LIMIT 1;", "options": {} }, - "id": "15a3dd71-bc5b-4396-9534-63518e097438", + "id": "faef0ff7-e592-463d-9f98-460591038799", "name": "Lookup Dedup", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, @@ -147,7 +151,7 @@ "parameters": { "jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadyNotified = rows.length > 0;\nreturn [{ json: { ...alertData, alreadyNotified, shouldNotify: !alreadyNotified } }];" }, - "id": "a92590e3-7044-4ba3-9b39-f6a9457c5fbe", + "id": "1fdb0262-7d45-4f30-8dd4-2445ab569df1", "name": "Check Already Notified", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -178,7 +182,7 @@ "combinator": "and" } }, - "id": "39ac0eaf-73ce-4fe1-9c4b-d09ac920291d", + "id": "258f7606-5252-4e78-8398-89970d5cea74", "name": "Should Notify?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, @@ -193,7 +197,7 @@ "query": "INSERT INTO n8n_health_dedup (fingerprint, last_seen, workflow_names) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ $json.failures.map(f => f.workflowName).join(\", \") }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW(), workflow_names = EXCLUDED.workflow_names;", "options": {} }, - "id": "0c3be155-0c3a-4354-b863-73a3f5bbf598", + "id": "dc7c3c90-1cb0-4b3f-b462-58401411e976", "name": "Upsert Dedup Record", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, @@ -210,9 +214,9 @@ }, { "parameters": { - "jsCode": "const d = $('Build Fingerprint').first().json;\nconst lines = (d.failures || []).map(f =>\n ` \u2022 ${f.workflowName}\\n ${f.errorMessage.substring(0, 120)}`\n);\nconst body = `${d.count} workflow${d.count !== 1 ? 's' : ''} failed in the past hour:\\n\\n${lines.join('\\n')}\\n\\nhttps://n8n.paccoco.com`;\nreturn [{ json: { title: `\u26a0\ufe0f n8n: ${d.count} Workflow Failure${d.count !== 1 ? 's' : ''}`, message: body, priority: 7 } }];" + "jsCode": "const d = $('Build Fingerprint').first().json;\nconst lines = (d.failures || []).map(f =>\n ` • ${f.workflowName}\\n ${f.errorMessage.substring(0, 120)}`\n);\nconst body = `${d.count} workflow${d.count !== 1 ? 's' : ''} failed in the past hour:\\n\\n${lines.join('\\n')}\\n\\nhttps://n8n.paccoco.com`;\nreturn [{ json: { title: `⚠️ n8n: ${d.count} Workflow Failure${d.count !== 1 ? 's' : ''}`, message: body, priority: 7 } }];" }, - "id": "1c6b42bf-a38e-47b6-82ac-50c43ee9e583", + "id": "7d040998-67c2-4888-937c-6d82e41056d1", "name": "Format Notification", "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -232,7 +236,7 @@ "contentType": "text/plain" } }, - "id": "09435c2f-9044-432a-8ca8-dd3ce3262c76", + "id": "2569776a-c0a8-48cb-8615-af95daf53292", "name": "Push to Gotify", "type": "n8n-nodes-base.gotify", "typeVersion": 1, @@ -242,7 +246,7 @@ ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", + "id": "mMegDxfmMUfbOrnF", "name": "n8n Health" } } @@ -366,18 +370,444 @@ "executionOrder": "v1", "binaryMode": "separate" }, - "staticData": null, - "meta": null, + "staticData": { + "node:Every 15 Minutes": { + "recurrenceRules": [] + } + }, + "meta": { + "templateCredsSetupCompleted": true + }, "pinData": {}, + "versionId": "8cf46654-a773-4921-8745-3169eb7709f3", + "activeVersionId": "8cf46654-a773-4921-8745-3169eb7709f3", + "versionCounter": 31, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-10T02:25:40.618Z", + "createdAt": "2026-05-10T02:25:40.618Z", + "role": "workflow:owner", + "workflowId": "BLJVcnMRNMdswJQN", + "projectId": "dxCRnBdX5uJizCGa", + "project": { + "updatedAt": "2026-05-06T01:10:37.484Z", + "createdAt": "2026-05-06T01:08:07.721Z", + "id": "dxCRnBdX5uJizCGa", + "name": "Wilfred Fizzlepoof ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + } + ], "tags": [ { + "updatedAt": "2026-05-06T22:38:51.660Z", + "createdAt": "2026-05-06T22:38:51.660Z", + "id": "0ETpkL5jJ5wwgt8k", "name": "monitoring" }, { + "updatedAt": "2026-05-09T18:01:37.295Z", + "createdAt": "2026-05-09T18:01:37.295Z", + "id": "PYzt74cSdu6cC6P1", "name": "scheduled" }, { + "updatedAt": "2026-05-09T18:02:26.263Z", + "createdAt": "2026-05-09T18:02:26.263Z", + "id": "t3Vz8xsWhwxAXMRx", "name": "n8n" } - ] -} \ No newline at end of file + ], + "activeVersion": { + "updatedAt": "2026-05-22T21:51:06.496Z", + "createdAt": "2026-05-22T21:51:06.496Z", + "versionId": "8cf46654-a773-4921-8745-3169eb7709f3", + "workflowId": "BLJVcnMRNMdswJQN", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 15 + } + ] + } + }, + "id": "53bd1074-cb61-4c5e-80ec-625ef9310ea2", + "name": "Every 15 Minutes", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "http://10.5.30.6:5678/api/v1/executions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "X-N8N-API-KEY", + "value": "={{ $env.N8N_API_KEY }}" + } + ] + }, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "status", + "value": "error" + }, + { + "name": "includeData", + "value": "false" + }, + { + "name": "limit", + "value": "50" + } + ] + }, + "options": {} + }, + "id": "ce33dd4c-6a76-4a65-9f61-55e2d2b64148", + "name": "Fetch Failed Executions", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 0 + ] + }, + { + "parameters": { + "jsCode": "const data = $input.first().json;\nconst executions = data.data || [];\nconst oneHourAgo = Date.now() - (60 * 60 * 1000);\n\n// Filter to failures in the past hour\nconst recent = executions.filter(e => {\n const ts = new Date(e.startedAt || e.stoppedAt || 0).getTime();\n return ts >= oneHourAgo;\n});\n\n// Group by workflow name + error message for dedup key\nconst failures = recent.map(e => ({\n executionId: e.id,\n workflowId: e.workflowId,\n workflowName: e.workflowData?.name || `Workflow ${e.workflowId}`,\n startedAt: e.startedAt,\n stoppedAt: e.stoppedAt,\n errorMessage: e.data?.resultData?.error?.message || 'Unknown error'\n}));\n\n// Deduplicate by workflowName (report once per workflow per check)\nconst seen = new Set();\nconst unique = failures.filter(f => {\n if (seen.has(f.workflowName)) return false;\n seen.add(f.workflowName);\n return true;\n});\n\nreturn [{ json: { hasFailures: unique.length > 0, count: unique.length, failures: unique } }];" + }, + "id": "83b9a030-b10c-4a59-a8d0-a632b4cfe5d2", + "name": "Filter Recent Failures", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 448, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "a76d10a7-1313-467e-9648-471b296229a3", + "leftValue": "={{ $json.hasFailures }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equal" + } + } + ], + "combinator": "and" + } + }, + "id": "fea1e46a-e3af-4d60-9f21-a79593fdef71", + "name": "Has Failures?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 672, + 0 + ] + }, + { + "parameters": { + "jsCode": "const d = $input.first().json;\n// Fingerprint = sorted workflow names joined — stable across retriggers\nconst fingerprint = (d.failures || [])\n .map(f => f.workflowName)\n .sort()\n .join('|') || 'unknown';\nreturn [{ json: { ...d, fingerprint } }];" + }, + "id": "cf9ad5c7-163c-4f06-8300-48499e70072c", + "name": "Build Fingerprint", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 896, + -120 + ] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT fingerprint, last_seen FROM n8n_health_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '1 hour' LIMIT 1;", + "options": {} + }, + "id": "faef0ff7-e592-463d-9f98-460591038799", + "name": "Lookup Dedup", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [ + 1120, + -120 + ], + "credentials": { + "postgres": { + "id": "n9svoXemqSZoNNUB", + "name": "Postgres account" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadyNotified = rows.length > 0;\nreturn [{ json: { ...alertData, alreadyNotified, shouldNotify: !alreadyNotified } }];" + }, + "id": "1fdb0262-7d45-4f30-8dd4-2445ab569df1", + "name": "Check Already Notified", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1344, + -120 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "b1e77af5-6875-47dd-97af-8e13958a7217", + "leftValue": "={{ $json.shouldNotify }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equal" + } + } + ], + "combinator": "and" + } + }, + "id": "258f7606-5252-4e78-8398-89970d5cea74", + "name": "Should Notify?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 1568, + -120 + ] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO n8n_health_dedup (fingerprint, last_seen, workflow_names) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ $json.failures.map(f => f.workflowName).join(\", \") }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW(), workflow_names = EXCLUDED.workflow_names;", + "options": {} + }, + "id": "dc7c3c90-1cb0-4b3f-b462-58401411e976", + "name": "Upsert Dedup Record", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [ + 1792, + -120 + ], + "credentials": { + "postgres": { + "id": "n9svoXemqSZoNNUB", + "name": "Postgres account" + } + } + }, + { + "parameters": { + "jsCode": "const d = $('Build Fingerprint').first().json;\nconst lines = (d.failures || []).map(f =>\n ` • ${f.workflowName}\\n ${f.errorMessage.substring(0, 120)}`\n);\nconst body = `${d.count} workflow${d.count !== 1 ? 's' : ''} failed in the past hour:\\n\\n${lines.join('\\n')}\\n\\nhttps://n8n.paccoco.com`;\nreturn [{ json: { title: `⚠️ n8n: ${d.count} Workflow Failure${d.count !== 1 ? 's' : ''}`, message: body, priority: 7 } }];" + }, + "id": "7d040998-67c2-4888-937c-6d82e41056d1", + "name": "Format Notification", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2016, + -120 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "2569776a-c0a8-48cb-8615-af95daf53292", + "name": "Push to Gotify", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 2240, + -120 + ], + "credentials": { + "gotifyApi": { + "id": "mMegDxfmMUfbOrnF", + "name": "n8n Health" + } + } + } + ], + "connections": { + "Every 15 Minutes": { + "main": [ + [ + { + "node": "Fetch Failed Executions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Failed Executions": { + "main": [ + [ + { + "node": "Filter Recent Failures", + "type": "main", + "index": 0 + } + ] + ] + }, + "Filter Recent Failures": { + "main": [ + [ + { + "node": "Has Failures?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has Failures?": { + "main": [ + [ + { + "node": "Build Fingerprint", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "Build Fingerprint": { + "main": [ + [ + { + "node": "Lookup Dedup", + "type": "main", + "index": 0 + } + ] + ] + }, + "Lookup Dedup": { + "main": [ + [ + { + "node": "Check Already Notified", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Already Notified": { + "main": [ + [ + { + "node": "Should Notify?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Should Notify?": { + "main": [ + [ + { + "node": "Upsert Dedup Record", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "Upsert Dedup Record": { + "main": [ + [ + { + "node": "Format Notification", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Notification": { + "main": [ + [ + { + "node": "Push to Gotify", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-22T21:51:07.244Z", + "id": 317, + "workflowId": "BLJVcnMRNMdswJQN", + "versionId": "8cf46654-a773-4921-8745-3169eb7709f3", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-22T21:51:07.263Z", + "id": 318, + "workflowId": "BLJVcnMRNMdswJQN", + "versionId": "8cf46654-a773-4921-8745-3169eb7709f3", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] + } +} diff --git a/n8n-workflows/16-nfs-watchdog-v1.2.json b/n8n-workflows/16-nfs-watchdog-v1.2.json index 5bc5ed2..e1f19f2 100644 --- a/n8n-workflows/16-nfs-watchdog-v1.2.json +++ b/n8n-workflows/16-nfs-watchdog-v1.2.json @@ -1,5 +1,9 @@ { + "updatedAt": "2026-05-22T21:55:32.962Z", + "createdAt": "2026-05-09T20:46:23.974Z", + "id": "vdv7fILmnhQd9qL2", "name": "NFS Mount Watchdog + Auto-Heal v1.2", + "description": null, "active": true, "isArchived": false, "nodes": [ @@ -8,31 +12,36 @@ "rule": { "interval": [ { - "field": "minutes", - "minutesInterval": 5 + "field": "minutes" } ] } }, - "id": "8c466a67-a0fa-42dc-8ed8-0918834fc3a7", + "id": "d902559a-9099-4437-acf8-251ddc822dd8", "name": "Every 5 Minutes", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, - "position": [0, 0] + "position": [ + -2016, + 240 + ] }, { "parameters": { "command": "mountpoint -q /mnt/unraid/data && echo DATA_OK || echo DATA_MISSING; mountpoint -q /mnt/unraid/immich && echo IMMICH_OK || echo IMMICH_MISSING" }, - "id": "f5b2fee9-d67a-4da7-80dd-f72e6078aed9", + "id": "81396ba5-a882-45c2-82f9-7838ce420d7f", "name": "Probe Mounts", "type": "n8n-nodes-base.ssh", "typeVersion": 1, - "position": [224, 0], + "position": [ + -1792, + 240 + ], "credentials": { - "sshPrivateKey": { - "id": "SETUP_REQUIRED", - "name": "PD SSH" + "sshPassword": { + "id": "0irY4e4WHwyMXKhA", + "name": "SSH Password account" } }, "continueOnFail": true @@ -41,11 +50,14 @@ "parameters": { "jsCode": "const out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst dataOk = out.includes('DATA_OK');\nconst immichOk = out.includes('IMMICH_OK');\nconst missing = [];\nif (!dataOk) missing.push({ mount: '/mnt/unraid/data', label: 'data',\n containers: ['ix-plex-plex-1', 'ix-audiobookshelf-audiobookshelf-1'] });\nif (!immichOk) missing.push({ mount: '/mnt/unraid/immich', label: 'immich',\n containers: ['ix-immich-immich-server-1', 'ix-immich-immich-machine-learning-1'] });\nreturn [{ json: { dataOk, immichOk, anyMissing: missing.length > 0, anyMissingStr: missing.length > 0 ? 'yes' : 'no', missing } }];\n" }, - "id": "25bb3239-49cb-4570-b706-4022b9aa751c", + "id": "cbc19d0e-ca0c-41a8-8db6-a5f9754d68bf", "name": "Parse Mount Status", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [448, 0] + "position": [ + -1568, + 240 + ] }, { "parameters": { @@ -53,7 +65,8 @@ "options": { "caseSensitive": true, "leftValue": "", - "typeValidation": "strict" + "typeValidation": "strict", + "version": 1 }, "conditions": [ { @@ -67,57 +80,66 @@ } ], "combinator": "and" - } + }, + "options": {} }, - "id": "68b74832-85a7-4193-9382-a0c22a666e66", + "id": "6c0f9f30-a2d3-4f85-a17d-99b215178652", "name": "Any Missing?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, - "position": [672, 0] + "position": [ + -1344, + 240 + ] }, { "parameters": { - "command": "sudo /usr/bin/bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh 2>&1" + "command": "sudo /usr/bin/bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh" }, - "id": "b18c7537-0c2f-4122-bfe5-6469d6be6853", + "id": "764c3d6c-f5fb-4ce7-9291-20718ad3b598", "name": "Run Mount Script", "type": "n8n-nodes-base.ssh", "typeVersion": 1, - "position": [896, -120], + "position": [ + -1120, + 128 + ], "credentials": { - "sshPrivateKey": { - "id": "SETUP_REQUIRED", - "name": "PD SSH" + "sshPassword": { + "id": "0irY4e4WHwyMXKhA", + "name": "SSH Password account" } }, "continueOnFail": true }, { - "parameters": { - "resume": "timeInterval", - "unit": "seconds", - "value": 10 - }, - "id": "2e927f83-9ddc-4816-8ee6-76d21ce21a5e", + "parameters": {}, + "id": "1510ac5b-873f-46d8-b86b-071ab0d6ca26", "name": "Wait 10s", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, - "position": [1120, -120], + "position": [ + -896, + 128 + ], "webhookId": "4e89e8d0-7f08-4378-a2b7-63756665b039" }, { "parameters": { "command": "mountpoint -q /mnt/unraid/data && echo DATA_OK || echo DATA_MISSING; mountpoint -q /mnt/unraid/immich && echo IMMICH_OK || echo IMMICH_MISSING" }, - "id": "d15c0c90-e94f-404a-a787-75f1b1b5befa", + "id": "4c4bbe24-d496-435e-ba81-02a91f1fb0b7", "name": "Re-probe Mounts", "type": "n8n-nodes-base.ssh", "typeVersion": 1, - "position": [1344, -120], + "position": [ + -672, + 128 + ], "credentials": { - "sshPrivateKey": { - "id": "SETUP_REQUIRED", - "name": "PD SSH" + "sshPassword": { + "id": "0irY4e4WHwyMXKhA", + "name": "SSH Password account" } }, "continueOnFail": true @@ -126,11 +148,14 @@ "parameters": { "jsCode": "const out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst dataOk = out.includes('DATA_OK');\nconst immichOk = out.includes('IMMICH_OK');\n\nconst originalMissing = $('Parse Mount Status').first().json.missing || [];\n\nconst healed = originalMissing.filter(m =>\n (m.label === 'data' && dataOk) ||\n (m.label === 'immich' && immichOk)\n);\nconst stillDown = originalMissing.filter(m =>\n (m.label === 'data' && !dataOk) ||\n (m.label === 'immich' && !immichOk)\n);\n\nconst containersToRestart = [...new Set(healed.flatMap(m => m.containers))];\nconst restartCmd = containersToRestart.length > 0\n ? `sudo docker restart ${containersToRestart.join(' ')}`\n : 'echo NO_RESTART_NEEDED';\n\nreturn [{ json: {\n allHealed: stillDown.length === 0,\n allHealedStr: stillDown.length === 0 ? 'yes' : 'no',\n healed,\n stillDown,\n containersToRestart,\n restartCmd,\n originalMissing\n} }];\n" }, - "id": "48de38c2-df08-406f-ad61-809ca6e306a1", + "id": "66a1d989-f0a2-4b5a-b7e0-1cb767d010e6", "name": "Parse Heal Result", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1568, -120] + "position": [ + -448, + 128 + ] }, { "parameters": { @@ -138,7 +163,8 @@ "options": { "caseSensitive": true, "leftValue": "", - "typeValidation": "strict" + "typeValidation": "strict", + "version": 1 }, "conditions": [ { @@ -152,27 +178,34 @@ } ], "combinator": "and" - } + }, + "options": {} }, - "id": "29a1b544-97b3-433f-9821-2bf5a8af95d2", + "id": "49d9ff7a-9227-405d-9887-a7537ce2b844", "name": "Healed?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, - "position": [1792, -120] + "position": [ + -224, + 128 + ] }, { "parameters": { "command": "={{ $json.restartCmd }}" }, - "id": "8d791509-9e18-44c1-bf58-ed3027927710", + "id": "69951482-d5e3-400c-9d3a-3790f671c225", "name": "Restart Containers", "type": "n8n-nodes-base.ssh", "typeVersion": 1, - "position": [2016, -240], + "position": [ + 0, + 0 + ], "credentials": { - "sshPrivateKey": { - "id": "SETUP_REQUIRED", - "name": "PD SSH" + "sshPassword": { + "id": "0irY4e4WHwyMXKhA", + "name": "SSH Password account" } }, "continueOnFail": true @@ -181,11 +214,14 @@ "parameters": { "jsCode": "const d = $('Parse Heal Result').first().json;\nconst mountList = d.healed.map(m => ` • ${m.mount}`).join('\\n');\nconst containers = d.containersToRestart.join(', ') || 'none';\nconst body = `NFS mounts were missing and have been automatically remounted.\\n\\nRemounted:\\n${mountList}\\n\\nContainers restarted: ${containers}`;\nreturn [{ json: { title: '🔗 NFS Auto-Healed', message: body, priority: 5 } }];\n" }, - "id": "75c8976f-d622-43cd-a332-72c32f0c7c60", + "id": "c109df43-532a-4a49-af80-4897bb0b7c1a", "name": "Format Healed Notify", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2240, -240] + "position": [ + 224, + 0 + ] }, { "parameters": { @@ -198,15 +234,18 @@ "contentType": "text/plain" } }, - "id": "38705cd2-775b-4ef9-b470-4a7709f132e5", + "id": "c8414235-8840-4302-a8e4-6644c3beadb9", "name": "Gotify (Healed)", "type": "n8n-nodes-base.gotify", "typeVersion": 1, - "position": [2464, -240], + "position": [ + 448, + 0 + ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", - "name": "Uptime / Infra" + "id": "K4p3mFrQrjDutIOU", + "name": "Infrastructure" } } }, @@ -214,11 +253,14 @@ "parameters": { "jsCode": "const d = $('Parse Heal Result').first().json;\nconst downList = d.stillDown.map(m => ` • ${m.mount}`).join('\\n');\nconst healedList = d.healed.length > 0\n ? `\\nPartially healed:\\n${d.healed.map(m => ' • ' + m.mount).join('\\n')}`\n : '';\nconst body = `NFS mount script ran but the following paths are still unavailable. Manual intervention required.\\n\\nStill down:\\n${downList}${healedList}\\n\\nCheck Serenity (10.5.30.5) is up and NFS service is running.`;\nreturn [{ json: { title: '🚨 NFS Down — Manual Fix Needed', message: body, priority: 9 } }];\n" }, - "id": "074b984f-7fc2-45bc-b97f-f5db4d8d589b", + "id": "ff65b3f1-ff81-48dd-a1e8-fd8adcef55d1", "name": "Format Escalation", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2016, -20] + "position": [ + 0, + 224 + ] }, { "parameters": { @@ -231,73 +273,669 @@ "contentType": "text/plain" } }, - "id": "f929dd77-6072-4ac9-9dc4-11246110efed", + "id": "39d131f2-f933-4253-92ce-9e312f881b16", "name": "Gotify (Escalation)", "type": "n8n-nodes-base.gotify", "typeVersion": 1, - "position": [2240, -20], + "position": [ + 224, + 224 + ], "credentials": { "gotifyApi": { - "id": "SETUP_REQUIRED", - "name": "Uptime / Infra" + "id": "K4p3mFrQrjDutIOU", + "name": "Infrastructure" } } } ], "connections": { "Every 5 Minutes": { - "main": [[{ "node": "Probe Mounts", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Probe Mounts", + "type": "main", + "index": 0 + } + ] + ] }, "Probe Mounts": { - "main": [[{ "node": "Parse Mount Status", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Parse Mount Status", + "type": "main", + "index": 0 + } + ] + ] }, "Parse Mount Status": { - "main": [[{ "node": "Any Missing?", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Any Missing?", + "type": "main", + "index": 0 + } + ] + ] }, "Any Missing?": { "main": [ - [{ "node": "Run Mount Script", "type": "main", "index": 0 }], - [] + [ + { + "node": "Run Mount Script", + "type": "main", + "index": 0 + } + ] ] }, "Run Mount Script": { - "main": [[{ "node": "Wait 10s", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Wait 10s", + "type": "main", + "index": 0 + } + ] + ] }, "Wait 10s": { - "main": [[{ "node": "Re-probe Mounts", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Re-probe Mounts", + "type": "main", + "index": 0 + } + ] + ] }, "Re-probe Mounts": { - "main": [[{ "node": "Parse Heal Result", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Parse Heal Result", + "type": "main", + "index": 0 + } + ] + ] }, "Parse Heal Result": { - "main": [[{ "node": "Healed?", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Healed?", + "type": "main", + "index": 0 + } + ] + ] }, "Healed?": { "main": [ - [{ "node": "Restart Containers", "type": "main", "index": 0 }], - [{ "node": "Format Escalation", "type": "main", "index": 0 }] + [ + { + "node": "Restart Containers", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Escalation", + "type": "main", + "index": 0 + } + ] ] }, "Restart Containers": { - "main": [[{ "node": "Format Healed Notify", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Format Healed Notify", + "type": "main", + "index": 0 + } + ] + ] }, "Format Healed Notify": { - "main": [[{ "node": "Gotify (Healed)", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Gotify (Healed)", + "type": "main", + "index": 0 + } + ] + ] }, "Format Escalation": { - "main": [[{ "node": "Gotify (Escalation)", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Gotify (Escalation)", + "type": "main", + "index": 0 + } + ] + ] } }, "settings": { "executionOrder": "v1", "binaryMode": "separate" }, - "staticData": null, - "meta": null, + "staticData": { + "node:Every 5 Minutes": { + "recurrenceRules": [] + } + }, + "meta": { + "templateCredsSetupCompleted": true + }, "pinData": {}, + "versionId": "68cca20d-5a60-446f-863f-abef2a12639f", + "activeVersionId": "68cca20d-5a60-446f-863f-abef2a12639f", + "versionCounter": 38, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-09T20:46:23.974Z", + "createdAt": "2026-05-09T20:46:23.974Z", + "role": "workflow:owner", + "workflowId": "vdv7fILmnhQd9qL2", + "projectId": "dxCRnBdX5uJizCGa", + "project": { + "updatedAt": "2026-05-06T01:10:37.484Z", + "createdAt": "2026-05-06T01:08:07.721Z", + "id": "dxCRnBdX5uJizCGa", + "name": "Wilfred Fizzlepoof ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + } + ], "tags": [ - { "name": "monitoring" }, - { "name": "infrastructure" }, - { "name": "scheduled" } - ] + { + "updatedAt": "2026-05-06T22:38:51.660Z", + "createdAt": "2026-05-06T22:38:51.660Z", + "id": "0ETpkL5jJ5wwgt8k", + "name": "monitoring" + }, + { + "updatedAt": "2026-05-09T18:01:37.295Z", + "createdAt": "2026-05-09T18:01:37.295Z", + "id": "PYzt74cSdu6cC6P1", + "name": "scheduled" + }, + { + "updatedAt": "2026-05-09T18:06:13.298Z", + "createdAt": "2026-05-09T18:06:13.298Z", + "id": "urlD2YH1LZ32LY2d", + "name": "infrastructure" + } + ], + "activeVersion": { + "updatedAt": "2026-05-22T21:55:32.963Z", + "createdAt": "2026-05-22T21:55:32.963Z", + "versionId": "68cca20d-5a60-446f-863f-abef2a12639f", + "workflowId": "vdv7fILmnhQd9qL2", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes" + } + ] + } + }, + "id": "d902559a-9099-4437-acf8-251ddc822dd8", + "name": "Every 5 Minutes", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + -2016, + 240 + ] + }, + { + "parameters": { + "command": "mountpoint -q /mnt/unraid/data && echo DATA_OK || echo DATA_MISSING; mountpoint -q /mnt/unraid/immich && echo IMMICH_OK || echo IMMICH_MISSING" + }, + "id": "81396ba5-a882-45c2-82f9-7838ce420d7f", + "name": "Probe Mounts", + "type": "n8n-nodes-base.ssh", + "typeVersion": 1, + "position": [ + -1792, + 240 + ], + "credentials": { + "sshPassword": { + "id": "0irY4e4WHwyMXKhA", + "name": "SSH Password account" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst dataOk = out.includes('DATA_OK');\nconst immichOk = out.includes('IMMICH_OK');\nconst missing = [];\nif (!dataOk) missing.push({ mount: '/mnt/unraid/data', label: 'data',\n containers: ['ix-plex-plex-1', 'ix-audiobookshelf-audiobookshelf-1'] });\nif (!immichOk) missing.push({ mount: '/mnt/unraid/immich', label: 'immich',\n containers: ['ix-immich-immich-server-1', 'ix-immich-immich-machine-learning-1'] });\nreturn [{ json: { dataOk, immichOk, anyMissing: missing.length > 0, anyMissingStr: missing.length > 0 ? 'yes' : 'no', missing } }];\n" + }, + "id": "cbc19d0e-ca0c-41a8-8db6-a5f9754d68bf", + "name": "Parse Mount Status", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1568, + 240 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 1 + }, + "conditions": [ + { + "id": "70384f84-9d15-4e45-b295-67202a39a227", + "leftValue": "={{ $json.anyMissingStr }}", + "rightValue": "yes", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "6c0f9f30-a2d3-4f85-a17d-99b215178652", + "name": "Any Missing?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + -1344, + 240 + ] + }, + { + "parameters": { + "command": "sudo /usr/bin/bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh" + }, + "id": "764c3d6c-f5fb-4ce7-9291-20718ad3b598", + "name": "Run Mount Script", + "type": "n8n-nodes-base.ssh", + "typeVersion": 1, + "position": [ + -1120, + 128 + ], + "credentials": { + "sshPassword": { + "id": "0irY4e4WHwyMXKhA", + "name": "SSH Password account" + } + }, + "continueOnFail": true + }, + { + "parameters": {}, + "id": "1510ac5b-873f-46d8-b86b-071ab0d6ca26", + "name": "Wait 10s", + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [ + -896, + 128 + ], + "webhookId": "4e89e8d0-7f08-4378-a2b7-63756665b039" + }, + { + "parameters": { + "command": "mountpoint -q /mnt/unraid/data && echo DATA_OK || echo DATA_MISSING; mountpoint -q /mnt/unraid/immich && echo IMMICH_OK || echo IMMICH_MISSING" + }, + "id": "4c4bbe24-d496-435e-ba81-02a91f1fb0b7", + "name": "Re-probe Mounts", + "type": "n8n-nodes-base.ssh", + "typeVersion": 1, + "position": [ + -672, + 128 + ], + "credentials": { + "sshPassword": { + "id": "0irY4e4WHwyMXKhA", + "name": "SSH Password account" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const out = ($input.first().json.stdout || '') + ($input.first().json.stderr || '');\nconst dataOk = out.includes('DATA_OK');\nconst immichOk = out.includes('IMMICH_OK');\n\nconst originalMissing = $('Parse Mount Status').first().json.missing || [];\n\nconst healed = originalMissing.filter(m =>\n (m.label === 'data' && dataOk) ||\n (m.label === 'immich' && immichOk)\n);\nconst stillDown = originalMissing.filter(m =>\n (m.label === 'data' && !dataOk) ||\n (m.label === 'immich' && !immichOk)\n);\n\nconst containersToRestart = [...new Set(healed.flatMap(m => m.containers))];\nconst restartCmd = containersToRestart.length > 0\n ? `sudo docker restart ${containersToRestart.join(' ')}`\n : 'echo NO_RESTART_NEEDED';\n\nreturn [{ json: {\n allHealed: stillDown.length === 0,\n allHealedStr: stillDown.length === 0 ? 'yes' : 'no',\n healed,\n stillDown,\n containersToRestart,\n restartCmd,\n originalMissing\n} }];\n" + }, + "id": "66a1d989-f0a2-4b5a-b7e0-1cb767d010e6", + "name": "Parse Heal Result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -448, + 128 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 1 + }, + "conditions": [ + { + "id": "32b2dfd5-9958-45d4-94fc-410ce4d9543d", + "leftValue": "={{ $json.allHealedStr }}", + "rightValue": "yes", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "49d9ff7a-9227-405d-9887-a7537ce2b844", + "name": "Healed?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + -224, + 128 + ] + }, + { + "parameters": { + "command": "={{ $json.restartCmd }}" + }, + "id": "69951482-d5e3-400c-9d3a-3790f671c225", + "name": "Restart Containers", + "type": "n8n-nodes-base.ssh", + "typeVersion": 1, + "position": [ + 0, + 0 + ], + "credentials": { + "sshPassword": { + "id": "0irY4e4WHwyMXKhA", + "name": "SSH Password account" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const d = $('Parse Heal Result').first().json;\nconst mountList = d.healed.map(m => ` • ${m.mount}`).join('\\n');\nconst containers = d.containersToRestart.join(', ') || 'none';\nconst body = `NFS mounts were missing and have been automatically remounted.\\n\\nRemounted:\\n${mountList}\\n\\nContainers restarted: ${containers}`;\nreturn [{ json: { title: '🔗 NFS Auto-Healed', message: body, priority: 5 } }];\n" + }, + "id": "c109df43-532a-4a49-af80-4897bb0b7c1a", + "name": "Format Healed Notify", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 224, + 0 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "c8414235-8840-4302-a8e4-6644c3beadb9", + "name": "Gotify (Healed)", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 448, + 0 + ], + "credentials": { + "gotifyApi": { + "id": "K4p3mFrQrjDutIOU", + "name": "Infrastructure" + } + } + }, + { + "parameters": { + "jsCode": "const d = $('Parse Heal Result').first().json;\nconst downList = d.stillDown.map(m => ` • ${m.mount}`).join('\\n');\nconst healedList = d.healed.length > 0\n ? `\\nPartially healed:\\n${d.healed.map(m => ' • ' + m.mount).join('\\n')}`\n : '';\nconst body = `NFS mount script ran but the following paths are still unavailable. Manual intervention required.\\n\\nStill down:\\n${downList}${healedList}\\n\\nCheck Serenity (10.5.30.5) is up and NFS service is running.`;\nreturn [{ json: { title: '🚨 NFS Down — Manual Fix Needed', message: body, priority: 9 } }];\n" + }, + "id": "ff65b3f1-ff81-48dd-a1e8-fd8adcef55d1", + "name": "Format Escalation", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 0, + 224 + ] + }, + { + "parameters": { + "message": "={{ $json.message }}", + "additionalFields": { + "priority": "={{ $json.priority }}", + "title": "={{ $json.title }}" + }, + "options": { + "contentType": "text/plain" + } + }, + "id": "39d131f2-f933-4253-92ce-9e312f881b16", + "name": "Gotify (Escalation)", + "type": "n8n-nodes-base.gotify", + "typeVersion": 1, + "position": [ + 224, + 224 + ], + "credentials": { + "gotifyApi": { + "id": "K4p3mFrQrjDutIOU", + "name": "Infrastructure" + } + } + } + ], + "connections": { + "Every 5 Minutes": { + "main": [ + [ + { + "node": "Probe Mounts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Probe Mounts": { + "main": [ + [ + { + "node": "Parse Mount Status", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Mount Status": { + "main": [ + [ + { + "node": "Any Missing?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Any Missing?": { + "main": [ + [ + { + "node": "Run Mount Script", + "type": "main", + "index": 0 + } + ] + ] + }, + "Run Mount Script": { + "main": [ + [ + { + "node": "Wait 10s", + "type": "main", + "index": 0 + } + ] + ] + }, + "Wait 10s": { + "main": [ + [ + { + "node": "Re-probe Mounts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Re-probe Mounts": { + "main": [ + [ + { + "node": "Parse Heal Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Heal Result": { + "main": [ + [ + { + "node": "Healed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Healed?": { + "main": [ + [ + { + "node": "Restart Containers", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Escalation", + "type": "main", + "index": 0 + } + ] + ] + }, + "Restart Containers": { + "main": [ + [ + { + "node": "Format Healed Notify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Healed Notify": { + "main": [ + [ + { + "node": "Gotify (Healed)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Escalation": { + "main": [ + [ + { + "node": "Gotify (Escalation)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-22T21:55:33.007Z", + "id": 327, + "workflowId": "vdv7fILmnhQd9qL2", + "versionId": "68cca20d-5a60-446f-863f-abef2a12639f", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-22T21:55:33.028Z", + "id": 328, + "workflowId": "vdv7fILmnhQd9qL2", + "versionId": "68cca20d-5a60-446f-863f-abef2a12639f", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] + } } diff --git a/n8n-workflows/17-paperless-intake-triage.json b/n8n-workflows/17-paperless-intake-triage.json index 39394b6..591823a 100644 --- a/n8n-workflows/17-paperless-intake-triage.json +++ b/n8n-workflows/17-paperless-intake-triage.json @@ -1,6 +1,10 @@ { - "name": "Paperless Intake Triage \u2192 Doris Review Queue", - "active": false, + "updatedAt": "2026-05-22T21:55:07.597Z", + "createdAt": "2026-05-13T16:06:39.571Z", + "id": "FIhrfDfL1NgakDwl", + "name": "Paperless Intake Triage → Doris Review Queue", + "description": null, + "active": true, "isArchived": false, "nodes": [ { @@ -16,7 +20,8 @@ "position": [ -980, 0 - ] + ], + "webhookId": "706f9c22-fbe4-4f6a-87d7-168a6eb96b97" }, { "parameters": { @@ -346,19 +351,399 @@ "executionOrder": "v1" }, "staticData": null, - "meta": { - "templateCredsSetupCompleted": false - }, - "pinData": {}, - "tags": [ + "meta": null, + "pinData": null, + "versionId": "26b3e6dd-3e8b-4bd0-a38d-f2d09990447f", + "activeVersionId": "26b3e6dd-3e8b-4bd0-a38d-f2d09990447f", + "versionCounter": 36, + "triggerCount": 1, + "shared": [ { - "name": "paperless" - }, - { - "name": "doris" - }, - { - "name": "review-first" + "updatedAt": "2026-05-13T16:06:39.571Z", + "createdAt": "2026-05-13T16:06:39.571Z", + "role": "workflow:owner", + "workflowId": "FIhrfDfL1NgakDwl", + "projectId": "dxCRnBdX5uJizCGa", + "project": { + "updatedAt": "2026-05-06T01:10:37.484Z", + "createdAt": "2026-05-06T01:08:07.721Z", + "id": "dxCRnBdX5uJizCGa", + "name": "Wilfred Fizzlepoof ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } } - ] + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-05-22T21:55:07.599Z", + "createdAt": "2026-05-22T21:55:07.599Z", + "versionId": "26b3e6dd-3e8b-4bd0-a38d-f2d09990447f", + "workflowId": "FIhrfDfL1NgakDwl", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "paperless/intake-triage", + "options": {} + }, + "id": "paperless-webhook", + "name": "Paperless Intake Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + -980, + 0 + ], + "webhookId": "706f9c22-fbe4-4f6a-87d7-168a6eb96b97" + }, + { + "parameters": { + "jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst query = input.query || {};\nconst params = input.params || {};\nconst headers = input.headers || {};\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document,\n query.document_id,\n query.id,\n query.doc_id,\n params.document_id,\n params.id,\n headers['x-paperless-document-id'],\n input.document_id,\n input.id\n].filter(v => v !== undefined && v !== null && v !== '');\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: String(documentId), received_at: new Date().toISOString(), webhook_payload_keys: Object.keys(body) } }];\n" + }, + "id": "extract-doc-id", + "name": "Extract Document ID", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -760, + 0 + ] + }, + { + "parameters": { + "url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + ($json.document_id ? ($json.document_id + '/') : '?ordering=-added&page_size=1')}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": { + "timeout": 30000 + } + }, + "id": "fetch-document", + "name": "Fetch Paperless Document", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -540, + 0 + ] + }, + { + "parameters": { + "jsCode": "\nconst response = $json;\nconst doc = response.results ? response.results[0] : response;\nif (!doc || !doc.id) throw new Error('No document found in Paperless API response');\nconst truncate = (s, n=9000) => String(s || '').replace(/\\s+/g, ' ').trim().slice(0, n);\nconst archiveUrl = `${($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '')}/documents/${doc.id}/details`;\nconst payload = {\n id: doc.id,\n title: doc.title || 'Untitled',\n original_filename: doc.original_filename || doc.original_file_name || doc.archive_filename || null,\n created: doc.created || null,\n added: doc.added || doc.created_date || null,\n correspondent: doc.correspondent_detail?.name || doc.correspondent_name || doc.correspondent || null,\n document_type: doc.document_type_detail?.name || doc.document_type_name || doc.document_type || null,\n tags: (doc.tags_detail || doc.tags || []).map(t => typeof t === 'string' ? t : (t.name || t.id)).filter(Boolean),\n archive_url: archiveUrl,\n content: truncate(doc.content || doc.text || '')\n};\nconst schema = `Return STRICT JSON only with exactly these keys: summary, suggested_title, document_type, suggested_correspondent, suggested_tags, due_date, needs_review, urgency, reason, confidence. document_type must be one of bill, receipt, school, medical, tax, insurance, legal, manual, letter, other. due_date is YYYY-MM-DD or null. urgency is none, low, medium, high. confidence is high, medium, low.`;\nreturn [{ json: { document: payload, llm_messages: [\n { role: 'system', content: 'You triage Paperless-NGX documents for a human review-first dashboard. Never invent due dates. Be conservative. ' + schema },\n { role: 'user', content: JSON.stringify(payload) }\n] } }];\n" + }, + "id": "prepare-triage", + "name": "Prepare Compact Triage Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -320, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{($env.LITELLM_BASE_URL || 'http://10.5.30.6:4000').replace(/\\/$/, '') + '/v1/chat/completions'}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: $env.PAPERLESS_TRIAGE_MODEL || 'medium', temperature: 0.1, response_format: { type: 'json_object' }, messages: $json.llm_messages }) }}", + "options": { + "timeout": 60000 + } + }, + "id": "llm-triage", + "name": "LiteLLM Triage JSON", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -100, + 0 + ] + }, + { + "parameters": { + "jsCode": "\nfunction parseTriage(input) {\n let content = input.choices?.[0]?.message?.content ?? input.text ?? input;\n if (typeof content !== 'string') content = JSON.stringify(content);\n content = content.trim().replace(/^```json\\s*/i, '').replace(/```$/,'').trim();\n return JSON.parse(content);\n}\nconst sourceDoc = $('Prepare Compact Triage Payload').first().json.document;\nconst t = parseTriage($json);\nconst allowedTypes = new Set(['bill','receipt','school','medical','tax','insurance','legal','manual','letter','other']);\nconst out = {\n summary: String(t.summary || '').slice(0, 800),\n suggested_title: String(t.suggested_title || sourceDoc.title || sourceDoc.original_filename || `Document ${sourceDoc.id}`).slice(0, 200),\n document_type: allowedTypes.has(t.document_type) ? t.document_type : 'other',\n suggested_correspondent: t.suggested_correspondent ? String(t.suggested_correspondent).slice(0, 160) : null,\n suggested_tags: Array.isArray(t.suggested_tags) ? [...new Set(t.suggested_tags.map(x => String(x).trim().toLowerCase()).filter(Boolean))].slice(0, 12) : [],\n due_date: /^\\d{4}-\\d{2}-\\d{2}$/.test(String(t.due_date || '')) ? String(t.due_date) : null,\n needs_review: Boolean(t.needs_review),\n urgency: ['none','low','medium','high'].includes(t.urgency) ? t.urgency : 'medium',\n reason: String(t.reason || 'No reason supplied; review required.').slice(0, 500),\n confidence: ['high','medium','low'].includes(t.confidence) ? t.confidence : 'low'\n};\nconst riskTypes = new Set(['legal','medical','school','tax','insurance']);\nconst billSignals = /\b(bill|payment|invoice|amount due|pay by|past due|statement)\b/i.test(`${out.summary} ${out.suggested_title} ${sourceDoc.content || ''}`);\nconst forcedReview = riskTypes.has(out.document_type) || Boolean(out.due_date) || billSignals || out.confidence === 'low' || ['medium','high'].includes(out.urgency);\nout.needs_review = out.needs_review || forcedReview;\nif (forcedReview && !/review/i.test(out.reason)) out.reason = `${out.reason} Safety gate requires human review.`;\nconst applySafe = String($env.PAPERLESS_TRIAGE_APPLY_SAFE || 'false').toLowerCase() === 'true';\nconst eligible_for_safe_apply = !out.needs_review && out.confidence === 'high' && ['none','low'].includes(out.urgency) && !riskTypes.has(out.document_type) && !out.due_date;\nconst reviewItem = {\n id: sourceDoc.id,\n title: sourceDoc.title,\n original_filename: sourceDoc.original_filename,\n added: sourceDoc.added,\n archive_url: sourceDoc.archive_url,\n current: { correspondent: sourceDoc.correspondent, document_type: sourceDoc.document_type, tags: sourceDoc.tags },\n triage: out,\n safety: { forced_review: forcedReview, eligible_for_safe_apply, apply_safe_enabled: applySafe },\n queued_at: new Date().toISOString()\n};\nreturn [{ json: { document: sourceDoc, triage: out, review_item: reviewItem, should_apply_safe: applySafe && eligible_for_safe_apply, should_notify: out.urgency === 'high' } }];\n" + }, + "id": "parse-safety", + "name": "Parse JSON + Safety Gate", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 120, + 0 + ] + }, + { + "parameters": { + "jsCode": "\n// Requires n8n Code node filesystem access: NODE_FUNCTION_ALLOW_BUILTIN=fs,path\nconst fs = require('fs');\nconst path = require('path');\nconst queuePath = $env.PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH || '/data/paperless_review.json';\nconst maxItems = Number($env.PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS || 200);\nfs.mkdirSync(path.dirname(queuePath), { recursive: true });\nlet queue = [];\ntry { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch (_) { queue = []; }\nif (!Array.isArray(queue)) queue = [];\nconst item = $json.review_item;\nqueue = [item, ...queue.filter(x => String(x.id) !== String(item.id))].slice(0, maxItems);\nconst tmp = queuePath + '.tmp';\nfs.writeFileSync(tmp, JSON.stringify(queue, null, 2) + '\\n', 'utf8');\nfs.renameSync(tmp, queuePath);\nreturn [{ json: { ...$json, queue_path: queuePath, queue_count: queue.length } }];\n" + }, + "id": "write-queue", + "name": "Write Doris Review Queue JSON", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 340, + 0 + ] + }, + { + "parameters": { + "conditions": { + "boolean": [ + { + "value1": "={{$json.should_apply_safe}}", + "value2": true + } + ] + } + }, + "id": "if-safe-apply", + "name": "Apply Safe Updates Enabled?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 560, + -80 + ] + }, + { + "parameters": { + "method": "PATCH", + "url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document.id + '/'}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ title: $json.triage.suggested_title }) }}", + "options": { + "timeout": 30000 + } + }, + "id": "safe-title-update", + "name": "Safe Title Update Only", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 780, + -160 + ] + }, + { + "parameters": { + "conditions": { + "boolean": [ + { + "value1": "={{$json.should_notify && !!$env.GOTIFY_URL && !!$env.GOTIFY_TOKEN}}", + "value2": true + } + ] + } + }, + "id": "if-urgent", + "name": "Urgent Notification Configured?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 560, + 140 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{$env.GOTIFY_URL.replace(/\\/$/, '') + '/message'}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "X-Gotify-Key", + "value": "={{$env.GOTIFY_TOKEN}}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ title: 'Urgent Paperless document', message: `${$json.triage.suggested_title}\n${$json.triage.summary}\n${$json.document.archive_url}`, priority: 8 }) }}", + "options": { + "timeout": 15000 + } + }, + "id": "gotify-urgent", + "name": "Send Urgent Gotify", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 780, + 140 + ] + } + ], + "connections": { + "Paperless Intake Webhook": { + "main": [ + [ + { + "node": "Extract Document ID", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Document ID": { + "main": [ + [ + { + "node": "Fetch Paperless Document", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Paperless Document": { + "main": [ + [ + { + "node": "Prepare Compact Triage Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Compact Triage Payload": { + "main": [ + [ + { + "node": "LiteLLM Triage JSON", + "type": "main", + "index": 0 + } + ] + ] + }, + "LiteLLM Triage JSON": { + "main": [ + [ + { + "node": "Parse JSON + Safety Gate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse JSON + Safety Gate": { + "main": [ + [ + { + "node": "Write Doris Review Queue JSON", + "type": "main", + "index": 0 + } + ] + ] + }, + "Write Doris Review Queue JSON": { + "main": [ + [ + { + "node": "Apply Safe Updates Enabled?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Apply Safe Updates Enabled?": { + "main": [ + [ + { + "node": "Safe Title Update Only", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "Urgent Notification Configured?": { + "main": [ + [ + { + "node": "Send Urgent Gotify", + "type": "main", + "index": 0 + } + ], + [] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-22T21:55:07.641Z", + "id": 323, + "workflowId": "FIhrfDfL1NgakDwl", + "versionId": "26b3e6dd-3e8b-4bd0-a38d-f2d09990447f", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-22T21:55:07.661Z", + "id": 324, + "workflowId": "FIhrfDfL1NgakDwl", + "versionId": "26b3e6dd-3e8b-4bd0-a38d-f2d09990447f", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] + } } diff --git a/n8n-workflows/18-school-paperless-intake-v1.2.json b/n8n-workflows/18-school-paperless-intake-v1.2.json index f38dcf3..68d2406 100644 --- a/n8n-workflows/18-school-paperless-intake-v1.2.json +++ b/n8n-workflows/18-school-paperless-intake-v1.2.json @@ -1,6 +1,10 @@ { - "name": "Telegram School Intake \u2192 Postgres \u2192 Paperless v1.2", - "active": false, + "updatedAt": "2026-05-14T01:53:21.608Z", + "createdAt": "2026-05-13T21:15:57.655Z", + "id": "bu0kZAbLo2KUVkTl", + "name": "Telegram School Intake → Postgres → Paperless v1.2", + "description": null, + "active": true, "isArchived": false, "nodes": [ { @@ -25,7 +29,7 @@ }, { "parameters": { - "jsCode": "\nconst req = $input.first().json;\nconst body = req.body || {};\nconst binary = $input.first().binary || {};\nconst upload = binary.data || Object.values(binary)[0];\nif (!upload) throw new Error('Expected multipart file upload in the webhook request.');\nconst required = ['class_name', 'assignment_name', 'submission_kind'];\nfor (const key of required) {\n if (!body[key] || !String(body[key]).trim()) {\n throw new Error('Missing required form field: ' + key + ' . Required fields: class_name, assignment_name, submission_kind.');\n }\n}\nconst sanitize = (value, fallback = 'item') => String(value || fallback)\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 80) || fallback;\nconst stableHash = (str) => {\n let h1 = 0xdeadbeef;\n let h2 = 0x41c6ce57;\n for (let i = 0; i < str.length; i++) {\n const ch = str.charCodeAt(i);\n h1 = Math.imul(h1 ^ ch, 2654435761);\n h2 = Math.imul(h2 ^ ch, 1597334677);\n }\n h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);\n h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);\n return ((h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0'));\n};\nconst originalName = upload.fileName || body.filename || 'upload.bin';\nconst extension = (() => {\n const m = String(originalName).match(/(\\.[A-Za-z0-9]{1,10})$/);\n return m ? m[1].toLowerCase() : '';\n})();\nconst bytes = Number(upload.bytes ?? 0) || (() => {\n const text = String(upload.fileSize || '').trim();\n const m = text.match(/^([0-9.]+)\\s*(B|KB|MB|GB)$/i);\n if (!m) return 0;\n const value = Number(m[1]);\n const unit = m[2].toUpperCase();\n const mult = unit === 'GB' ? 1024**3 : unit === 'MB' ? 1024**2 : unit === 'KB' ? 1024 : 1;\n return Number.isFinite(value) ? Math.round(value * mult) : 0;\n})();\nconst checksum = stableHash([originalName, upload.mimeType || '', String(bytes), String(body.class_name), String(body.assignment_name), String(body.submission_kind)].join('|'));\nconst now = new Date();\nconst intakeId = ['school', now.toISOString().slice(0,10).replace(/-/g,''), sanitize(body.class_name), sanitize(body.assignment_name), checksum.slice(0, 12)].join('-');\nconst storedFilename = intakeId + extension;\nconst title = [body.class_name, body.assignment_name, body.submission_kind].map(v => String(v).trim()).join(' \u2014 ');\nreturn [{\n json: {\n intake_id: intakeId,\n class_name: String(body.class_name).trim(),\n assignment_name: String(body.assignment_name).trim(),\n submission_kind: String(body.submission_kind).trim(),\n semester: body.semester ? String(body.semester).trim() : null,\n course_code: body.course_code ? String(body.course_code).trim() : null,\n paper_kind: body.paper_kind ? String(body.paper_kind).trim() : null,\n notes: body.notes ? String(body.notes).trim().slice(0, 4000) : null,\n source: body.source ? String(body.source).trim() : 'telegram',\n telegram_chat_id: body.telegram_chat_id ? String(body.telegram_chat_id).trim() : null,\n telegram_message_id: body.telegram_message_id ? String(body.telegram_message_id).trim() : null,\n original_filename: originalName,\n stored_filename: storedFilename,\n mime_type: upload.mimeType || 'application/octet-stream',\n file_size_bytes: bytes,\n checksum_sha256: checksum,\n paperless_title: title,\n received_at: now.toISOString()\n },\n binary: {\n data: {\n ...upload,\n fileName: storedFilename,\n mimeType: upload.mimeType || 'application/octet-stream'\n }\n }\n}];" + "jsCode": "\nconst req = $input.first().json;\nconst body = req.body || {};\nconst binary = $input.first().binary || {};\nconst upload = binary.data || Object.values(binary)[0];\nif (!upload) throw new Error('Expected multipart file upload in the webhook request.');\nconst required = ['class_name', 'assignment_name', 'submission_kind'];\nfor (const key of required) {\n if (!body[key] || !String(body[key]).trim()) {\n throw new Error('Missing required form field: ' + key + ' . Required fields: class_name, assignment_name, submission_kind.');\n }\n}\nconst sanitize = (value, fallback = 'item') => String(value || fallback)\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 80) || fallback;\nconst stableHash = (str) => {\n let h1 = 0xdeadbeef;\n let h2 = 0x41c6ce57;\n for (let i = 0; i < str.length; i++) {\n const ch = str.charCodeAt(i);\n h1 = Math.imul(h1 ^ ch, 2654435761);\n h2 = Math.imul(h2 ^ ch, 1597334677);\n }\n h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);\n h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);\n return ((h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0'));\n};\nconst originalName = upload.fileName || body.filename || 'upload.bin';\nconst extension = (() => {\n const m = String(originalName).match(/(\\.[A-Za-z0-9]{1,10})$/);\n return m ? m[1].toLowerCase() : '';\n})();\nconst bytes = Number(upload.bytes ?? 0) || (() => {\n const text = String(upload.fileSize || '').trim();\n const m = text.match(/^([0-9.]+)\\s*(B|KB|MB|GB)$/i);\n if (!m) return 0;\n const value = Number(m[1]);\n const unit = m[2].toUpperCase();\n const mult = unit === 'GB' ? 1024**3 : unit === 'MB' ? 1024**2 : unit === 'KB' ? 1024 : 1;\n return Number.isFinite(value) ? Math.round(value * mult) : 0;\n})();\nconst checksum = stableHash([originalName, upload.mimeType || '', String(bytes), String(body.class_name), String(body.assignment_name), String(body.submission_kind)].join('|'));\nconst now = new Date();\nconst intakeId = ['school', now.toISOString().slice(0,10).replace(/-/g,''), sanitize(body.class_name), sanitize(body.assignment_name), checksum.slice(0, 12)].join('-');\nconst storedFilename = intakeId + extension;\nconst title = [body.class_name, body.assignment_name, body.submission_kind].map(v => String(v).trim()).join(' — ');\nreturn [{\n json: {\n intake_id: intakeId,\n class_name: String(body.class_name).trim(),\n assignment_name: String(body.assignment_name).trim(),\n submission_kind: String(body.submission_kind).trim(),\n semester: body.semester ? String(body.semester).trim() : null,\n course_code: body.course_code ? String(body.course_code).trim() : null,\n paper_kind: body.paper_kind ? String(body.paper_kind).trim() : null,\n notes: body.notes ? String(body.notes).trim().slice(0, 4000) : null,\n source: body.source ? String(body.source).trim() : 'telegram',\n telegram_chat_id: body.telegram_chat_id ? String(body.telegram_chat_id).trim() : null,\n telegram_message_id: body.telegram_message_id ? String(body.telegram_message_id).trim() : null,\n original_filename: originalName,\n stored_filename: storedFilename,\n mime_type: upload.mimeType || 'application/octet-stream',\n file_size_bytes: bytes,\n checksum_sha256: checksum,\n paperless_title: title,\n received_at: now.toISOString()\n },\n binary: {\n data: {\n ...upload,\n fileName: storedFilename,\n mimeType: upload.mimeType || 'application/octet-stream'\n }\n }\n}];" }, "id": "normalize-request", "name": "Normalize Intake Request", @@ -161,6 +165,29 @@ "staticData": null, "meta": null, "pinData": {}, + "versionId": "27a8310d-fb26-4dfe-a093-611d684f4b67", + "activeVersionId": "27a8310d-fb26-4dfe-a093-611d684f4b67", + "versionCounter": 35, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-13T21:15:57.655Z", + "createdAt": "2026-05-13T21:15:57.655Z", + "role": "workflow:owner", + "workflowId": "bu0kZAbLo2KUVkTl", + "projectId": "dxCRnBdX5uJizCGa", + "project": { + "updatedAt": "2026-05-06T01:10:37.484Z", + "createdAt": "2026-05-06T01:08:07.721Z", + "id": "dxCRnBdX5uJizCGa", + "name": "Wilfred Fizzlepoof ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + } + ], "tags": [ { "updatedAt": "2026-05-13T21:15:56.032Z", @@ -186,5 +213,185 @@ "id": "Q9lCYtCOuy9XmeyL", "name": "school" } - ] + ], + "activeVersion": { + "updatedAt": "2026-05-14T01:53:21.609Z", + "createdAt": "2026-05-14T01:53:21.609Z", + "versionId": "27a8310d-fb26-4dfe-a093-611d684f4b67", + "workflowId": "bu0kZAbLo2KUVkTl", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "school/intake/upload", + "responseMode": "lastNode", + "responseData": "allEntries", + "options": { + "rawBody": true + } + }, + "id": "school-intake-webhook", + "name": "School Intake Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + -1180, + 0 + ], + "webhookId": "6251b84e-2ac7-49a3-9b05-7ca6540da134" + }, + { + "parameters": { + "jsCode": "\nconst req = $input.first().json;\nconst body = req.body || {};\nconst binary = $input.first().binary || {};\nconst upload = binary.data || Object.values(binary)[0];\nif (!upload) throw new Error('Expected multipart file upload in the webhook request.');\nconst required = ['class_name', 'assignment_name', 'submission_kind'];\nfor (const key of required) {\n if (!body[key] || !String(body[key]).trim()) {\n throw new Error('Missing required form field: ' + key + ' . Required fields: class_name, assignment_name, submission_kind.');\n }\n}\nconst sanitize = (value, fallback = 'item') => String(value || fallback)\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 80) || fallback;\nconst stableHash = (str) => {\n let h1 = 0xdeadbeef;\n let h2 = 0x41c6ce57;\n for (let i = 0; i < str.length; i++) {\n const ch = str.charCodeAt(i);\n h1 = Math.imul(h1 ^ ch, 2654435761);\n h2 = Math.imul(h2 ^ ch, 1597334677);\n }\n h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);\n h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);\n return ((h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0'));\n};\nconst originalName = upload.fileName || body.filename || 'upload.bin';\nconst extension = (() => {\n const m = String(originalName).match(/(\\.[A-Za-z0-9]{1,10})$/);\n return m ? m[1].toLowerCase() : '';\n})();\nconst bytes = Number(upload.bytes ?? 0) || (() => {\n const text = String(upload.fileSize || '').trim();\n const m = text.match(/^([0-9.]+)\\s*(B|KB|MB|GB)$/i);\n if (!m) return 0;\n const value = Number(m[1]);\n const unit = m[2].toUpperCase();\n const mult = unit === 'GB' ? 1024**3 : unit === 'MB' ? 1024**2 : unit === 'KB' ? 1024 : 1;\n return Number.isFinite(value) ? Math.round(value * mult) : 0;\n})();\nconst checksum = stableHash([originalName, upload.mimeType || '', String(bytes), String(body.class_name), String(body.assignment_name), String(body.submission_kind)].join('|'));\nconst now = new Date();\nconst intakeId = ['school', now.toISOString().slice(0,10).replace(/-/g,''), sanitize(body.class_name), sanitize(body.assignment_name), checksum.slice(0, 12)].join('-');\nconst storedFilename = intakeId + extension;\nconst title = [body.class_name, body.assignment_name, body.submission_kind].map(v => String(v).trim()).join(' — ');\nreturn [{\n json: {\n intake_id: intakeId,\n class_name: String(body.class_name).trim(),\n assignment_name: String(body.assignment_name).trim(),\n submission_kind: String(body.submission_kind).trim(),\n semester: body.semester ? String(body.semester).trim() : null,\n course_code: body.course_code ? String(body.course_code).trim() : null,\n paper_kind: body.paper_kind ? String(body.paper_kind).trim() : null,\n notes: body.notes ? String(body.notes).trim().slice(0, 4000) : null,\n source: body.source ? String(body.source).trim() : 'telegram',\n telegram_chat_id: body.telegram_chat_id ? String(body.telegram_chat_id).trim() : null,\n telegram_message_id: body.telegram_message_id ? String(body.telegram_message_id).trim() : null,\n original_filename: originalName,\n stored_filename: storedFilename,\n mime_type: upload.mimeType || 'application/octet-stream',\n file_size_bytes: bytes,\n checksum_sha256: checksum,\n paperless_title: title,\n received_at: now.toISOString()\n },\n binary: {\n data: {\n ...upload,\n fileName: storedFilename,\n mimeType: upload.mimeType || 'application/octet-stream'\n }\n }\n}];" + }, + "id": "normalize-request", + "name": "Normalize Intake Request", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -920, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://paperless.paccoco.com/api/documents/post_document/", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "=Token {{ $env.PAPERLESS_API_TOKEN }}" + } + ] + }, + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "parameterType": "formBinaryData", + "name": "document", + "inputDataFieldName": "data" + }, + { + "name": "title", + "value": "={{ $json.paperless_title }}" + } + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "text" + } + } + } + }, + "id": "paperless-upload", + "name": "Upload to Paperless", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -400, + 0 + ] + }, + { + "parameters": { + "jsCode": "\nconst intake = $('Normalize Intake Request').first().json;\nconst response = $json;\nconst taskId = response.task_id || response.id || response.document?.id || null;\nlet paperlessDocumentId = response.document_id || response.document?.id || null;\nif (!paperlessDocumentId && typeof response === 'object' && response?.id && !response?.task_id) {\n paperlessDocumentId = response.id;\n}\nreturn [{ json: {\n ...intake,\n paperless_task_id: taskId ? String(taskId) : null,\n paperless_document_id: paperlessDocumentId ? String(paperlessDocumentId) : null,\n paperless_response: response,\n uploaded_at: new Date().toISOString(),\n status: 'uploaded'\n}}];" + }, + "id": "capture-paperless-response", + "name": "Capture Paperless Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -140, + 0 + ] + }, + { + "parameters": { + "jsCode": "\nreturn [{\n json: {\n ok: true,\n intake_id: $json.intake_id,\n status: $json.status || 'uploaded',\n paperless_task_id: $json.paperless_task_id,\n paperless_document_id: $json.paperless_document_id,\n paperless_title: $json.paperless_title,\n stored_filename: $json.stored_filename,\n received_at: $json.received_at,\n uploaded_at: $json.uploaded_at\n }\n}];" + }, + "id": "success-response", + "name": "Return Success Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 360, + 0 + ] + } + ], + "connections": { + "School Intake Webhook": { + "main": [ + [ + { + "node": "Normalize Intake Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Intake Request": { + "main": [ + [ + { + "node": "Upload to Paperless", + "type": "main", + "index": 0 + } + ] + ] + }, + "Upload to Paperless": { + "main": [ + [ + { + "node": "Capture Paperless Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Capture Paperless Response": { + "main": [ + [ + { + "node": "Return Success Payload", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-14T01:53:21.640Z", + "id": 258, + "workflowId": "bu0kZAbLo2KUVkTl", + "versionId": "27a8310d-fb26-4dfe-a093-611d684f4b67", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-14T01:53:21.661Z", + "id": 259, + "workflowId": "bu0kZAbLo2KUVkTl", + "versionId": "27a8310d-fb26-4dfe-a093-611d684f4b67", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] + } } diff --git a/n8n-workflows/19-paperless-school-metadata-enrichment.json b/n8n-workflows/19-paperless-school-metadata-enrichment.json index a91fc8c..243537e 100644 --- a/n8n-workflows/19-paperless-school-metadata-enrichment.json +++ b/n8n-workflows/19-paperless-school-metadata-enrichment.json @@ -1,6 +1,10 @@ { + "updatedAt": "2026-05-22T21:55:07.379Z", + "createdAt": "2026-05-13T21:26:54.606Z", + "id": "7MlM3DjAMf4eSl0s", "name": "Paperless School Intake Metadata Enrichment v1.1", - "active": false, + "description": null, + "active": true, "isArchived": false, "nodes": [ { @@ -8,14 +12,15 @@ "httpMethod": "POST", "path": "school/intake/paperless-enrich", "responseMode": "lastNode", - "responseData": "allEntries" + "responseData": "allEntries", + "options": {} }, "id": "paperless-school-webhook", "name": "Paperless School Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [ - -1160, + -1168, 0 ], "webhookId": "22ec3671-3ad9-4c48-8720-2ba31b944671" @@ -29,13 +34,12 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - -900, + -896, 0 ] }, { "parameters": { - "method": "GET", "url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/'}}", "sendHeaders": true, "headerParameters": { @@ -65,27 +69,27 @@ }, { "parameters": { - "jsCode": "\nconst doc = $input.first().json || {};\nconst originalFilename = doc.original_file_name || doc.original_filename || '';\nconst baseName = String(originalFilename).replace(/\\.[^.]+$/, '');\nconst currentTitle = String(doc.title || '').trim();\nconst parts = currentTitle.split(' \u2014 ').map((p) => p.trim()).filter(Boolean);\nconst class_name = parts.length >= 3 ? parts[0] : null;\nconst submission_kind = parts.length >= 3 ? parts[parts.length - 1] : null;\nconst assignment_name = parts.length >= 3 ? parts.slice(1, -1).join(' \u2014 ') : null;\nreturn [{ json: {\n intake_id: baseName && baseName.startsWith('school-') ? baseName : null,\n document_id: doc.id,\n current_title: currentTitle,\n current_tags: Array.isArray(doc.tags) ? doc.tags : [],\n original_filename: originalFilename,\n class_name,\n assignment_name,\n submission_kind\n} }];" + "jsCode": "\nconst doc = $input.first().json || {};\nconst originalFilename = doc.original_file_name || doc.original_filename || '';\nconst baseName = String(originalFilename).replace(/\\.[^.]+$/, '');\nconst currentTitle = String(doc.title || '').trim();\nconst parts = currentTitle.split(' — ').map((p) => p.trim()).filter(Boolean);\nconst class_name = parts.length >= 3 ? parts[0] : null;\nconst submission_kind = parts.length >= 3 ? parts[parts.length - 1] : null;\nconst assignment_name = parts.length >= 3 ? parts.slice(1, -1).join(' — ') : null;\nreturn [{ json: {\n intake_id: baseName && baseName.startsWith('school-') ? baseName : null,\n document_id: doc.id,\n current_title: currentTitle,\n current_tags: Array.isArray(doc.tags) ? doc.tags : [],\n original_filename: originalFilename,\n class_name,\n assignment_name,\n submission_kind\n} }];" }, "id": "extract-intake-id", "name": "Extract Intake Metadata", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - -380, + -384, 0 ] }, { "parameters": { - "jsCode": "\nconst intake = $input.first().json || {};\nconst doc = $('Fetch Paperless Document').first().json || {};\nconst classTagMap = JSON.parse($env.PAPERLESS_TAG_CLASS_MAP_JSON || '{}');\nconst kindTagMap = JSON.parse($env.PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON || '{}');\nconst classCorrespondentMap = JSON.parse($env.PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON || '{}');\nconst currentTags = Array.isArray(doc.tags) ? doc.tags.map(Number).filter(Number.isFinite) : [];\nconst merged = new Set(currentTags);\nfor (const id of [$env.PAPERLESS_TAG_SCHOOL, $env.PAPERLESS_TAG_ASSIGNMENTS, $env.PAPERLESS_TAG_TELEGRAM]) {\n const n = Number(id);\n if (Number.isFinite(n) && n > 0) merged.add(n);\n}\nconst classTag = Number(classTagMap[intake.class_name]);\nif (Number.isFinite(classTag) && classTag > 0) merged.add(classTag);\nconst kindKey = String(intake.submission_kind || '').trim().toLowerCase();\nconst kindTag = Number(kindTagMap[kindKey]);\nif (Number.isFinite(kindTag) && kindTag > 0) merged.add(kindTag);\nconst title = intake.current_title || [intake.class_name, intake.assignment_name, intake.submission_kind].filter(Boolean).join(' \u2014 ');\nconst payload = { title, tags: Array.from(merged) };\nconst correspondent = Number(classCorrespondentMap[intake.class_name]);\nif (Number.isFinite(correspondent) && correspondent > 0) payload.correspondent = correspondent;\nreturn [{ json: {\n intake_id: intake.intake_id,\n document_id: doc.id,\n payload,\n title,\n final_tag_ids: payload.tags,\n class_name: intake.class_name,\n assignment_name: intake.assignment_name,\n submission_kind: intake.submission_kind,\n original_filename: intake.original_filename,\n content: String(doc.content || '').slice(0, 12000),\n created: doc.created || null,\n status: 'enriched'\n} }];" + "jsCode": "\nconst intake = $input.first().json || {};\nconst doc = $('Fetch Paperless Document').first().json || {};\nconst classTagMap = JSON.parse($env.PAPERLESS_TAG_CLASS_MAP_JSON || '{}');\nconst kindTagMap = JSON.parse($env.PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON || '{}');\nconst classCorrespondentMap = JSON.parse($env.PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON || '{}');\nconst currentTags = Array.isArray(doc.tags) ? doc.tags.map(Number).filter(Number.isFinite) : [];\nconst merged = new Set(currentTags);\nfor (const id of [$env.PAPERLESS_TAG_SCHOOL, $env.PAPERLESS_TAG_ASSIGNMENTS, $env.PAPERLESS_TAG_TELEGRAM]) {\n const n = Number(id);\n if (Number.isFinite(n) && n > 0) merged.add(n);\n}\nconst classTag = Number(classTagMap[intake.class_name]);\nif (Number.isFinite(classTag) && classTag > 0) merged.add(classTag);\nconst kindKey = String(intake.submission_kind || '').trim().toLowerCase();\nconst kindTag = Number(kindTagMap[kindKey]);\nif (Number.isFinite(kindTag) && kindTag > 0) merged.add(kindTag);\nconst title = intake.current_title || [intake.class_name, intake.assignment_name, intake.submission_kind].filter(Boolean).join(' — ');\nconst payload = { title, tags: Array.from(merged) };\nconst correspondent = Number(classCorrespondentMap[intake.class_name]);\nif (Number.isFinite(correspondent) && correspondent > 0) payload.correspondent = correspondent;\nreturn [{ json: {\n intake_id: intake.intake_id,\n document_id: doc.id,\n payload,\n title,\n final_tag_ids: payload.tags,\n class_name: intake.class_name,\n assignment_name: intake.assignment_name,\n submission_kind: intake.submission_kind,\n original_filename: intake.original_filename,\n content: String(doc.content || '').slice(0, 12000),\n created: doc.created || null,\n status: 'enriched'\n} }];" }, "id": "build-paperless-update", "name": "Build Paperless Update", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 140, + 144, 0 ] }, @@ -111,8 +115,11 @@ ] }, "sendBody": true, - "contentType": "json", - "jsonBody": "={{$json.payload}}", + "bodyParameters": { + "parameters": [ + {} + ] + }, "options": { "timeout": 120000 } @@ -135,7 +142,7 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 920, + 1216, 0 ] }, @@ -166,20 +173,20 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 660, + 672, 0 ] }, { "parameters": { - "jsCode": "\nconst raw = $input.first().json?.choices?.[0]?.message?.content || '{}';\nconst meta = $('Build Paperless Update').first().json;\nlet parsed;\ntry {\n parsed = JSON.parse(raw);\n} catch (error) {\n parsed = {\n summary: 'School document ingested and tagged. Automated summary parsing failed, so this note was kept minimal.',\n course_context: [],\n suggested_tags: []\n };\n}\nconst contextLines = [\n `- Course: ${meta.class_name || 'Unknown'}`,\n meta.assignment_name ? `- D2L assignment: ${meta.assignment_name}` : null,\n meta.submission_kind ? `- Submission kind: ${meta.submission_kind}` : null,\n ...(Array.isArray(parsed.course_context) ? parsed.course_context.filter(Boolean).map(v => `- ${String(v).replace(/^[-\u2022]\\s*/, '').trim()}`) : [])\n].filter(Boolean);\nconst note = [\n '**School Intake Summary**',\n ...contextLines,\n '',\n '**Summary**',\n String(parsed.summary || '').trim() || 'No summary returned.'\n].join('\\n');\nconst aiTags = Array.isArray(parsed.suggested_tags)\n ? [...new Set(parsed.suggested_tags.map(t => String(t || '').trim().toLowerCase()).filter(Boolean))].slice(0, 6)\n : [];\nreturn [{ json: { ...meta, note, ai_suggested_tags: aiTags } }];" + "jsCode": "\nconst raw = $input.first().json?.choices?.[0]?.message?.content || '{}';\nconst meta = $('Build Paperless Update').first().json;\nlet parsed;\ntry {\n parsed = JSON.parse(raw);\n} catch (error) {\n parsed = {\n summary: 'School document ingested and tagged. Automated summary parsing failed, so this note was kept minimal.',\n course_context: [],\n suggested_tags: []\n };\n}\nconst contextLines = [\n `- Course: ${meta.class_name || 'Unknown'}`,\n meta.assignment_name ? `- D2L assignment: ${meta.assignment_name}` : null,\n meta.submission_kind ? `- Submission kind: ${meta.submission_kind}` : null,\n ...(Array.isArray(parsed.course_context) ? parsed.course_context.filter(Boolean).map(v => `- ${String(v).replace(/^[-•]\\s*/, '').trim()}`) : [])\n].filter(Boolean);\nconst note = [\n '**School Intake Summary**',\n ...contextLines,\n '',\n '**Summary**',\n String(parsed.summary || '').trim() || 'No summary returned.'\n].join('\\n');\nconst aiTags = Array.isArray(parsed.suggested_tags)\n ? [...new Set(parsed.suggested_tags.map(t => String(t || '').trim().toLowerCase()).filter(Boolean))].slice(0, 6)\n : [];\nreturn [{ json: { ...meta, note, ai_suggested_tags: aiTags } }];" }, "id": "school-parse-summary", "name": "Parse School Summary", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 920, + 864, 0 ] }, @@ -205,8 +212,11 @@ ] }, "sendBody": true, - "contentType": "json", - "jsonBody": "={{ { note: $json.note } }}", + "bodyParameters": { + "parameters": [ + {} + ] + }, "options": { "timeout": 120000 } @@ -216,7 +226,7 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 1180, + 1040, 0 ] } @@ -323,10 +333,388 @@ } }, "settings": { - "executionOrder": "v1" + "executionOrder": "v1", + "binaryMode": "separate" }, "staticData": null, "meta": null, - "pinData": null, - "tags": [] + "pinData": {}, + "versionId": "c7771791-a51a-4750-b99c-ea263463ac51", + "activeVersionId": "c7771791-a51a-4750-b99c-ea263463ac51", + "versionCounter": 51, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-13T21:26:54.606Z", + "createdAt": "2026-05-13T21:26:54.606Z", + "role": "workflow:owner", + "workflowId": "7MlM3DjAMf4eSl0s", + "projectId": "dxCRnBdX5uJizCGa", + "project": { + "updatedAt": "2026-05-06T01:10:37.484Z", + "createdAt": "2026-05-06T01:08:07.721Z", + "id": "dxCRnBdX5uJizCGa", + "name": "Wilfred Fizzlepoof ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-05-22T21:55:07.381Z", + "createdAt": "2026-05-22T21:55:07.381Z", + "versionId": "c7771791-a51a-4750-b99c-ea263463ac51", + "workflowId": "7MlM3DjAMf4eSl0s", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "school/intake/paperless-enrich", + "responseMode": "lastNode", + "responseData": "allEntries", + "options": {} + }, + "id": "paperless-school-webhook", + "name": "Paperless School Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + -1168, + 0 + ], + "webhookId": "22ec3671-3ad9-4c48-8720-2ba31b944671" + }, + { + "parameters": { + "jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst query = input.query || {};\nconst params = input.params || {};\nconst headers = input.headers || {};\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document,\n query.document_id,\n query.id,\n query.doc_id,\n params.document_id,\n params.id,\n headers['x-paperless-document-id'],\n input.document_id,\n input.id\n].filter(v => v !== undefined && v !== null && v !== '');\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: documentId } }];" + }, + "id": "normalize-paperless-webhook", + "name": "Normalize Paperless Webhook", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -896, + 0 + ] + }, + { + "parameters": { + "url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/'}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": { + "timeout": 120000 + } + }, + "id": "fetch-paperless-document", + "name": "Fetch Paperless Document", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -640, + 0 + ] + }, + { + "parameters": { + "jsCode": "\nconst doc = $input.first().json || {};\nconst originalFilename = doc.original_file_name || doc.original_filename || '';\nconst baseName = String(originalFilename).replace(/\\.[^.]+$/, '');\nconst currentTitle = String(doc.title || '').trim();\nconst parts = currentTitle.split(' — ').map((p) => p.trim()).filter(Boolean);\nconst class_name = parts.length >= 3 ? parts[0] : null;\nconst submission_kind = parts.length >= 3 ? parts[parts.length - 1] : null;\nconst assignment_name = parts.length >= 3 ? parts.slice(1, -1).join(' — ') : null;\nreturn [{ json: {\n intake_id: baseName && baseName.startsWith('school-') ? baseName : null,\n document_id: doc.id,\n current_title: currentTitle,\n current_tags: Array.isArray(doc.tags) ? doc.tags : [],\n original_filename: originalFilename,\n class_name,\n assignment_name,\n submission_kind\n} }];" + }, + "id": "extract-intake-id", + "name": "Extract Intake Metadata", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -384, + 0 + ] + }, + { + "parameters": { + "jsCode": "\nconst intake = $input.first().json || {};\nconst doc = $('Fetch Paperless Document').first().json || {};\nconst classTagMap = JSON.parse($env.PAPERLESS_TAG_CLASS_MAP_JSON || '{}');\nconst kindTagMap = JSON.parse($env.PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON || '{}');\nconst classCorrespondentMap = JSON.parse($env.PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON || '{}');\nconst currentTags = Array.isArray(doc.tags) ? doc.tags.map(Number).filter(Number.isFinite) : [];\nconst merged = new Set(currentTags);\nfor (const id of [$env.PAPERLESS_TAG_SCHOOL, $env.PAPERLESS_TAG_ASSIGNMENTS, $env.PAPERLESS_TAG_TELEGRAM]) {\n const n = Number(id);\n if (Number.isFinite(n) && n > 0) merged.add(n);\n}\nconst classTag = Number(classTagMap[intake.class_name]);\nif (Number.isFinite(classTag) && classTag > 0) merged.add(classTag);\nconst kindKey = String(intake.submission_kind || '').trim().toLowerCase();\nconst kindTag = Number(kindTagMap[kindKey]);\nif (Number.isFinite(kindTag) && kindTag > 0) merged.add(kindTag);\nconst title = intake.current_title || [intake.class_name, intake.assignment_name, intake.submission_kind].filter(Boolean).join(' — ');\nconst payload = { title, tags: Array.from(merged) };\nconst correspondent = Number(classCorrespondentMap[intake.class_name]);\nif (Number.isFinite(correspondent) && correspondent > 0) payload.correspondent = correspondent;\nreturn [{ json: {\n intake_id: intake.intake_id,\n document_id: doc.id,\n payload,\n title,\n final_tag_ids: payload.tags,\n class_name: intake.class_name,\n assignment_name: intake.assignment_name,\n submission_kind: intake.submission_kind,\n original_filename: intake.original_filename,\n content: String(doc.content || '').slice(0, 12000),\n created: doc.created || null,\n status: 'enriched'\n} }];" + }, + "id": "build-paperless-update", + "name": "Build Paperless Update", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 144, + 0 + ] + }, + { + "parameters": { + "method": "PATCH", + "url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/'}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}" + }, + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + {} + ] + }, + "options": { + "timeout": 120000 + } + }, + "id": "update-paperless-document", + "name": "Update Paperless Document", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 400, + 0 + ] + }, + { + "parameters": { + "jsCode": "\nconst update = $('Parse School Summary').first().json;\nreturn [{ json: {\n ok: true,\n intake_id: update.intake_id,\n document_id: update.document_id,\n title: update.title,\n final_tag_ids: update.final_tag_ids,\n ai_suggested_tags: update.ai_suggested_tags || [],\n status: update.status || 'enriched'\n} }];" + }, + "id": "return-enrichment-success", + "name": "Return Enrichment Success", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1216, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://10.5.30.6:4000/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Authorization", + "value": "={{'Bearer ' + $env.LITELLM_API_KEY}}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You write concise, useful summaries for school assignments stored in Paperless. Return ONLY valid JSON with this exact shape: {\\\"summary\\\": string, \\\"course_context\\\": [string], \\\"suggested_tags\\\": [string]}. Summary should be 2-4 sentences, name the argument or subject clearly, and avoid fluff. course_context should contain short bullets like assignment type, score if known, or class framing when inferable from the provided metadata. suggested_tags should be 3-6 lowercase tags focused on subject matter, not generic words. No markdown, no code fences.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"={{ JSON.stringify('Title: ' + ($json.title || '') + '\\nClass: ' + ($json.class_name || '') + '\\nAssignment: ' + ($json.assignment_name || '') + '\\nSubmission kind: ' + ($json.submission_kind || '') + '\\nOriginal filename: ' + ($json.original_filename || '') + '\\n\\nDocument content:\\n' + ($json.content || '')).slice(1, -1) }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}", + "options": {} + }, + "id": "school-ai-summary", + "name": "Generate School Summary", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 672, + 0 + ] + }, + { + "parameters": { + "jsCode": "\nconst raw = $input.first().json?.choices?.[0]?.message?.content || '{}';\nconst meta = $('Build Paperless Update').first().json;\nlet parsed;\ntry {\n parsed = JSON.parse(raw);\n} catch (error) {\n parsed = {\n summary: 'School document ingested and tagged. Automated summary parsing failed, so this note was kept minimal.',\n course_context: [],\n suggested_tags: []\n };\n}\nconst contextLines = [\n `- Course: ${meta.class_name || 'Unknown'}`,\n meta.assignment_name ? `- D2L assignment: ${meta.assignment_name}` : null,\n meta.submission_kind ? `- Submission kind: ${meta.submission_kind}` : null,\n ...(Array.isArray(parsed.course_context) ? parsed.course_context.filter(Boolean).map(v => `- ${String(v).replace(/^[-•]\\s*/, '').trim()}`) : [])\n].filter(Boolean);\nconst note = [\n '**School Intake Summary**',\n ...contextLines,\n '',\n '**Summary**',\n String(parsed.summary || '').trim() || 'No summary returned.'\n].join('\\n');\nconst aiTags = Array.isArray(parsed.suggested_tags)\n ? [...new Set(parsed.suggested_tags.map(t => String(t || '').trim().toLowerCase()).filter(Boolean))].slice(0, 6)\n : [];\nreturn [{ json: { ...meta, note, ai_suggested_tags: aiTags } }];" + }, + "id": "school-parse-summary", + "name": "Parse School Summary", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 864, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '') + '/api/documents/' + $json.document_id + '/notes/'}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}" + }, + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + {} + ] + }, + "options": { + "timeout": 120000 + } + }, + "id": "add-school-note", + "name": "Add School Note", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1040, + 0 + ] + } + ], + "connections": { + "Paperless School Webhook": { + "main": [ + [ + { + "node": "Normalize Paperless Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Paperless Webhook": { + "main": [ + [ + { + "node": "Fetch Paperless Document", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Paperless Document": { + "main": [ + [ + { + "node": "Extract Intake Metadata", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Intake Metadata": { + "main": [ + [ + { + "node": "Build Paperless Update", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Paperless Update": { + "main": [ + [ + { + "node": "Update Paperless Document", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update Paperless Document": { + "main": [ + [ + { + "node": "Generate School Summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generate School Summary": { + "main": [ + [ + { + "node": "Parse School Summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse School Summary": { + "main": [ + [ + { + "node": "Add School Note", + "type": "main", + "index": 0 + } + ] + ] + }, + "Add School Note": { + "main": [ + [ + { + "node": "Return Enrichment Success", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Wilfred Fizzlepoof", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-22T21:55:07.444Z", + "id": 321, + "workflowId": "7MlM3DjAMf4eSl0s", + "versionId": "c7771791-a51a-4750-b99c-ea263463ac51", + "event": "deactivated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + }, + { + "createdAt": "2026-05-22T21:55:07.467Z", + "id": 322, + "workflowId": "7MlM3DjAMf4eSl0s", + "versionId": "c7771791-a51a-4750-b99c-ea263463ac51", + "event": "activated", + "userId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d" + } + ] + } }