Zammad API nodes for Node-RED

This commit is contained in:
2026-10-14 23:17:40 +02:00
commit 2f47e7eb6c
19 changed files with 1243 additions and 0 deletions
+36
View File
@@ -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
+9
View File
@@ -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"]
+170
View File
@@ -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=<your-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
+50
View File
@@ -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"
+12
View File
@@ -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:
+34
View File
@@ -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
+57
View File
@@ -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": [[]]
}
]
+140
View File
@@ -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/"
+23
View File
@@ -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"
}
}
+58
View File
@@ -0,0 +1,58 @@
<script type="text/javascript">
RED.nodes.registerType("zammad-api", {
category: "Zammad",
color: "#3e8e41",
defaults: {
name: { value: "" },
config: { type: "zammad-config", required: true }
},
inputs: 1,
outputs: 1,
icon: "font-awesome/fa-exchange",
label: function () {
return this.name || "Zammad API";
},
labelStyle: function () {
return this.name ? "node_label_italic" : "";
},
oneditprepare: function () {
$("#node-input-config").val(this.config ? this.config.id : "");
}
});
</script>
<script type="text/x-red" data-template-name="zammad-api">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Zammad API">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-server"></i> Config</label>
<input type="text" id="node-input-config">
</div>
<div class="form-tips" style="color:#888; font-size:11px; margin-top:10px;">
Override via message properties:
<ul style="margin:4px 0; padding-left:18px;">
<li><b>msg.method</b> – GET, POST, PUT, DELETE</li>
<li><b>msg.endpoint</b> – /tickets, /users, etc.</li>
<li><b>msg.payload</b> – request body (object or JSON string)</li>
<li><b>msg.headers</b> – additional headers object</li>
</ul>
</div>
</script>
<script type="text/x-red" data-help-name="zammad-api">
<p>Generic Zammad API caller. Sends any HTTP method to any endpoint.</p>
<h3>Inputs</h3>
<dl class="message-properties">
<dt>msg.method<span class="property-type">string</span></dt><dd>HTTP method (GET, POST, PUT, DELETE). Default: GET</dd>
<dt>msg.endpoint<span class="property-type">string</span></dt><dd>API path e.g. /tickets, /users/search</dd>
<dt>msg.payload<span class="property-type">object|string</span></dt><dd>Request body</dd>
<dt>msg.headers<span class="property-type">object</span></dt><dd>Additional HTTP headers</dd>
</dl>
<h3>Outputs</h3>
<dl class="message-properties">
<dt>msg.payload<span class="property-type">object</span></dt><dd>Parsed JSON response body</dd>
<dt>msg.statusCode<span class="property-type">number</span></dt><dd>HTTP status code</dd>
</dl>
</script>
+44
View File
@@ -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);
};
+67
View File
@@ -0,0 +1,67 @@
<script type="text/javascript">
RED.nodes.registerType("zammad-config", {
category: "config",
defaults: {
name: { value: "" },
baseUrl: { value: "https://support.hanssonit.se/api/v1", required: true },
useToken: { value: true }
},
credentials: {
token: { type: "text" },
username: { type: "text" },
password: { type: "password" }
},
label: function () {
return this.name || this.baseUrl || "Zammad";
},
labelStyle: function () {
return this.name ? "node_label_italic" : "";
}
});
</script>
<script type="text/x-red" data-template-name="zammad-config">
<div class="form-row">
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-config-input-name" placeholder="Zammad Config">
</div>
<div class="form-row">
<label for="node-config-input-baseUrl"><i class="fa fa-server"></i> Base URL</label>
<input type="text" id="node-config-input-baseUrl" placeholder="https://support.hanssonit.se/api/v1">
</div>
<div class="form-row">
<label for="node-config-input-useToken"><i class="fa fa-lock"></i> Use Token Auth</label>
<input type="checkbox" id="node-config-input-useToken" checked style="width:auto;">
</div>
<div id="zammad-token-section">
<div class="form-row">
<label for="node-config-input-token"><i class="fa fa-key"></i> API Token</label>
<input type="text" id="node-config-input-token" autocomplete="off">
<div class="form-tips" style="color:#888; font-size:11px; margin-top:4px;">
Create token at https://support.hanssonit.se while logged in.<br>
Required permission: <b>ticket.agent</b>
</div>
</div>
</div>
<div id="zammad-basic-section" style="display:none;">
<div class="form-row">
<label for="node-config-input-username"><i class="fa fa-user"></i> Username</label>
<input type="text" id="node-config-input-username" placeholder="Hannes">
</div>
<div class="form-row">
<label for="node-config-input-password"><i class="fa fa-key"></i> Password</label>
<input type="password" id="node-config-input-password" autocomplete="off">
</div>
</div>
</script>
<script type="text/x-red" data-help-name="zammad-config">
<p>Zammad API configuration node.</p>
<h3>Details</h3>
<p>Configure your Zammad instance connection.</p>
<ul>
<li><b>Base URL</b> – Must be <code>https://support.hanssonit.se/api/v1</code> (do not use zammad.kafit.se).</li>
<li><b>Token Auth</b> – Preferred. Create token on support.hanssonit.se with <code>ticket.agent</code> permission.</li>
<li><b>Basic Auth</b> – Username is typically just <code>Hannes</code> (not full email).</li>
</ul>
</script>
+59
View File
@@ -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);
};
+54
View File
@@ -0,0 +1,54 @@
<script type="text/javascript">
RED.nodes.registerType("zammad-organization", {
category: "Zammad",
color: "#3e8e41",
defaults: {
name: { value: "" },
config: { type: "zammad-config", required: true },
operation: { value: "list" }
},
inputs: 1,
outputs: 1,
icon: "font-awesome/fa-building",
label: function () {
return this.name || ("Zammad Org " + this.operation);
},
labelStyle: function () {
return this.name ? "node_label_italic" : "";
},
oneditprepare: function () {
$("#node-input-config").val(this.config ? this.config.id : "");
},
oneditsave: function () {
this.operation = $("#node-select-operation").val();
}
});
</script>
<script type="text/x-red" data-template-name="zammad-organization">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Zammad Organization">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-server"></i> Config</label>
<input type="text" id="node-input-config">
</div>
<div class="form-row">
<label for="node-select-operation"><i class="fa fa-wrench"></i> Operation</label>
<select id="node-select-operation">
<option value="list">List Organizations</option>
<option value="search">Search Organizations</option>
<option value="get">Get Organization by ID</option>
<option value="create">Create Organization</option>
</select>
</div>
<div class="form-tips" style="color:#888; font-size:11px; margin-top:10px;">
<b>Search</b>: msg.query – e.g. <code>name:LRG Invest AB</code><br>
<b>Create</b>: msg.name, msg.note, msg.street, msg.zip, msg.city, msg.country
</div>
</script>
<script type="text/x-red" data-help-name="zammad-organization">
<p>Zammad organization operations: list, search, get, create.</p>
</script>
+83
View File
@@ -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);
};
+79
View File
@@ -0,0 +1,79 @@
<script type="text/javascript">
RED.nodes.registerType("zammad-ticket", {
category: "Zammad",
color: "#3e8e41",
defaults: {
name: { value: "" },
config: { type: "zammad-config", required: true },
operation: { value: "list" },
topic: { value: "" }
},
inputs: 1,
outputs: 1,
icon: "font-awesome/fa-ticket",
label: function () {
return this.name || ("Zammad " + this.operation);
},
labelStyle: function () {
return this.name ? "node_label_italic" : "";
},
oneditprepare: function () {
$("#node-input-config").val(this.config ? this.config.id : "");
var op = this.operation || "list";
$("#node-select-operation").val(op);
$("#node-input-topic").parent().toggle(op === "subscribe" || op === "publish");
},
oneditsave: function () {
this.operation = $("#node-select-operation").val();
}
});
</script>
<script type="text/x-red" data-template-name="zammad-ticket">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Zammad Ticket">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-server"></i> Config</label>
<input type="text" id="node-input-config">
</div>
<div class="form-row">
<label for="node-select-operation"><i class="fa fa-wrench"></i> Operation</label>
<select id="node-select-operation">
<option value="list">List Tickets</option>
<option value="search">Search Tickets</option>
<option value="get">Get Ticket</option>
<option value="create">Create Ticket</option>
<option value="update">Update Ticket</option>
<option value="articles">List Articles</option>
<option value="addArticle">Add Article</option>
<option value="tags">List Tags</option>
<option value="addTag">Add Tag</option>
</select>
</div>
<div class="form-tips" style="color:#888; font-size:11px; margin-top:10px;">
<b>Create</b>: Set msg.title, msg.customer, msg.body, msg.group, msg.state, msg.priority —
or send a full JSON payload object with those fields.<br>
<b>Search</b>: Zammad query syntax e.g. <code>state.name:open</code>,
<code>customer.email:foo@example.com</code>, <code>title:order</code><br>
<b>addArticle</b>: msg.articleBody, msg.sender ("Agent"/"Customer"), msg.from (email for customer articles)<br>
<b>addTag</b>: msg.tag — e.g. "woocommerce-order"
</div>
</script>
<script type="text/x-red" data-help-name="zammad-ticket">
<p>Zammad ticket operations: list, search, get, create, update, articles, tags.</p>
<h3>Operations</h3>
<ul>
<li><b>list</b> – List all tickets (optional msg.query, msg.limit, msg.page)</li>
<li><b>search</b</b> – Search via Zammad query syntax (msg.query)</li>
<li><b>get</b> – Get single ticket by msg.ticketId</li>
<li><b>create</b> – Create from individual properties or full JSON payload</li>
<li><b>update</b> – Update existing ticket (msg.ticketId)</li>
<li><b>articles</b> – List ticket articles (msg.ticketId)</li>
<li><b>addArticle</b> – Add article to ticket (msg.ticketId, msg.articleBody, msg.sender)</li>
<li><b>tags</b> – List ticket tags (msg.ticketId)</li>
<li><b>addTag</b> – Add tag to ticket (msg.ticketId, msg.tag)</li>
</ul>
</script>
+117
View File
@@ -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);
};
+63
View File
@@ -0,0 +1,63 @@
<script type="text/javascript">
RED.nodes.registerType("zammad-user", {
category: "Zammad",
color: "#3e8e41",
defaults: {
name: { value: "" },
config: { type: "zammad-config", required: true },
operation: { value: "list" }
},
inputs: 1,
outputs: 1,
icon: "font-awesome/fa-user",
label: function () {
return this.name || ("Zammad User " + this.operation);
},
labelStyle: function () {
return this.name ? "node_label_italic" : "";
},
oneditprepare: function () {
$("#node-input-config").val(this.config ? this.config.id : "");
},
oneditsave: function () {
this.operation = $("#node-select-operation").val();
}
});
</script>
<script type="text/x-red" data-template-name="zammad-user">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Zammad User">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-server"></i> Config</label>
<input type="text" id="node-input-config">
</div>
<div class="form-row">
<label for="node-select-operation"><i class="fa fa-wrench"></i> Operation</label>
<select id="node-select-operation">
<option value="me">Get Current User (me)</option>
<option value="list">List Users</option>
<option value="search">Search Users</option>
<option value="get">Get User by ID</option>
<option value="create">Create User</option>
</select>
</div>
<div class="form-tips" style="color:#888; font-size:11px; margin-top:10px;">
<b>Search</b>: msg.query – e.g. <code>email:foo@example.com</code><br>
<b>Create</b>: msg.firstname, msg.lastname, msg.email, msg.phone, msg.customer (login), msg.group
</div>
</script>
<script type="text/x-red" data-help-name="zammad-user">
<p>Zammad user operations: me, list, search, get, create.</p>
<h3>Operations</h3>
<ul>
<li><b>me</b> – Get currently authenticated user info</li>
<li><b>list</b> – List all users (optional msg.limit, msg.page)</li>
<li><b>search</b> – Search users by query string (msg.query)</li>
<li><b>get</b> – Get user by msg.userId</li>
<li><b>create</b> – Create user from individual properties or full JSON payload</li>
</ul>
</script>
+88
View File
@@ -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);
};