Initial commit: Redmine ticket portal

This commit is contained in:
2026-07-13 13:26:37 +02:00
commit fb78c96e6b
8 changed files with 1416 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
import express from "express";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// --- Konfiguration (per Umgebungsvariablen, siehe .env.example) ---
const config = {
port: Number(process.env.PORT) || 3000,
redmineUrl: (process.env.REDMINE_URL || "https://redmine.unicon-gmbh.de/redmine").replace(/\/+$/, ""),
redmineApiKey: process.env.REDMINE_API_KEY || "",
projectId: Number(process.env.REDMINE_PROJECT_ID) || 1,
trackerId: Number(process.env.REDMINE_TRACKER_ID) || 3,
customerFieldId: Number(process.env.CUSTOMER_FIELD_ID) || 5,
customersFile: process.env.CUSTOMERS_FILE || path.join(__dirname, "data", "customers.csv"),
};
// Ohne API-Key laeuft der Server im Trockenlauf: Tickets werden nur simuliert.
const dryRun = config.redmineApiKey === "";
// --- Kundenstammdaten laden (CSV: Kundennummer;Firmenname) ---
function loadCustomers(filePath) {
const rawContent = fs.readFileSync(filePath, "utf8");
return rawContent
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== "" && !line.startsWith("#"))
.map((line) => {
const [number, ...nameParts] = line.split(";");
return { number: number.trim(), name: nameParts.join(";").trim() };
})
.filter((customer) => customer.number !== "" && customer.name !== "");
}
let customers = [];
try {
customers = loadCustomers(config.customersFile);
console.log(`${customers.length} Kunden aus ${config.customersFile} geladen`);
} catch (error) {
console.error(`Kundendatei konnte nicht gelesen werden: ${error.message}`);
process.exit(1);
}
const customersByNumber = new Map(customers.map((customer) => [customer.number, customer]));
// --- Suche: Treffer am Wortanfang zuerst, dann Teilstring-Treffer ---
function searchCustomers(query, limit) {
const needle = query.toLocaleLowerCase("de-DE");
const startMatches = [];
const partialMatches = [];
for (const customer of customers) {
const haystack = `${customer.number} ${customer.name}`.toLocaleLowerCase("de-DE");
if (!haystack.includes(needle)) continue;
const isStartMatch = haystack.startsWith(needle) || haystack.includes(` ${needle}`);
if (isStartMatch) { startMatches.push(customer); } else { partialMatches.push(customer); }
if (startMatches.length >= limit) break;
}
return startMatches.concat(partialMatches).slice(0, limit);
}
// --- Redmine-Anbindung ---
async function createRedmineIssue(payload) {
if (dryRun) {
return { id: Math.floor(Math.random() * 9000) + 1000, dryRun: true };
}
const response = await fetch(`${config.redmineUrl}/issues.json`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Redmine-API-Key": config.redmineApiKey,
},
body: JSON.stringify({ issue: payload }),
});
if (response.status === 201) {
const body = await response.json();
return { id: body.issue.id, dryRun: false };
}
let details = `HTTP ${response.status}`;
try {
const errorBody = await response.json();
if (Array.isArray(errorBody.errors)) { details = errorBody.errors.join(", "); }
} catch { /* Antwort war kein JSON, Status reicht als Info */ }
const redmineError = new Error(details);
redmineError.statusCode = response.status;
throw redmineError;
}
// --- HTTP-Server ---
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
// GET /api/customers?search=... -> Trefferliste fuer das Autocomplete
app.get("/api/customers", (request, response) => {
const query = String(request.query.search || "").trim();
if (query.length < 2) {
return response.json({ results: [], total: customers.length });
}
const results = searchCustomers(query, 12);
return response.json({ results, total: customers.length });
});
// POST /api/tickets -> validiert den Kundenbezug und legt das Ticket in Redmine an
app.post("/api/tickets", async (request, response) => {
const { customerNumber, subject, description } = request.body || {};
const customer = customersByNumber.get(String(customerNumber || "").trim());
if (!customer) {
return response.status(422).json({ error: "Unbekannte Kundennummer. Bitte einen Kunden aus der Vorschlagsliste waehlen." });
}
if (!subject || String(subject).trim().length < 5) {
return response.status(422).json({ error: "Der Betreff muss mindestens 5 Zeichen lang sein." });
}
const issuePayload = {
project_id: config.projectId,
tracker_id: config.trackerId,
subject: String(subject).trim(),
description: String(description || "").trim(),
custom_fields: [{ id: config.customerFieldId, value: customer.number }],
};
try {
const created = await createRedmineIssue(issuePayload);
return response.status(201).json({
issueId: created.id,
issueUrl: `${config.redmineUrl}/issues/${created.id}`,
customer,
dryRun: created.dryRun,
});
} catch (error) {
const statusCode = error.statusCode === 422 ? 422 : 502;
console.error(`Ticket-Anlage fehlgeschlagen: ${error.message}`);
return response.status(statusCode).json({ error: `Redmine hat das Ticket abgelehnt: ${error.message}` });
}
});
// Zentraler Fehler-Handler, damit keine Stacktraces an den Client gehen
app.use((error, request, response, next) => {
console.error(error);
response.status(500).json({ error: "Interner Fehler. Details stehen im Server-Log." });
});
app.listen(config.port, () => {
const mode = dryRun ? "TROCKENLAUF (kein REDMINE_API_KEY gesetzt, Tickets werden simuliert)" : `verbunden mit ${config.redmineUrl}`;
console.log(`Ticket-Portal laeuft auf http://localhost:${config.port} - Modus: ${mode}`);
});