homelab: sync post-migration repo and n8n runtime audit
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled

This commit is contained in:
Fizzlepoof
2026-05-22 23:04:33 +00:00
parent d62a391cbf
commit bec21292de
28 changed files with 6291 additions and 430 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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())

View File

@@ -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/<site>/firewall/zone
/proxy/network/v2/api/site/<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())

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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'},
],
},

View File

@@ -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
}

View File

@@ -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"
}
]

View File

@@ -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

View File

@@ -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`

View File

@@ -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/<id>` with `network_id`, `virtual_network_override_enabled=true`, and `virtual_network_override_id=<target_network_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

View File

@@ -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 designs 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

View File

@@ -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

View File

@@ -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 <admin@paccoco.com>",
"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"
}
]
}
}

View File

@@ -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"
}
]
}
}
}

View File

@@ -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"
}
]
}
}

View File

@@ -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"
}
]
}
}

View File

@@ -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 <admin@paccoco.com>",
"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"
}
]
}
],
"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"
}
]
}
}

View File

@@ -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 <admin@paccoco.com>",
"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"
}
]
}
],
"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"
}
]
}
}

View File

@@ -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 <admin@paccoco.com>",
"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"
}
]
}
],
"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"
}
]
}
}

View File

@@ -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 <admin@paccoco.com>",
"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"
}
]
}
}

View File

@@ -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 <admin@paccoco.com>",
"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"
}
]
}
}

View File

@@ -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 <admin@paccoco.com>",
"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"
}
]
}
}

View File

@@ -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 <admin@paccoco.com>",
"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"
}
]
}
}