From 2f47e7eb6ca7872c32344f3fbfa130effb940235 Mon Sep 17 00:00:00 2001 From: n3wk Date: Wed, 14 Oct 2026 23:17:40 +0200 Subject: [PATCH] Zammad API nodes for Node-RED --- .gitignore | 36 +++++++ Dockerfile | 9 ++ README.md | 170 +++++++++++++++++++++++++++++++ bootstrap-install.sh | 50 +++++++++ docker-compose.yml | 12 +++ entrypoint.sh | 34 +++++++ flows/woocommerce-to-zammad.json | 57 +++++++++++ integrate-into-nodered.sh | 140 +++++++++++++++++++++++++ nodes/package.json | 23 +++++ nodes/zammad-api.html | 58 +++++++++++ nodes/zammad-api.js | 44 ++++++++ nodes/zammad-config.html | 67 ++++++++++++ nodes/zammad-config.js | 59 +++++++++++ nodes/zammad-organization.html | 54 ++++++++++ nodes/zammad-organization.js | 83 +++++++++++++++ nodes/zammad-ticket.html | 79 ++++++++++++++ nodes/zammad-ticket.js | 117 +++++++++++++++++++++ nodes/zammad-user.html | 63 ++++++++++++ nodes/zammad-user.js | 88 ++++++++++++++++ 19 files changed, 1243 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bootstrap-install.sh create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 flows/woocommerce-to-zammad.json create mode 100644 integrate-into-nodered.sh create mode 100644 nodes/package.json create mode 100644 nodes/zammad-api.html create mode 100644 nodes/zammad-api.js create mode 100644 nodes/zammad-config.html create mode 100644 nodes/zammad-config.js create mode 100644 nodes/zammad-organization.html create mode 100644 nodes/zammad-organization.js create mode 100644 nodes/zammad-ticket.html create mode 100644 nodes/zammad-ticket.js create mode 100644 nodes/zammad-user.html create mode 100644 nodes/zammad-user.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ade3d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Secrets & credentials +.env +.env.* +*.pem +*.key +credentials* +credentials.json +token* +secrets* + +# Dependencies +node_modules/ + +# Build artifacts +dist/ +build/ +*.log + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Docker +.dockerignore + +# Temp +tmp/ +temp/ +*.tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b3a9c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM nodered/node-red:latest + +USER root + +COPY nodes/ /opt/nextcloud-nodes/node-red-contrib-nextcloud-zammad +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5ed064 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# nextcloud-nodered-zammad + +Node-RED nodes for the [Zammad](https://zammad.org/) helpdesk API. Part of the +[nextcloud-nodered](https://kode.hannesahlin.se/n3wk/) suite. + +## Package + +`node-red-contrib-nextcloud-zammad` + +## Nodes + +| Node | Type | Purpose | +|------|------|---------| +| `zammad-config` | config | Base URL + Token (or Basic auth) | +| `zammad-api` | function | Generic HTTP caller — any method + endpoint | +| `zammad-ticket` | function | list, search, get, create, update, articles, addArticle, tags, addTag | +| `zammad-user` | function | me, list, search, get, create | +| `zammad-organization` | function | list, search, get, create | + +## Installation + +### Prerequisites + +- An existing [nextcloud-node-red](https://kode.hannesahlin.se/n3wk/nextcloud-nodered-ocs-api) installation. +- A Zammad API token with `ticket.agent` permission, created while logged into `support.hanssonit.se`. + +### Quick install (recommended) + +```bash +git clone https://github.com/kode-tjenester/nextcloud-nodered-zammad.git +cd nextcloud-nodered-zammad +export NODERED_DIR=/path/to/your/nextcloud-node-red +bash bootstrap-install.sh +cd $NODERED_DIR +docker compose build --no-cache +docker compose up -d +``` + +### Manual install + +```bash +export NODERED_DIR=/path/to/your/nextcloud-node-red +bash integrate-into-nodered.sh +``` + +The integrate script: +1. Copies all `.js` and `.html` files into `$NODERED_DIR/nodes/`. +2. Copies the package to `$NODERED_DIR/opt/nextcloud-nodes/node-red-contrib-nextcloud-zammad/`. +3. Patches `$NODERED_DIR/entrypoint.sh` to install the nodes on container start. +4. Is idempotent — safe to run multiple times. + +## Authentication + +| Method | Header | Notes | +|--------|--------|-------| +| Token (preferred) | `Authorization: Token token=` | Create token on support.hanssonit.se with `ticket.agent` permission | +| Basic | `Authorization: Basic base64(user:pass)` | Username is typically just `Hannes` (not full email) | + +**Important:** Base URL must be `https://support.hanssonit.se/api/v1/`. +Using `zammad.kafit.se` or other hosts will result in authentication errors. + +## Node Details + +### zammad-ticket + +Operations: `list`, `search`, `get`, `create`, `update`, `articles`, `addArticle`, `tags`, `addTag`. + +**Create from individual properties:** +``` +msg.title = "Order #31921 – Lars Gardelin (LRG Invest AB)" +msg.customer = "lars@lrginvest.se" +msg.body = product list + address + phone +msg.group = "Orders" +msg.state = "open" +msg.priority = "2 normal" +``` + +**Create from full JSON payload:** +``` +msg.payload = { title: "...", customer: "...", body: "...", group: "..." } +``` + +**Search syntax (Zammad query):** +``` +state.name:open +title:order +customer.email:foo@example.com +``` + +### zammad-api + +Generic HTTP caller. Override config via: +- `msg.method` — GET, POST, PUT, DELETE +- `msg.endpoint` — `/tickets`, `/users/search`, etc. +- `msg.payload` — request body +- `msg.headers` — additional headers + +## Vertical Integration Flow: WooCommerce → Zammad → Nextcloud + +This example creates a Zammad ticket from a WooCommerce order and organizes +the customer in Nextcloud. + +```json +[ + { + "id": "wc-trigger", + "type": "inject", + "name": "New Order", + "topic": "", + "payload": "", + "payloadType": "date", + "repeat": "", + "crontab": "", + "once": true, + "x": 150, "y": 100 + }, + { + "id": "wc-extract", + "type": "function", + "name": "Extract Order Info", + "func": "msg.title = 'Order #' + msg.payload.number + ' – ' + msg.payload.billing.first_name + ' ' + msg.payload.billing.last_name;\nmsg.customer = msg.payload.billing.email;\nmsg.body = 'Products:\\n';\nfor (let item of msg.payload.line_items) {\n msg.body += '- ' + item.name + ' x' + item.quantity + ' (' + msg.payload.currency + ' ' + item.total + ')\\n';\n}\nmsg.body += '\\nTotal: ' + msg.payload.currency + ' ' + msg.payload.total + '\\n';\nmsg.body += 'Address: ' + msg.payload.billing.address_1 + ', ' + msg.payload.billing.city + ' ' + msg.payload.billing.postcode + '\\n';\nmsg.body += 'Phone: ' + msg.payload.billing.phone;\nreturn msg;", + "outputs": 1, + "x": 350, "y": 100 + }, + { + "id": "zammad-create", + "type": "zammad-ticket", + "name": "Create Ticket", + "config": "zammad-config-id", + "operation": "create", + "x": 550, "y": 100 + }, + { + "id": "zammad-tag", + "type": "zammad-ticket", + "name": "Add WooCommerce Tag", + "config": "zammad-config-id", + "operation": "addTag", + "x": 750, "y": 100 + } +] +``` + +**Full flow with Nextcloud OCS nodes** (after Zammad ticket creation): + +1. `woocommerce-order` (operation: list, status=processing) → trigger +2. Function node extracts `billing.email`, `billing.first_name`, `billing.last_name`, `billing.company`, `number`, `total`, `currency`, `line_items` +3. `zammad-ticket` (operation: create) — builds ticket from extracted data +4. `zammad-ticket` (operation: addTag) — tag `woocommerce-order` +5. Existing Nextcloud OCS nodes create/update: + - Customer folder + - Note or Deck card + - Optional calendar event + - Link back to Zammad ticket in ticket body or Nextcloud note + +## Integration with Other Packages + +- **nextcloud-nodered-woocommerce**: Polls WooCommerce API for new orders and triggers this flow. +- **nextcloud-nodered-ocs-api**: Provides Nextcloud OCS nodes for creating folders, notes, deck cards, etc. +- **nextcloud-nodered-agent**: Agent-based nodes for complex orchestration. + +## Credentials Security + +- Tokens and passwords are stored **only** in the Node-RED config node (encrypted at rest). +- Never commit credentials to git (`.gitignore` excludes them). +- The config node uses Node-RED's built-in credential encryption. + +## License + +MIT diff --git a/bootstrap-install.sh b/bootstrap-install.sh new file mode 100644 index 0000000..78cca04 --- /dev/null +++ b/bootstrap-install.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Bootstrap installer for node-red-contrib-nextcloud-zammad +# Clones repo and integrates into existing nextcloud-node-red + +REPO_URL="https://kode.hannesahlin.se/n3wk/nextcloud-nodered-zammad.git" +INSTALL_DIR="${INSTALL_DIR:-/opt/node-red-contrib-nextcloud-zammad}" +NODERED_DIR="${NODERED_DIR:-}" + +echo "=== Nextcloud Zammad Node-RED Bootstrap Installer ===" + +# Auto-detect nextcloud-node-red directory if not set +if [[ -z "$NODERED_DIR" ]]; then + for dir in \ + "/opt/nextcloud-node-red" \ + "/home/ncdeploy/nextcloud-node-red" \ + "/root/nextcloud-node-red" \ + "$(docker inspect -f '{{.Config.Labels}}' nextcloud-node-red 2>/dev/null | grep -o 'com.docker.compose.project.working_dir=[^ ]*' | cut -d= -f2)"; do + if [[ -n "$dir" && -d "$dir" && -f "$dir/Dockerfile" ]]; then + NODERED_DIR="$dir" + break + fi + done +fi + +if [[ -z "$NODERED_DIR" ]]; then + echo "ERROR: Could not auto-detect nextcloud-node-red directory." + echo "Set NODERED_DIR environment variable." + exit 1 +fi + +echo "Target Node-RED: $NODERED_DIR" + +# Clone or update repo +if [[ -d "$INSTALL_DIR/.git" ]]; then + echo "Updating existing repo..." + git -C "$INSTALL_DIR" pull --ff-only || echo "Pull failed, continuing with existing code..." +else + echo "Cloning repo..." + git clone --depth 1 "$REPO_URL" "$INSTALL_DIR" +fi + +# Run integration +export NODERED_DIR +"$INSTALL_DIR/integrate-into-nodered.sh" + +echo "" +echo "=== Bootstrap Complete ===" +echo "Rebuild with: cd $NODERED_DIR && docker compose build --no-cache && docker compose up -d" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..848323e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + node-red: + build: . + image: nextcloud-node-red-zammad:latest + container_name: nextcloud-node-red-zammad + restart: unless-stopped + network_mode: host + volumes: + - node-red-data:/data + +volumes: + node-red-data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..7a58520 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Install zammad nodes into /data at runtime. +# /data is a Docker volume that masks build-time COPY. + +set -eu + +SRC="/opt/nextcloud-nodes/node-red-contrib-nextcloud-zammad" +DST_DIR="/data/node_modules" +DST="$DST_DIR/node-red-contrib-nextcloud-zammad" + +if [ ! -d "$SRC" ]; then + echo "[entrypoint] ERROR: source directory missing: $SRC" >&2 + ls -la /opt || true + ls -la /opt/nextcloud-nodes || true + exit 1 +fi + +mkdir -p "$DST_DIR" +rm -rf "$DST" +cp -a "$SRC" "$DST_DIR/" + +if [ ! -f "$DST/package.json" ]; then + echo "[entrypoint] ERROR: install did not produce $DST/package.json" >&2 + ls -la /data || true + ls -la "$DST_DIR" || true + exit 1 +fi + +chown -R node-red:node-red /data "$DST_DIR" "$DST" || true +chmod -R a+rX "$DST" || true + +export NODE_RED_USER_DIR=/data + +exec node-red --userDir /data diff --git a/flows/woocommerce-to-zammad.json b/flows/woocommerce-to-zammad.json new file mode 100644 index 0000000..14368d9 --- /dev/null +++ b/flows/woocommerce-to-zammad.json @@ -0,0 +1,57 @@ +[ + { + "id": "wc-poll-trigger", + "type": "inject", + "name": "Poll Processing Orders", + "topic": "woocommerce/poll", + "payload": "", + "payloadType": "date", + "repeat": "900", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "x": 150, + "y": 100, + "wires": [["wc-list-orders"]] + }, + { + "id": "wc-list-orders", + "type": "woocommerce-order", + "name": "List Processing Orders", + "config": "woocommerce-config-placeholder", + "operation": "list", + "x": 350, + "y": 100, + "wires": [["wc-extract-info"]] + }, + { + "id": "wc-extract-info", + "type": "function", + "name": "Extract Order Info", + "func": "var order = Array.isArray(msg.payload) ? msg.payload[0] : msg.payload;\nif (!order || !order.billing) return null;\nmsg.title = 'Order #' + order.number + ' – ' + order.billing.first_name + ' ' + order.billing.last_name;\nif (order.billing.company) msg.title += ' (' + order.billing.company + ')';\nmsg.customer = order.billing.email;\nmsg.body = 'Products:\\n';\nif (order.line_items) {\n for (var i = 0; i < order.line_items.length; i++) {\n var item = order.line_items[i];\n msg.body += '- ' + item.name + ' x' + item.quantity + ' (' + order.currency + ' ' + item.total + ')\\n';\n }\n}\nmsg.body += '\\nTotal: ' + order.currency + ' ' + order.total;\nmsg.body += '\\nAddress: ' + order.billing.address_1 + ', ' + order.billing.city + ' ' + order.billing.postcode;\nmsg.body += '\\nPhone: ' + order.billing.phone;\nmsg.body += '\\nEmail: ' + order.billing.email;\nmsg.ticketId = order.id;\nmsg.tag = 'woocommerce-order';\nreturn msg;", + "outputs": 1, + "x": 550, + "y": 100, + "wires": [["zammad-create-ticket"]] + }, + { + "id": "zammad-create-ticket", + "type": "zammad-ticket", + "name": "Create Ticket", + "config": "zammad-config-placeholder", + "operation": "create", + "x": 750, + "y": 100, + "wires": [["zammad-add-tag"]] + }, + { + "id": "zammad-add-tag", + "type": "zammad-ticket", + "name": "Add WooCommerce Tag", + "config": "zammad-config-placeholder", + "operation": "addTag", + "x": 950, + "y": 100, + "wires": [[]] + } +] diff --git a/integrate-into-nodered.sh b/integrate-into-nodered.sh new file mode 100644 index 0000000..092cabb --- /dev/null +++ b/integrate-into-nodered.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Integration script for node-red-contrib-nextcloud-zammad +# Patches zammad nodes into existing nextcloud-node-red Docker image + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_NAME="node-red-contrib-nextcloud-zammad" + +# Auto-detect Node-RED directory +if [[ -z "${NODERED_DIR:-}" ]]; then + for dir in \ + "/opt/nextcloud-node-red" \ + "/home/ncdeploy/nextcloud-node-red" \ + "/root/nextcloud-node-red" \ + "$(dirname "$SCRIPT_DIR")/nextcloud-node-red" \ + "$(docker inspect -f '{{.Config.Labels}}' nextcloud-node-red 2>/dev/null | grep -o 'com.docker.compose.project.working_dir=[^ ]*' | cut -d= -f2)"; do + if [[ -n "$dir" && -d "$dir" && -f "$dir/Dockerfile" ]]; then + NODERED_DIR="$dir" + break + fi + done +fi + +if [[ -z "${NODERED_DIR:-}" ]] || [[ ! -d "$NODERED_DIR" ]]; then + echo "ERROR: Could not auto-detect nextcloud-node-red directory." + echo "Set NODERED_DIR environment variable." + exit 1 +fi + +if [[ ! -f "$NODERED_DIR/Dockerfile" ]]; then + echo "ERROR: No Dockerfile found in $NODERED_DIR" + exit 1 +fi + +echo "=== Nextcloud Zammad Node-RED Integration ===" +echo "Source: $SCRIPT_DIR" +echo "Target: $NODERED_DIR" + +# 1. Copy nodes to nextcloud-node-red nodes subdirectory +NODES_TARGET="$NODERED_DIR/nodes/$PACKAGE_NAME" +echo "[integrate] Copying zammad nodes to $NODES_TARGET..." +mkdir -p "$NODES_TARGET" +cp "$SCRIPT_DIR/nodes"/*.js "$NODES_TARGET/" +cp "$SCRIPT_DIR/nodes"/*.html "$NODES_TARGET/" +cp "$SCRIPT_DIR/nodes/package.json" "$NODES_TARGET/" +echo " Nodes copied." + +# 2. Patch Dockerfile to COPY zammad nodes into /opt +DOCKERFILE="$NODERED_DIR/Dockerfile" +echo "[integrate] Patching Dockerfile..." + +if grep -q "$PACKAGE_NAME" "$DOCKERFILE"; then + echo " Dockerfile already contains $PACKAGE_NAME" +else + cp "$DOCKERFILE" "$DOCKERFILE.bak.$(date +%s)" + + sed -i '/COPY nodes\//a\ +COPY nodes/ '"$PACKAGE_NAME"' /opt/nextcloud-nodes/'"$PACKAGE_NAME"'\ +' "$DOCKERFILE" + + echo " Dockerfile patched with COPY for $PACKAGE_NAME." +fi + +# 3. Patch entrypoint.sh to install zammad nodes at container start +ENTRYPOINT="$NODERED_DIR/entrypoint.sh" +echo "[integrate] Patching entrypoint.sh..." + +if [[ -f "$ENTRYPOINT" ]]; then + cp "$ENTRYPOINT" "$ENTRYPOINT.bak.$(date +%s)" + + if ! grep -q "$PACKAGE_NAME" "$ENTRYPOINT"; then + cat >> "$ENTRYPOINT" << 'PATCH_EOF' + +# --- node-red-contrib-nextcloud-zammad --- +SRC_ZAMMAD="/opt/nextcloud-nodes/node-red-contrib-nextcloud-zammad" +DST_ZAMMAD="/data/node_modules/node-red-contrib-nextcloud-zammad" +if [ -d "$SRC_ZAMMAD" ]; then + echo "[entrypoint] Installing zammad nodes into /data..." + mkdir -p /data/node_modules + rm -rf "$DST_ZAMMAD" + cp -a "$SRC_ZAMMAD" /data/node_modules/ + chown -R node-red:node-red "$DST_ZAMMAD" 2>/dev/null || true + chmod -R a+rX "$DST_ZAMMAD" 2>/dev/null || true + echo "[entrypoint] Zammad nodes installed." +fi +PATCH_EOF + echo " entrypoint.sh patched." + else + echo " entrypoint.sh already contains $PACKAGE_NAME." + fi +else + echo " WARNING: entrypoint.sh not found at $ENTRYPOINT" +fi + +# 4. Create zammad nodes package in /opt for entrypoint to copy +OPT_NODES_DIR="$NODERED_DIR/opt/nextcloud-nodes" +echo "[integrate] Creating zammad nodes package in $OPT_NODES_DIR..." +mkdir -p "$OPT_NODES_DIR/$PACKAGE_NAME/nodes" +cp "$SCRIPT_DIR/nodes"/*.js "$OPT_NODES_DIR/$PACKAGE_NAME/nodes/" +cp "$SCRIPT_DIR/nodes"/*.html "$OPT_NODES_DIR/$PACKAGE_NAME/nodes/" +cp "$SCRIPT_DIR/nodes/package.json" "$OPT_NODES_DIR/$PACKAGE_NAME/package.json" +echo " Package created." + +# 5. Update docker-compose.yml to mount /opt/nextcloud-nodes +COMPOSE="$NODERED_DIR/docker-compose.yml" +if [[ -f "$COMPOSE" ]]; then + echo "[integrate] Checking docker-compose.yml for zammad volume..." + if ! grep -q "nextcloud-nodes" "$COMPOSE"; then + cp "$COMPOSE" "$COMPOSE.bak.$(date +%s)" + + # Add volume mount under services.node-red.volumes + sed -i '/- node-red-data:\/data/a\ + - nextcloud-nodes:/opt/nextcloud-nodes' "$COMPOSE" + + # Add named volume under top-level volumes + sed -i '/^volumes:/a\ + nextcloud-nodes:' "$COMPOSE" + + echo " docker-compose.yml updated with nextcloud-nodes volume." + else + echo " docker-compose.yml already has nextcloud-nodes volume." + fi +else + echo " WARNING: docker-compose.yml not found at $COMPOSE" +fi + +echo "" +echo "=== Integration Complete ===" +echo "" +echo "Next steps:" +echo "1. Rebuild the Docker image:" +echo " cd $NODERED_DIR && docker compose build --no-cache" +echo "" +echo "2. Restart the container:" +echo " docker compose up -d" +echo "" +echo "3. In Node-RED, configure a 'zammad config' node with your API token." +echo "" +echo "4. Import example flows from: $SCRIPT_DIR/flows/" diff --git a/nodes/package.json b/nodes/package.json new file mode 100644 index 0000000..e105dbe --- /dev/null +++ b/nodes/package.json @@ -0,0 +1,23 @@ +{ + "name": "node-red-contrib-nextcloud-zammad", + "version": "1.0.0", + "description": "Node-RED nodes for Zammad API integration", + "keywords": ["node-red", "zammad", "ticketing", "support"], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://kode.hannesahlin.se/n3wk/nextcloud-nodered-zammad" + }, + "node-red": { + "nodes": { + "zammad-config": "zammad-config.js", + "zammad-api": "zammad-api.js", + "zammad-ticket": "zammad-ticket.js", + "zammad-user": "zammad-user.js", + "zammad-organization": "zammad-organization.js" + } + }, + "engines": { + "node": ">=14.0.0" + } +} diff --git a/nodes/zammad-api.html b/nodes/zammad-api.html new file mode 100644 index 0000000..e69c5a1 --- /dev/null +++ b/nodes/zammad-api.html @@ -0,0 +1,58 @@ + + + + + diff --git a/nodes/zammad-api.js b/nodes/zammad-api.js new file mode 100644 index 0000000..4e5dc3a --- /dev/null +++ b/nodes/zammad-api.js @@ -0,0 +1,44 @@ +module.exports = function (RED) { + "use strict"; + + function ZammadApiNode(config) { + RED.nodes.createNode(this, config); + const node = this; + + node.configNode = RED.nodes.getNode(config.config); + if (!node.configNode) { + node.error("No zammad-config node configured"); + return; + } + + node.status({ fill: "blue", shape: "ring", text: "ready" }); + + node.on("input", function (msg, send, done) { + const method = (msg.method || "GET").toUpperCase(); + const endpoint = msg.endpoint || msg.path || "/"; + const body = msg.payload !== undefined ? msg.payload : msg.body; + const extraHeaders = msg.headers || {}; + + node.status({ fill: "yellow", shape: "dot", text: method + " " + endpoint }); + + node.configNode.sendRequest(method, endpoint, body, extraHeaders, function (err, result) { + if (err) { + node.status({ fill: "red", shape: "ring", text: "error" }); + if (done) done(err); + else node.error(err, msg); + return; + } + + msg.payload = result.body; + msg.statusCode = result.statusCode; + msg.zammad = result; + + node.status({ fill: "green", shape: "dot", text: result.statusCode }); + send(msg); + if (done) done(); + }); + }); + } + + RED.nodes.registerType("zammad-api", ZammadApiNode); +}; diff --git a/nodes/zammad-config.html b/nodes/zammad-config.html new file mode 100644 index 0000000..3f68cfc --- /dev/null +++ b/nodes/zammad-config.html @@ -0,0 +1,67 @@ + + + + + diff --git a/nodes/zammad-config.js b/nodes/zammad-config.js new file mode 100644 index 0000000..406ae73 --- /dev/null +++ b/nodes/zammad-config.js @@ -0,0 +1,59 @@ +module.exports = function (RED) { + "use strict"; + + const https = require("https"); + const http = require("http"); + + function ZammadConfigNode(config) { + RED.nodes.createNode(this, config); + const node = this; + + node.baseUrl = (config.baseUrl || "https://support.hanssonit.se/api/v1").replace(/\/+$/, ""); + node.useToken = config.useToken !== false; + node.token = config.token || ""; + node.username = config.username || ""; + node.password = config.password || ""; + + node.sendRequest = function (method, path, body, msgHeaders, callback) { + const url = new URL(node.baseUrl + path); + const parsedBody = body !== undefined ? (typeof body === "string" ? body : JSON.stringify(body)) : undefined; + + const headers = Object.assign({ "Accept": "application/json", "Content-Type": "application/json" }, msgHeaders || {}); + if (parsedBody !== undefined) headers["Content-Length"] = Buffer.byteLength(parsedBody); + + if (node.useToken && node.token) { + headers["Authorization"] = "Token token=" + node.token; + } else if (node.username && node.password) { + headers["Authorization"] = "Basic " + Buffer.from(node.username + ":" + node.password).toString("base64"); + } + + const options = { + method, hostname: url.hostname, + port: url.port || (url.protocol === "https:" ? 443 : 80), + path: url.pathname + url.search, headers, rejectUnauthorized: false + }; + + const req = (url.protocol === "https:" ? https : http).request(options, function (res) { + let data = ""; + res.on("data", chunk => data += chunk); + res.on("end", () => { + let parsed = null; + if (data) { try { parsed = JSON.parse(data); } catch (e) { parsed = data; } } + if (res.statusCode >= 200 && res.statusCode < 300) { + callback(null, { statusCode: res.statusCode, body: parsed, headers: res.headers }); + } else { + const err = new Error("Zammad API returned status " + res.statusCode + ": " + data); + err.statusCode = res.statusCode; + err.body = parsed; + callback(err); + } + }); + }); + req.on("error", callback); + if (parsedBody !== undefined) req.write(parsedBody); + req.end(); + }; + } + + RED.nodes.registerType("zammad-config", ZammadConfigNode); +}; diff --git a/nodes/zammad-organization.html b/nodes/zammad-organization.html new file mode 100644 index 0000000..6580ae0 --- /dev/null +++ b/nodes/zammad-organization.html @@ -0,0 +1,54 @@ + + + + + diff --git a/nodes/zammad-organization.js b/nodes/zammad-organization.js new file mode 100644 index 0000000..870b944 --- /dev/null +++ b/nodes/zammad-organization.js @@ -0,0 +1,83 @@ +module.exports = function (RED) { + "use strict"; + + function ZammadOrganizationNode(config) { + RED.nodes.createNode(this, config); + const node = this; + + node.configNode = RED.nodes.getNode(config.config); + if (!node.configNode) { + node.error("No zammad-config node configured"); + return; + } + + node.operation = config.operation || "list"; + + node.status({ fill: "blue", shape: "ring", text: "ready" }); + + function doRequest(method, path, body, msg, done) { + node.status({ fill: "yellow", shape: "dot", text: method + " " + path }); + node.configNode.sendRequest(method, path, body, {}, function (err, result) { + if (err) { + node.status({ fill: "red", shape: "ring", text: "error" }); + if (done) done(err); + else node.error(err, msg); + return; + } + msg.payload = result.body; + msg.statusCode = result.statusCode; + msg.zammad = result; + node.status({ fill: "green", shape: "dot", text: result.statusCode }); + done(); + }); + } + + node.on("input", function (msg, send, done) { + const op = msg.operation || node.operation; + + switch (op) { + case "list": { + const params = new URLSearchParams(); + if (msg.limit) params.append("per_page", msg.limit); + if (msg.page) params.append("page", msg.page); + const qs = params.toString(); + doRequest("GET", "/organizations" + (qs ? "?" + qs : ""), undefined, msg, done); + break; + } + case "search": { + const searchParams = new URLSearchParams(); + if (msg.query) searchParams.append("query", msg.query); + if (msg.limit) searchParams.append("per_page", msg.limit); + const qs = searchParams.toString(); + doRequest("GET", "/organizations/search" + (qs ? "?" + qs : ""), undefined, msg, done); + break; + } + case "get": { + const id = msg.organizationId || msg.payload; + if (!id) { node.error("Missing organizationId"); if (done) done(new Error("Missing organizationId")); return; } + doRequest("GET", "/organizations/" + encodeURIComponent(id), undefined, msg, done); + break; + } + case "create": { + const body = msg.payload && typeof msg.payload === "object" ? msg.payload : { + name: msg.name || "", + note: msg.note || "", + street: msg.street || "", + zip: msg.zip || "", + city: msg.city || "", + country: msg.country || "" + }; + doRequest("POST", "/organizations", body, msg, done); + break; + } + default: { + const err = new Error("Unknown operation: " + op); + node.error(err); + if (done) done(err); + } + } + }); + } + + RED.nodes.registerType("zammad-organization", ZammadOrganizationNode); +}; diff --git a/nodes/zammad-ticket.html b/nodes/zammad-ticket.html new file mode 100644 index 0000000..a28cb7e --- /dev/null +++ b/nodes/zammad-ticket.html @@ -0,0 +1,79 @@ + + + + + diff --git a/nodes/zammad-ticket.js b/nodes/zammad-ticket.js new file mode 100644 index 0000000..e7a4b92 --- /dev/null +++ b/nodes/zammad-ticket.js @@ -0,0 +1,117 @@ +module.exports = function (RED) { + "use strict"; + + function ZammadTicketNode(config) { + RED.nodes.createNode(this, config); + const node = this; + + node.configNode = RED.nodes.getNode(config.config); + if (!node.configNode) { node.error("No zammad-config node configured"); return; } + + node.operation = config.operation || "list"; + node.status({ fill: "blue", shape: "ring", text: "ready" }); + + function doRequest(method, path, body, msg, done) { + node.status({ fill: "yellow", shape: "dot", text: method + " " + path }); + node.configNode.sendRequest(method, path, body, {}, function (err, result) { + if (err) { node.status({ fill: "red", shape: "ring", text: "error" }); done ? done(err) : node.error(err, msg); return; } + msg.payload = result.body; + msg.statusCode = result.statusCode; + msg.zammad = result; + node.status({ fill: "green", shape: "dot", text: result.statusCode }); + done(); + }); + } + + function buildTicketPayload(msg) { + if (msg.payload && typeof msg.payload === "object" && msg.payload.title) return msg.payload; + const p = {}; + if (msg.title !== undefined) p.title = msg.title; + if (msg.customer !== undefined) p.customer = msg.customer; + if (msg.group !== undefined) p.group = msg.group; + if (msg.state !== undefined) p.state = msg.state; + if (msg.priority !== undefined) p.priority = msg.priority; + if (msg.body !== undefined || msg.articleBody !== undefined) { + p.article = { body: msg.body || msg.articleBody, from: msg.from || "System", to: msg.to || "", sender: msg.sender || "Agent", type: msg.articleType || "note" }; + } + return p; + } + + node.on("input", function (msg, send, done) { + const op = msg.operation || node.operation; + switch (op) { + case "list": { + const p = new URLSearchParams(); + if (msg.query) p.append("query", msg.query); + if (msg.limit) p.append("per_page", msg.limit); + if (msg.page) p.append("page", msg.page); + const qs = p.toString(); + doRequest("GET", "/tickets" + (qs ? "?" + qs : ""), undefined, msg, done); + break; + } + case "search": { + const p = new URLSearchParams(); + if (msg.query) p.append("query", msg.query); + if (msg.limit) p.append("per_page", msg.limit); + const qs = p.toString(); + doRequest("GET", "/tickets/search" + (qs ? "?" + qs : ""), undefined, msg, done); + break; + } + case "get": { + const id = msg.ticketId || msg.payload; + if (!id) { node.error("Missing ticketId"); done && done(new Error("Missing ticketId")); return; } + doRequest("GET", "/tickets/" + encodeURIComponent(id), undefined, msg, done); + break; + } + case "create": { + doRequest("POST", "/tickets", buildTicketPayload(msg), msg, done); + break; + } + case "update": { + const id = msg.ticketId || (msg.payload && msg.payload.id); + if (!id) { node.error("Missing ticketId"); done && done(new Error("Missing ticketId")); return; } + doRequest("PUT", "/tickets/" + encodeURIComponent(id), + msg.payload && typeof msg.payload === "object" && msg.payload.title ? msg.payload : buildTicketPayload(msg), msg, done); + break; + } + case "articles": { + const id = msg.ticketId || (msg.payload && msg.payload.id); + if (!id) { node.error("Missing ticketId"); done && done(new Error("Missing ticketId")); return; } + doRequest("GET", "/ticket_articles/by_ticket/" + encodeURIComponent(id), undefined, msg, done); + break; + } + case "addArticle": { + const id = msg.ticketId || (msg.payload && msg.payload.id); + if (!id) { node.error("Missing ticketId"); done && done(new Error("Missing ticketId")); return; } + const sender = msg.sender || "Agent"; + doRequest("POST", "/ticket_articles", { + body: msg.articleBody || msg.body || (msg.payload && msg.payload.body) || "", + from: msg.from || sender, sender, type: msg.articleType || "note", + internal: msg.internal !== undefined ? msg.internal : false, contentType: "text/plain" + }, msg, done); + break; + } + case "tags": { + const id = msg.ticketId || (msg.payload && msg.payload.id); + if (!id) { node.error("Missing ticketId"); done && done(new Error("Missing ticketId")); return; } + doRequest("GET", "/tickets/" + encodeURIComponent(id) + "/tags", undefined, msg, done); + break; + } + case "addTag": { + const id = msg.ticketId || (msg.payload && msg.payload.id); + const tag = msg.tag || msg.tagName; + if (!id || !tag) { node.error("Missing ticketId or tag"); done && done(new Error("Missing ticketId or tag")); return; } + doRequest("POST", "/tags/add", { tag, object: "Ticket", o_id: id }, msg, done); + break; + } + default: { + const err = new Error("Unknown operation: " + op); + node.error(err); + if (done) done(err); + } + } + }); + } + + RED.nodes.registerType("zammad-ticket", ZammadTicketNode); +}; diff --git a/nodes/zammad-user.html b/nodes/zammad-user.html new file mode 100644 index 0000000..80ad5b2 --- /dev/null +++ b/nodes/zammad-user.html @@ -0,0 +1,63 @@ + + + + + diff --git a/nodes/zammad-user.js b/nodes/zammad-user.js new file mode 100644 index 0000000..1cc63c5 --- /dev/null +++ b/nodes/zammad-user.js @@ -0,0 +1,88 @@ +module.exports = function (RED) { + "use strict"; + + function ZammadUserNode(config) { + RED.nodes.createNode(this, config); + const node = this; + + node.configNode = RED.nodes.getNode(config.config); + if (!node.configNode) { + node.error("No zammad-config node configured"); + return; + } + + node.operation = config.operation || "list"; + + node.status({ fill: "blue", shape: "ring", text: "ready" }); + + function doRequest(method, path, body, msg, done) { + node.status({ fill: "yellow", shape: "dot", text: method + " " + path }); + node.configNode.sendRequest(method, path, body, {}, function (err, result) { + if (err) { + node.status({ fill: "red", shape: "ring", text: "error" }); + if (done) done(err); + else node.error(err, msg); + return; + } + msg.payload = result.body; + msg.statusCode = result.statusCode; + msg.zammad = result; + node.status({ fill: "green", shape: "dot", text: result.statusCode }); + done(); + }); + } + + node.on("input", function (msg, send, done) { + const op = msg.operation || node.operation; + + switch (op) { + case "me": { + doRequest("GET", "/users/me", undefined, msg, done); + break; + } + case "list": { + const params = new URLSearchParams(); + if (msg.limit) params.append("per_page", msg.limit); + if (msg.page) params.append("page", msg.page); + const qs = params.toString(); + doRequest("GET", "/users" + (qs ? "?" + qs : ""), undefined, msg, done); + break; + } + case "search": { + const searchParams = new URLSearchParams(); + if (msg.query) searchParams.append("query", msg.query); + if (msg.limit) searchParams.append("per_page", msg.limit); + const qs = searchParams.toString(); + doRequest("GET", "/users/search" + (qs ? "?" + qs : ""), undefined, msg, done); + break; + } + case "get": { + const id = msg.userId || msg.payload; + if (!id) { node.error("Missing userId"); if (done) done(new Error("Missing userId")); return; } + doRequest("GET", "/users/" + encodeURIComponent(id), undefined, msg, done); + break; + } + case "create": { + const body = msg.payload && typeof msg.payload === "object" ? msg.payload : { + firstname: msg.firstname || "", + lastname: msg.lastname || "", + email: msg.email || "", + phone: msg.phone || "", + customer: msg.customer || "", + group: msg.group || "", + note: msg.note || "" + }; + doRequest("POST", "/users", body, msg, done); + break; + } + default: { + const err = new Error("Unknown operation: " + op); + node.error(err); + if (done) done(err); + } + } + }); + } + + RED.nodes.registerType("zammad-user", ZammadUserNode); +};