Zammad API nodes for Node-RED
This commit is contained in:
+36
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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": [[]]
|
||||
}
|
||||
]
|
||||
@@ -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/"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user