fix(n8n): repair school intake upload flow
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# School Paperless Intake Workflow
|
# School Paperless Intake Workflow
|
||||||
|
|
||||||
Current workflow versions:
|
Current workflow versions:
|
||||||
- `n8n-workflows/18-school-paperless-intake-v1.1.json`
|
- `n8n-workflows/18-school-paperless-intake-v1.2.json`
|
||||||
- `n8n-workflows/19-paperless-school-metadata-enrichment-v1.0.json`
|
- `n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json`
|
||||||
|
|
||||||
This workflow set is meant for a Telegram chat-driven upload pipeline that sends schoolwork into Paperless-NGX while recording intake metadata in shared Postgres.
|
This workflow set is meant for a Telegram chat-driven upload pipeline that sends schoolwork into Paperless-NGX while recording intake metadata in shared Postgres.
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ When these workflow artifacts change, the version must change too:
|
|||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
### Workflow 18 — v1.1
|
### Workflow 18 — v1.2
|
||||||
|
|
||||||
1. Accepts a multipart upload at `POST /webhook/school/intake/upload`
|
1. Accepts a multipart upload at `POST /webhook/school/intake/upload`
|
||||||
2. Requires form fields:
|
2. Requires form fields:
|
||||||
@@ -38,11 +38,9 @@ When these workflow artifacts change, the version must change too:
|
|||||||
## Shared Postgres expectations
|
## Shared Postgres expectations
|
||||||
|
|
||||||
- Use the shared Postgres network/credentials pattern already used by the automation stack.
|
- Use the shared Postgres network/credentials pattern already used by the automation stack.
|
||||||
- The workflow expects an n8n Postgres credential named `Shared Postgres`.
|
- Earlier drafts used shared Postgres tracking, but the current live-tested v1.2/v1.1 path works without Postgres dependency in the webhook chain.
|
||||||
- Preferred target database: dedicated `school_intake`
|
- Deterministic filename/title are now the source of correlation between upload and enrichment.
|
||||||
- Current fast-path/live bootstrap also works against the existing `n8n` database, provided the schema tables are created there.
|
- Tracked schema: `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql` (kept for future DB-backed intake tracking if reinstated)
|
||||||
- If you are using the current PD bootstrap path, point the n8n credential at database `n8n` with the existing Postgres account.
|
|
||||||
- Tracked schema: `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`
|
|
||||||
|
|
||||||
## Required environment variables
|
## Required environment variables
|
||||||
|
|
||||||
@@ -86,6 +84,7 @@ Notes:
|
|||||||
- `PAPERLESS_TAG_CLASS_MAP_JSON`, `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON`, `PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON`, and `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON` are optional JSON maps used by workflow 19.
|
- `PAPERLESS_TAG_CLASS_MAP_JSON`, `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON`, `PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON`, and `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON` are optional JSON maps used by workflow 19.
|
||||||
- Leave optional Paperless IDs and JSON maps blank/default if you prefer to start with deterministic title + generic tags only.
|
- Leave optional Paperless IDs and JSON maps blank/default if you prefer to start with deterministic title + generic tags only.
|
||||||
- Workflow 18 v1.1 no longer requires `crypto`, so `NODE_FUNCTION_ALLOW_BUILTIN=crypto` is optional instead of required.
|
- Workflow 18 v1.1 no longer requires `crypto`, so `NODE_FUNCTION_ALLOW_BUILTIN=crypto` is optional instead of required.
|
||||||
|
- Workflow 18 v1.2 fixes the Paperless multipart upload by using `inputDataFieldName` for the binary form field.
|
||||||
- `N8N_BLOCK_ENV_ACCESS_IN_NODE` must remain `false`.
|
- `N8N_BLOCK_ENV_ACCESS_IN_NODE` must remain `false`.
|
||||||
|
|
||||||
## Paperless metadata behavior
|
## Paperless metadata behavior
|
||||||
@@ -100,7 +99,7 @@ Workflow 18 deliberately keeps the Paperless upload minimal and safe:
|
|||||||
|
|
||||||
The deterministic filename/title preserve the `intake_id` for later correlation.
|
The deterministic filename/title preserve the `intake_id` for later correlation.
|
||||||
|
|
||||||
### Workflow 19 — v1.0
|
### Workflow 19 — v1.1
|
||||||
|
|
||||||
Workflow 19 handles the follow-up metadata pass after Paperless finishes processing:
|
Workflow 19 handles the follow-up metadata pass after Paperless finishes processing:
|
||||||
- fetch Paperless document by webhook document id
|
- fetch Paperless document by webhook document id
|
||||||
@@ -118,12 +117,10 @@ Use the example curl request in:
|
|||||||
Minimum test checklist:
|
Minimum test checklist:
|
||||||
1. Import the workflow into n8n.
|
1. Import the workflow into n8n.
|
||||||
2. Attach the `Shared Postgres` credential.
|
2. Attach the `Shared Postgres` credential.
|
||||||
3. Confirm the target DB/table exists in either `school_intake` or `n8n`, depending on which credential/database you are using.
|
3. Send the sample multipart request.
|
||||||
4. Send the sample multipart request.
|
4. Verify the uploaded Paperless document title and stored filename both include the deterministic intake ID.
|
||||||
5. Verify a row appears in `school_paperless_intake` with `status=uploaded`.
|
5. Trigger workflow 19 with the Paperless document id and verify title/tags enrichment succeeds.
|
||||||
6. Verify the uploaded Paperless document title and stored filename both include the deterministic intake ID.
|
6. Re-submit the same file and metadata to confirm the same deterministic `intake_id` pattern is reused.
|
||||||
7. Trigger workflow 19 with the Paperless document id and verify the row moves to `status=enriched`.
|
|
||||||
8. Re-submit the same file and metadata to confirm the same `intake_id` is reused and the Postgres row is upserted.
|
|
||||||
|
|
||||||
## Known assumptions / caveats
|
## Known assumptions / caveats
|
||||||
|
|
||||||
|
|||||||
190
n8n-workflows/18-school-paperless-intake-v1.2.json
Normal file
190
n8n-workflows/18-school-paperless-intake-v1.2.json
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
{
|
||||||
|
"name": "Telegram School Intake \u2192 Postgres \u2192 Paperless v1.2",
|
||||||
|
"active": false,
|
||||||
|
"isArchived": false,
|
||||||
|
"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(' \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}];"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": {},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-13T21:15:56.032Z",
|
||||||
|
"createdAt": "2026-05-13T21:15:56.032Z",
|
||||||
|
"id": "h1moa2N6f4A1ArxD",
|
||||||
|
"name": "telegram"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T02:27:25.824Z",
|
||||||
|
"createdAt": "2026-05-07T02:27:25.824Z",
|
||||||
|
"id": "MfaTegnw8p0SsGGm",
|
||||||
|
"name": "postgres"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-06T22:39:46.476Z",
|
||||||
|
"createdAt": "2026-05-06T22:39:46.476Z",
|
||||||
|
"id": "NxHjuO4MMd5kdPR7",
|
||||||
|
"name": "paperless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"createdAt": "2026-05-07T00:49:56.081Z",
|
||||||
|
"id": "Q9lCYtCOuy9XmeyL",
|
||||||
|
"name": "school"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
218
n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json
Normal file
218
n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless School Intake Metadata Enrichment v1.1",
|
||||||
|
"active": false,
|
||||||
|
"isArchived": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "school/intake/paperless-enrich",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries"
|
||||||
|
},
|
||||||
|
"id": "paperless-school-webhook",
|
||||||
|
"name": "Paperless School Webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-1160,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "22ec3671-3ad9-4c48-8720-2ba31b944671"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || input;\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].filter(v => v !== undefined && v !== null);\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": [
|
||||||
|
-900,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"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(' \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} }];"
|
||||||
|
},
|
||||||
|
"id": "extract-intake-id",
|
||||||
|
"name": "Extract Intake Metadata",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-380,
|
||||||
|
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 status: 'enriched'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "build-paperless-update",
|
||||||
|
"name": "Build Paperless Update",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
140,
|
||||||
|
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,
|
||||||
|
"contentType": "json",
|
||||||
|
"jsonBody": "={{$json.payload}}",
|
||||||
|
"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 = $('Build Paperless Update').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 status: update.status || 'enriched'\n} }];"
|
||||||
|
},
|
||||||
|
"id": "return-enrichment-success",
|
||||||
|
"name": "Return Enrichment Success",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
920,
|
||||||
|
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": "Return Enrichment Success",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"staticData": null,
|
||||||
|
"meta": null,
|
||||||
|
"pinData": null,
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
| 14 | Paperless Inbox Reminder | Scheduled (Monday 9 AM) | Queries Paperless for untagged documents; sends Gotify reminder with oldest items listed, or a ✅ clear if inbox is empty |
|
| 14 | Paperless Inbox Reminder | Scheduled (Monday 9 AM) | Queries Paperless for untagged documents; sends Gotify reminder with oldest items listed, or a ✅ clear if inbox is empty |
|
||||||
| 15 | n8n Self-Health Monitor | Scheduled (every 15 min) | Checks n8n execution history for failures in the past hour; deduplicates via Postgres; pushes Gotify alert with workflow name and error summary |
|
| 15 | n8n Self-Health Monitor | Scheduled (every 15 min) | Checks n8n execution history for failures in the past hour; deduplicates via Postgres; pushes Gotify alert with workflow name and error summary |
|
||||||
| 16 | NFS Mount Watchdog | Scheduled (every 5 min) | Probes NFS mount points on PD; if missing: runs mount script, re-probes, restarts affected containers (Plex, Audiobookshelf, Immich); notifies via Gotify on heal or escalation |
|
| 16 | NFS Mount Watchdog | Scheduled (every 5 min) | Probes NFS mount points on PD; if missing: runs mount script, re-probes, restarts affected containers (Plex, Audiobookshelf, Immich); notifies via Gotify on heal or escalation |
|
||||||
| 18 | Telegram School Intake → Postgres → Paperless v1.1 | `POST /webhook/school/intake/upload` | Accepts multipart schoolwork uploads plus class/assignment metadata, writes an intake record to shared Postgres, then uploads into Paperless with deterministic `intake_id` in the filename/title |
|
| 18 | Telegram School Intake → Postgres → Paperless v1.2 | `POST /webhook/school/intake/upload` | Accepts multipart schoolwork uploads plus class/assignment metadata, uploads into Paperless with deterministic `intake_id` in the filename/title, and returns the resulting task/document context |
|
||||||
| 19 | Paperless School Intake Metadata Enrichment v1.0 | `POST /webhook/school/intake/paperless-enrich` | Handles Paperless post-consumption webhooks, looks up the intake record by deterministic filename, then applies deterministic title/tags and optional document-type/correspondent mapping |
|
| 19 | Paperless School Intake Metadata Enrichment v1.1 | `POST /webhook/school/intake/paperless-enrich` | Handles Paperless post-consumption webhooks, resolves metadata from deterministic filename/title, then applies deterministic title/tags and optional correspondent mapping |
|
||||||
|
|
||||||
## How to Import
|
## How to Import
|
||||||
|
|
||||||
@@ -289,17 +289,18 @@ curl -X POST https://n8n.paccoco.com/webhook/git/push \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Workflow 18 — Telegram School Intake → Postgres → Paperless v1.1
|
## Workflow 18 — Telegram School Intake → Postgres → Paperless v1.2
|
||||||
|
|
||||||
Import `18-school-paperless-intake-v1.1.json` for a chat-driven schoolwork intake flow. Supporting notes live at `docs/operations/SCHOOL_PAPERLESS_INTAKE.md` and the tracked schema lives at `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`.
|
Import `18-school-paperless-intake-v1.2.json` for a chat-driven schoolwork intake flow. Supporting notes live at `docs/operations/SCHOOL_PAPERLESS_INTAKE.md` and the tracked schema lives at `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`.
|
||||||
|
|
||||||
Highlights:
|
Highlights:
|
||||||
- multipart upload endpoint: `POST /webhook/school/intake/upload`
|
- multipart upload endpoint: `POST /webhook/school/intake/upload`
|
||||||
- required fields: `class_name`, `assignment_name`, `submission_kind`
|
- required fields: `class_name`, `assignment_name`, `submission_kind`
|
||||||
- optional fields: `semester`, `course_code`, `paper_kind`, `notes`, `telegram_chat_id`, `telegram_message_id`
|
- optional fields: `semester`, `course_code`, `paper_kind`, `notes`, `telegram_chat_id`, `telegram_message_id`
|
||||||
- writes intake metadata to shared Postgres before upload
|
|
||||||
- sends deterministic `intake_id` in the Paperless filename and title correlation data
|
- sends deterministic `intake_id` in the Paperless filename and title correlation data
|
||||||
- v1.1 removes the `require('crypto')` dependency so the workflow can run without adding `crypto` to `NODE_FUNCTION_ALLOW_BUILTIN`
|
- returns upload success context immediately from the webhook
|
||||||
|
- v1.1 removed the `require('crypto')` dependency so the workflow can run without adding `crypto` to `NODE_FUNCTION_ALLOW_BUILTIN`
|
||||||
|
- v1.2 fixes the multipart Paperless upload node to use n8n's correct `inputDataFieldName` pattern for binary form uploads
|
||||||
|
|
||||||
Testing fixture:
|
Testing fixture:
|
||||||
- `fixtures/school-paperless-intake-webhook.curl.example.txt`
|
- `fixtures/school-paperless-intake-webhook.curl.example.txt`
|
||||||
|
|||||||
@@ -108,36 +108,35 @@ _Last updated: 2026-05-13_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 18 — Telegram School Intake → Postgres → Paperless v1.1
|
### 18 — Telegram School Intake → Postgres → Paperless v1.2
|
||||||
**What it does:** Accepts multipart school-work upload plus metadata (`class_name`, `assignment_name`, `submission_kind`, optional semester/course_code/paper_kind), records it in shared Postgres, and uploads it into Paperless with a deterministic `intake_id`-based filename.
|
**What it does:** Accepts multipart school-work upload plus metadata (`class_name`, `assignment_name`, `submission_kind`, optional semester/course_code/paper_kind), uploads it into Paperless with a deterministic `intake_id`-based filename/title, and returns webhook success context.
|
||||||
|
|
||||||
**Local artifact checks completed:**
|
**Local artifact checks completed:**
|
||||||
- [x] Workflow JSON validates with `python3 -m json.tool`
|
- [x] Workflow JSON validates with `python3 -m json.tool`
|
||||||
- [x] Companion helper script validates with `node --check school/intake/submit_to_n8n.js`
|
- [x] Companion helper script validates with `node --check school/intake/submit_to_n8n.js`
|
||||||
- [x] Class profile example JSON validates with `python3 -m json.tool`
|
- [x] Class profile example JSON validates with `python3 -m json.tool`
|
||||||
- [x] v1.1 removes the `require('crypto')` dependency for easier live rollout
|
- [x] v1.1 removes the `require('crypto')` dependency for easier live rollout
|
||||||
|
- [x] v1.2 fixes the Paperless multipart upload node to use `inputDataFieldName` for binary form upload
|
||||||
|
- [x] Live-tested 2026-05-14 with `Why am I here` DOCX; Paperless created document `49`
|
||||||
|
|
||||||
**Pre-test checklist:**
|
**Pre-test checklist:**
|
||||||
- [ ] Shared Postgres table `school_paperless_intake` created from `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`
|
- [x] `PAPERLESS_API_TOKEN` exposed to n8n through env vars
|
||||||
- [ ] `PAPERLESS_API_TOKEN` exposed to n8n through env vars
|
- [x] Webhook path `https://n8n.paccoco.com/webhook/school/intake/upload` reachable from Doris/OpenClaw helper
|
||||||
- [ ] Postgres credential `Shared Postgres` created in n8n and wired into workflow 18
|
|
||||||
- [ ] Webhook path `https://n8n.paccoco.com/webhook/school/intake/upload` reachable from Doris/OpenClaw helper
|
|
||||||
|
|
||||||
**Test approach:** POST a small DOCX/PDF through the intake webhook, confirm a row appears in `school_paperless_intake`, and verify the document lands in Paperless with filename prefix `school-...`.
|
**Test approach:** POST a small DOCX/PDF through the intake webhook and verify the document lands in Paperless with filename prefix `school-...` and deterministic title.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 19 — Paperless School Intake Metadata Enrichment v1.0
|
### 19 — Paperless School Intake Metadata Enrichment v1.1
|
||||||
**What it does:** Receives Paperless post-consumption webhook for intake-managed documents, resolves the deterministic `intake_id` from the stored filename, loads the intake record from Postgres, then reapplies deterministic title/tags and optional document type/correspondent mapping.
|
**What it does:** Receives Paperless post-consumption webhook for intake-managed documents, resolves the deterministic `intake_id` from the stored filename, derives metadata from deterministic filename/title, then reapplies deterministic title/tags and optional correspondent mapping.
|
||||||
|
|
||||||
**Local artifact checks completed:**
|
**Local artifact checks completed:**
|
||||||
- [x] Workflow JSON validates with `python3 -m json.tool`
|
- [x] Workflow JSON validates with `python3 -m json.tool`
|
||||||
|
|
||||||
**Pre-test checklist:**
|
**Pre-test checklist:**
|
||||||
- [ ] Paperless webhook configured to POST `https://n8n.paccoco.com/webhook/school/intake/paperless-enrich`
|
- [x] Paperless webhook target configured to POST `https://n8n.paccoco.com/webhook/school/intake/paperless-enrich`
|
||||||
- [ ] Workflow 18 already working so intake-managed filenames exist in Paperless
|
- [x] Workflow 18 working so intake-managed filenames exist in Paperless
|
||||||
- [ ] `PAPERLESS_API_TOKEN`, `PAPERLESS_TAG_SCHOOL`, `PAPERLESS_TAG_ASSIGNMENTS`, and `PAPERLESS_TAG_TELEGRAM` exposed to n8n
|
- [x] `PAPERLESS_API_TOKEN` exposed to n8n
|
||||||
- [ ] Optional mapping env vars supplied when ready: `PAPERLESS_TAG_CLASS_MAP_JSON`, `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON`, `PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON`, `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON`
|
- [ ] Optional mapping env vars supplied when ready: `PAPERLESS_TAG_SCHOOL`, `PAPERLESS_TAG_ASSIGNMENTS`, `PAPERLESS_TAG_TELEGRAM`, `PAPERLESS_TAG_CLASS_MAP_JSON`, `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON`, `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON`
|
||||||
- [ ] Postgres credential `Shared Postgres` created in n8n and wired into workflow 19
|
|
||||||
|
|
||||||
**Test approach:** After a workflow-18 upload is consumed by Paperless, POST `{"document_id": <id>}` to `/webhook/school/intake/paperless-enrich` and verify title/tags are updated and the DB row status becomes `enriched`.
|
**Test approach:** After a workflow-18 upload is consumed by Paperless, POST `{"document_id": <id>}` to `/webhook/school/intake/paperless-enrich` and verify title/tags enrichment succeeds. Live-tested on document `49`.
|
||||||
|
|||||||
Reference in New Issue
Block a user