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}`); });