Quellcode des ICON CRM (3)
@@ -0,0 +1,62 @@
|
||||
|
||||
export type ViewState = 'Dashboard' | 'Live!' | 'Ausschreibungen' | 'Kunden' | 'Experten' | 'Laufende Projekte' | 'Organigramm' | 'Einstellungen';
|
||||
|
||||
export interface Expert {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
linkedin: string;
|
||||
status: 'Interessiert' | 'Interview' | 'Verfügbar' | 'Gebucht';
|
||||
avatar: string;
|
||||
company?: string;
|
||||
bio?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
customer_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
company: string;
|
||||
name?: string;
|
||||
logo?: string;
|
||||
notes?: string;
|
||||
contacts?: Contact[];
|
||||
}
|
||||
|
||||
export interface Tender {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
applicantsCount: number;
|
||||
status: 'Aktiv' | 'Ausgelaufen' | 'Archiviert' | 'Inaktiv';
|
||||
kanbanStatus: KanbanColumn;
|
||||
details?: string;
|
||||
notes?: string;
|
||||
link?: string;
|
||||
selectedCustomer?: Customer;
|
||||
selectedExperts?: Expert[];
|
||||
}
|
||||
|
||||
export type KanbanColumn = 'Ausschreibung' | 'Experten sichten' | 'Interview' | 'Kunden Interview' | 'Vertragsgestaltung' | 'Abschluss' | 'Geschlossen';
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
customer: string;
|
||||
expert: string;
|
||||
start: string;
|
||||
end: string;
|
||||
status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen';
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Icon Expert CRM",
|
||||
"description": "A high-end CRM system for expert placement agencies, featuring Kanban pipelines, tender management, and AI-assisted matching.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "icon-crm",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"d3-force": "^3.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,40 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 120" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Black Mesa: Rohre, Lueftung und Warnstreifen">
|
||||
<!-- Anlagentechnik als Silhouette. Bewusst flaechig und kontrastarm: die
|
||||
Grafik liegt hinter Text und darf ihn nicht stoeren. -->
|
||||
<rect width="1200" height="120" fill="#1b1f21"/>
|
||||
<g fill="#2a3033">
|
||||
<rect x="0" y="78" width="1200" height="42"/>
|
||||
<rect x="60" y="30" width="26" height="90"/>
|
||||
<rect x="96" y="46" width="14" height="74"/>
|
||||
<rect x="980" y="22" width="34" height="98"/>
|
||||
<rect x="1024" y="52" width="16" height="68"/>
|
||||
</g>
|
||||
<!-- Rohrleitungen -->
|
||||
<g stroke="#343c40" stroke-width="9" fill="none">
|
||||
<path d="M0 34 H240 q14 0 14 14 V70 q0 14 14 14 H520"/>
|
||||
<path d="M700 96 H860 q14 0 14-14 V44 q0-14 14-14 H1200"/>
|
||||
</g>
|
||||
<g stroke="#454f54" stroke-width="3" fill="none">
|
||||
<path d="M0 34 H240 q14 0 14 14 V70 q0 14 14 14 H520"/>
|
||||
<path d="M700 96 H860 q14 0 14-14 V44 q0-14 14-14 H1200"/>
|
||||
</g>
|
||||
<!-- Lambda, das Zeichen des Hauses -->
|
||||
<g transform="translate(566 24)" fill="none" stroke="#e0720b" stroke-width="7" stroke-linecap="square" opacity="0.85">
|
||||
<path d="M8 66 L30 8 L52 66"/>
|
||||
<path d="M24 26 L10 56"/>
|
||||
</g>
|
||||
<!-- Warnstreifen unten -->
|
||||
<g opacity="0.5">
|
||||
<rect x="0" y="112" width="1200" height="8" fill="#1b1f21"/>
|
||||
<g fill="#e0720b">
|
||||
<path d="M0 112 h20 l-20 8 z"/>
|
||||
<path d="M40 112 h20 l-20 8 h-20 z"/>
|
||||
<path d="M80 112 h20 l-20 8 h-20 z"/>
|
||||
<path d="M120 112 h20 l-20 8 h-20 z"/>
|
||||
<path d="M1060 112 h20 l-20 8 h-20 z"/>
|
||||
<path d="M1100 112 h20 l-20 8 h-20 z"/>
|
||||
<path d="M1140 112 h20 l-20 8 h-20 z"/>
|
||||
<path d="M1180 112 h20 l-20 8 h-20 z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 800" preserveAspectRatio="xMidYMax slice" role="img" aria-label="Black Mesa: Betonwand mit Leitungen">
|
||||
<rect width="240" height="800" fill="#1b1f21"/>
|
||||
<!-- Betonplatten -->
|
||||
<g stroke="#252b2e" stroke-width="2" fill="none">
|
||||
<path d="M0 200 H240 M0 420 H240 M0 640 H240 M80 200 V800 M160 200 V800"/>
|
||||
</g>
|
||||
<!-- Leitungen an der Wand -->
|
||||
<g stroke="#2f373b" stroke-width="10" fill="none" stroke-linecap="square">
|
||||
<path d="M28 800 V520 q0-16 16-16 H96"/>
|
||||
<path d="M212 800 V420"/>
|
||||
</g>
|
||||
<g stroke="#3d474c" stroke-width="3" fill="none">
|
||||
<path d="M28 800 V520 q0-16 16-16 H96"/>
|
||||
<path d="M212 800 V420"/>
|
||||
</g>
|
||||
<!-- Ventil -->
|
||||
<g transform="translate(212 470)" fill="none" stroke="#e0720b" stroke-width="4" opacity="0.7">
|
||||
<circle r="16"/>
|
||||
<path d="M-16 0 H16 M0 -16 V16"/>
|
||||
</g>
|
||||
<!-- Lambda unten -->
|
||||
<g transform="translate(94 690)" fill="none" stroke="#e0720b" stroke-width="9" stroke-linecap="square" opacity="0.5">
|
||||
<path d="M6 70 L30 6 L54 70"/>
|
||||
<path d="M23 27 L8 60"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 24" preserveAspectRatio="xMidYMid slice" shape-rendering="crispEdges" role="img" aria-label="8-bit Himmel mit Wolken und Skyline">
|
||||
<rect width="240" height="24" fill="#8ec7e8"/>
|
||||
<!-- Wolken -->
|
||||
<g fill="#ffffff">
|
||||
<rect x="18" y="4" width="10" height="2"/><rect x="15" y="6" width="16" height="2"/><rect x="18" y="8" width="10" height="2"/>
|
||||
<rect x="92" y="3" width="8" height="2"/><rect x="89" y="5" width="14" height="2"/>
|
||||
<rect x="176" y="6" width="12" height="2"/><rect x="173" y="8" width="18" height="2"/>
|
||||
</g>
|
||||
<!-- Sonne -->
|
||||
<g fill="#ffd34d"><rect x="206" y="3" width="8" height="8"/><rect x="204" y="5" width="12" height="4"/></g>
|
||||
<!-- Skyline -->
|
||||
<g fill="#2f7a3e">
|
||||
<rect x="0" y="18" width="240" height="6"/>
|
||||
</g>
|
||||
<g fill="#24632f">
|
||||
<rect x="10" y="14" width="12" height="4"/><rect x="34" y="12" width="8" height="6"/>
|
||||
<rect x="60" y="15" width="16" height="3"/><rect x="100" y="11" width="10" height="7"/>
|
||||
<rect x="132" y="14" width="14" height="4"/><rect x="168" y="12" width="9" height="6"/>
|
||||
<rect x="200" y="15" width="18" height="3"/>
|
||||
</g>
|
||||
<!-- Ziegel -->
|
||||
<g fill="#1c4d25">
|
||||
<rect x="0" y="21" width="240" height="1"/>
|
||||
<rect x="8" y="18" width="1" height="3"/><rect x="28" y="18" width="1" height="3"/>
|
||||
<rect x="48" y="18" width="1" height="3"/><rect x="68" y="18" width="1" height="3"/>
|
||||
<rect x="88" y="18" width="1" height="3"/><rect x="108" y="18" width="1" height="3"/>
|
||||
<rect x="128" y="18" width="1" height="3"/><rect x="148" y="18" width="1" height="3"/>
|
||||
<rect x="168" y="18" width="1" height="3"/><rect x="188" y="18" width="1" height="3"/>
|
||||
<rect x="208" y="18" width="1" height="3"/><rect x="228" y="18" width="1" height="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 200" preserveAspectRatio="xMidYMax slice" shape-rendering="crispEdges" role="img" aria-label="8-bit Steinmauer mit Truhe">
|
||||
<rect width="60" height="200" fill="#cdd8c2"/>
|
||||
<!-- Mauerwerk -->
|
||||
<g fill="#b3c0a6">
|
||||
<rect x="0" y="120" width="60" height="80"/>
|
||||
</g>
|
||||
<g fill="#9aa88d">
|
||||
<rect x="0" y="130" width="60" height="1"/><rect x="0" y="142" width="60" height="1"/>
|
||||
<rect x="0" y="154" width="60" height="1"/><rect x="0" y="166" width="60" height="1"/>
|
||||
<rect x="0" y="178" width="60" height="1"/><rect x="0" y="190" width="60" height="1"/>
|
||||
<rect x="14" y="120" width="1" height="10"/><rect x="38" y="120" width="1" height="10"/>
|
||||
<rect x="26" y="131" width="1" height="11"/><rect x="50" y="131" width="1" height="11"/>
|
||||
<rect x="14" y="143" width="1" height="11"/><rect x="38" y="143" width="1" height="11"/>
|
||||
<rect x="26" y="155" width="1" height="11"/><rect x="50" y="155" width="1" height="11"/>
|
||||
<rect x="14" y="167" width="1" height="11"/><rect x="38" y="167" width="1" height="11"/>
|
||||
<rect x="26" y="179" width="1" height="11"/><rect x="50" y="179" width="1" height="11"/>
|
||||
</g>
|
||||
<!-- Truhe -->
|
||||
<g transform="translate(16 96)">
|
||||
<rect x="0" y="0" width="28" height="20" fill="#8a5a2b"/>
|
||||
<rect x="0" y="0" width="28" height="6" fill="#a76f39"/>
|
||||
<rect x="0" y="6" width="28" height="1" fill="#5e3c1c"/>
|
||||
<rect x="12" y="4" width="4" height="8" fill="#ffd34d"/>
|
||||
<rect x="13" y="7" width="2" height="3" fill="#8a5a2b"/>
|
||||
<rect x="0" y="0" width="1" height="20" fill="#5e3c1c"/>
|
||||
<rect x="27" y="0" width="1" height="20" fill="#5e3c1c"/>
|
||||
</g>
|
||||
<!-- Grasbueschel -->
|
||||
<g fill="#2f7a3e">
|
||||
<rect x="4" y="116" width="2" height="4"/><rect x="6" y="114" width="2" height="6"/><rect x="8" y="117" width="2" height="3"/>
|
||||
<rect x="48" y="115" width="2" height="5"/><rect x="50" y="117" width="2" height="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,54 @@
|
||||
# ICON CRM Backend
|
||||
# Als .env kopieren und Werte setzen. .env gehoert nicht ins Repository.
|
||||
|
||||
PORT=5007
|
||||
# Nur lokal binden - von aussen erreichbar ist die Anwendung ausschliesslich
|
||||
# ueber den Apache-Reverse-Proxy auf icrm.icon-experts.de.
|
||||
HOST=127.0.0.1
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=iconcrm
|
||||
DB_PASSWORD=changeme
|
||||
DB_NAME=icon_CRM
|
||||
|
||||
# Signiert die JWT-Sitzungen. Langer Zufallswert, z. B.:
|
||||
# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
JWT_SECRET=changeme-langer-zufallswert
|
||||
JWT_EXPIRES_IN=12h
|
||||
|
||||
# Wird beim ersten Start angelegt, solange noch kein Benutzer existiert.
|
||||
# Ohne ADMIN_PASSWORD erzeugt das Backend eines und schreibt es einmalig ins Log.
|
||||
ADMIN_USERNAME=Admin
|
||||
ADMIN_NAME=Administrator
|
||||
ADMIN_EMAIL=admin@icon-experts.de
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Erlaubte Herkunft fuer den lokalen Vite-Dev-Server.
|
||||
CORS_ORIGINS=http://localhost:3101,http://127.0.0.1:3101
|
||||
|
||||
# Ablage der hochgeladenen Lebenslaeufe. Leer = <server>/../uploads.
|
||||
UPLOAD_DIR=/var/www/icon_CRM/uploads
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KI (CV-Auslesung und Schreibhilfe fuer Ausschreibungen)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit
|
||||
# OpenAI. Fehlt er, laeuft das CRM normal weiter und blendet die KI-Knoepfe aus.
|
||||
#
|
||||
# Hinweis zum Modell: gpt-5.6-luna akzeptiert KEIN eigenes "temperature";
|
||||
# ai.js schickt deshalb nur den Standardwert.
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-5.6-luna
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lesender Zugriff auf die Kontakte des perinco CRM
|
||||
# ---------------------------------------------------------------------------
|
||||
# Das ICON CRM zeigt die perinco-Kontakte zusaetzlich an - gekennzeichnet und
|
||||
# ausschliesslich lesend. Gespeichert wird nichts.
|
||||
#
|
||||
# Der Token steht drueben in server/.env als ICON_API_TOKEN und muss
|
||||
# uebereinstimmen. Fehlt einer der beiden Werte, bleibt der Schalter in der
|
||||
# Oberflaeche aus und das CRM laeuft normal weiter.
|
||||
PERINCO_API_URL=https://pcrm.perinco.de
|
||||
PERINCO_API_TOKEN=
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "icon-crm-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "REST-Backend fuer das ICON CRM (MariaDB icon_CRM)",
|
||||
"main": "src/server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mammoth": "^1.8.0",
|
||||
"multer": "^2.0.2",
|
||||
"mysql2": "^3.11.5",
|
||||
"pdf-parse": "^1.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { getSetting, setSetting } from './db.js';
|
||||
|
||||
/*
|
||||
* Anbindung an OpenAI.
|
||||
*
|
||||
* Bewusst ohne SDK: gebraucht werden genau zwei Aufrufe (Text und JSON), und
|
||||
* `fetch` ist in Node 22 eingebaut. Ein Paket weniger, das gepflegt werden muss.
|
||||
*
|
||||
* Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage
|
||||
* laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt
|
||||
* ("sk-proj-...a1b2").
|
||||
*
|
||||
* Er kann an zwei Stellen stehen:
|
||||
*
|
||||
* 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI
|
||||
* 2. in server/.env als OPENAI_API_KEY
|
||||
*
|
||||
* Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt,
|
||||
* und dann soll niemand dafuer eine Datei auf dem Server bearbeiten und einen
|
||||
* Dienst neu starten muessen. Die .env bleibt als Startwert und als Weg, das
|
||||
* System ohne Oberflaeche in Gang zu bringen.
|
||||
*/
|
||||
|
||||
const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
|
||||
|
||||
/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
export const DEFAULT_MODEL = 'gpt-5.6-luna';
|
||||
|
||||
const KEY_SETTING = 'openai_api_key';
|
||||
const MODEL_SETTING = 'openai_model';
|
||||
|
||||
/*
|
||||
* Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt
|
||||
* beim Start (loadAiConfig) und bei jeder Aenderung.
|
||||
*/
|
||||
const config = {
|
||||
key: process.env.OPENAI_API_KEY || '',
|
||||
model: process.env.OPENAI_MODEL || DEFAULT_MODEL,
|
||||
/** Woher der gueltige Schluessel stammt - fuer die Anzeige. */
|
||||
source: process.env.OPENAI_API_KEY ? 'env' : 'none',
|
||||
};
|
||||
|
||||
/** Beim Start aufrufen, sobald die Datenbank steht. */
|
||||
export async function loadAiConfig() {
|
||||
try {
|
||||
const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]);
|
||||
if (key) {
|
||||
config.key = key;
|
||||
config.source = 'db';
|
||||
}
|
||||
if (model) config.model = model;
|
||||
} catch (err) {
|
||||
// Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten.
|
||||
console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function aiConfigured() {
|
||||
return Boolean(config.key);
|
||||
}
|
||||
|
||||
export function aiModel() {
|
||||
return config.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt -
|
||||
* genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen.
|
||||
*/
|
||||
export function aiStatus() {
|
||||
return {
|
||||
configured: aiConfigured(),
|
||||
model: config.model,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
source: config.source,
|
||||
masked: maskKey(config.key),
|
||||
/** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */
|
||||
envAvailable: Boolean(process.env.OPENAI_API_KEY),
|
||||
};
|
||||
}
|
||||
|
||||
function maskKey(key) {
|
||||
if (!key) return null;
|
||||
return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schluessel setzen oder entfernen.
|
||||
*
|
||||
* Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env
|
||||
* einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall.
|
||||
*/
|
||||
export async function saveAiKey(key) {
|
||||
const value = String(key ?? '').trim();
|
||||
await setSetting(KEY_SETTING, value || null);
|
||||
if (value) {
|
||||
config.key = value;
|
||||
config.source = 'db';
|
||||
} else {
|
||||
config.key = process.env.OPENAI_API_KEY || '';
|
||||
config.source = config.key ? 'env' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/** Modell setzen; leer heisst zurueck zur Vorgabe. */
|
||||
export async function saveAiModel(model) {
|
||||
const value = String(model ?? '').trim();
|
||||
await setSetting(MODEL_SETTING, value || null);
|
||||
config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
class AiError extends Error {
|
||||
constructor(message, status = 502) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Aufruf an das Modell.
|
||||
*
|
||||
* `temperature` wird bewusst NICHT mitgeschickt: gpt-5.6-luna laesst nur den
|
||||
* Standardwert zu und weist jeden anderen mit 400 ab.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.system Rollenanweisung
|
||||
* @param {string} options.user Eigentliche Anfrage
|
||||
* @param {boolean} [options.json] true erzwingt eine JSON-Antwort
|
||||
* @param {number} [options.maxTokens]
|
||||
* @returns {Promise<string>} der Antworttext
|
||||
*/
|
||||
export async function askModel({ system, user, json = false, maxTokens }) {
|
||||
if (!aiConfigured()) {
|
||||
throw new AiError(
|
||||
'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).',
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: config.model,
|
||||
messages: [
|
||||
...(system ? [{ role: 'system', content: system }] : []),
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
};
|
||||
if (json) body.response_format = { type: 'json_object' };
|
||||
if (maxTokens) body.max_completion_tokens = maxTokens;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504);
|
||||
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
// Die Meldung von OpenAI ist meist praezise ("model not found", "quota") -
|
||||
// sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel.
|
||||
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
||||
}
|
||||
|
||||
const text = payload?.choices?.[0]?.message?.content;
|
||||
if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502);
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Wie askModel, gibt aber das geparste JSON zurueck. */
|
||||
export async function askModelJson(options) {
|
||||
const text = await askModel({ ...options, json: true });
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502);
|
||||
}
|
||||
}
|
||||
|
||||
export { AiError };
|
||||
@@ -0,0 +1,280 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
/*
|
||||
* Das Bewerbungsfoto aus einem Lebenslauf holen.
|
||||
*
|
||||
* Viele Lebenslaeufe tragen oben rechts ein Portrait. Es von Hand nachzupflegen
|
||||
* ist Fleissarbeit, die niemand macht - danach steht im CRM eine Kachelwand aus
|
||||
* Initialen, obwohl das Foto in der Datei lag.
|
||||
*
|
||||
* Bewusst ohne zusaetzliches Paket:
|
||||
*
|
||||
* DOCX ist ein ZIP. Gebraucht wird genau eine Sache - die Dateien unter
|
||||
* `word/media/` herausloesen. Das leistet das eingebaute zlib
|
||||
* (`inflateRawSync`) zusammen mit dem Lesen des Zentralverzeichnisses.
|
||||
* PDF speichert JPEG-Bilder mit `/DCTDecode` unveraendert im Datenstrom.
|
||||
* Sie lassen sich deshalb an ihren eigenen Markierungen (FFD8 … FFD9)
|
||||
* aus der Datei schneiden. Bilder in anderen Kodierungen (FlateDecode,
|
||||
* also rohe Bitmaps) bleiben aussen vor: sie muessten neu kodiert
|
||||
* werden, und dafuer braeuchte es eine Bildbibliothek.
|
||||
*
|
||||
* Verkleinert wird hier NICHT. Das macht der Browser beim Uebernehmen - dort
|
||||
* liegt schon die Skalierung fuer alle anderen Fotos (src/lib/imageCompress.ts),
|
||||
* und ein zweiter Weg mit eigener Bibliothek auf dem Server waere eine zweite
|
||||
* Stelle, an der sich Ergebnisse unterscheiden koennen.
|
||||
*/
|
||||
|
||||
/** Groesser wird nicht uebernommen - das waere ein Scan der ganzen Seite. */
|
||||
const MAX_BYTES = 6 * 1024 * 1024;
|
||||
|
||||
/** Kleiner ist kein Portrait, sondern ein Symbol, ein Logo oder eine Linie. */
|
||||
const MIN_SIDE = 100;
|
||||
|
||||
/**
|
||||
* Sucht das wahrscheinlichste Portrait in der Datei.
|
||||
*
|
||||
* @param {string} filePath absoluter Pfad
|
||||
* @param {string} originalName fuer die Endung
|
||||
* @returns {Promise<{dataUrl: string, mime: string, width: number, height: number, bytes: number} | null>}
|
||||
*/
|
||||
export async function extractPhoto(filePath, originalName) {
|
||||
const ext = path.extname(originalName || filePath).toLowerCase();
|
||||
|
||||
let candidates = [];
|
||||
try {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
if (ext === '.docx') candidates = imagesFromDocx(buffer);
|
||||
else if (ext === '.pdf') candidates = imagesFromPdf(buffer);
|
||||
} catch {
|
||||
// Ein unleserliches Bild darf den Import nicht aufhalten - der Text ist
|
||||
// das Wesentliche, das Foto die Zugabe.
|
||||
return null;
|
||||
}
|
||||
|
||||
const best = pickPortrait(candidates);
|
||||
if (!best) return null;
|
||||
|
||||
return {
|
||||
dataUrl: `data:${best.mime};base64,${best.data.toString('base64')}`,
|
||||
mime: best.mime,
|
||||
width: best.width,
|
||||
height: best.height,
|
||||
bytes: best.data.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Welches der gefundenen Bilder ist das Portrait?
|
||||
*
|
||||
* Die Reihenfolge in der Datei hilft nicht: in DOCX steht das Firmenlogo oft
|
||||
* vor dem Foto. Entschieden wird deshalb ueber die Form.
|
||||
*
|
||||
* * zu klein - Symbol, Trennlinie, Aufzaehlungszeichen
|
||||
* * sehr breit oder flach - Kopfzeile, Banner, Diagramm
|
||||
* * hochkant bis leicht quer bleibt uebrig; davon das groesste
|
||||
*
|
||||
* Hochformat gewinnt gegen Querformat, auch wenn es kleiner ist: ein Portrait
|
||||
* ist so gut wie immer hoeher als breit, ein Banner nie.
|
||||
*/
|
||||
function pickPortrait(images) {
|
||||
const usable = images.filter((image) => {
|
||||
if (!image.width || !image.height) return false;
|
||||
if (image.data.length > MAX_BYTES) return false;
|
||||
if (image.width < MIN_SIDE || image.height < MIN_SIDE) return false;
|
||||
const ratio = image.width / image.height;
|
||||
return ratio >= 0.4 && ratio <= 1.6;
|
||||
});
|
||||
|
||||
if (usable.length === 0) return null;
|
||||
|
||||
return usable.sort((a, b) => {
|
||||
const portraitA = a.height >= a.width ? 1 : 0;
|
||||
const portraitB = b.height >= b.width ? 1 : 0;
|
||||
if (portraitA !== portraitB) return portraitB - portraitA;
|
||||
return b.width * b.height - a.width * a.height;
|
||||
})[0];
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- DOCX */
|
||||
|
||||
/**
|
||||
* Die Bilder unter `word/media/` aus dem ZIP holen.
|
||||
*
|
||||
* Gelesen wird das Zentralverzeichnis am Dateiende - nur dort stehen die
|
||||
* Namen zuverlaessig. Die lokalen Kopfsaetze davor koennen Groessenangaben
|
||||
* nachreichen (Bit 3 der Flags), das Zentralverzeichnis nie.
|
||||
*/
|
||||
function imagesFromDocx(buffer) {
|
||||
const images = [];
|
||||
const endOffset = findEndOfCentralDirectory(buffer);
|
||||
if (endOffset < 0) return images;
|
||||
|
||||
const entryCount = buffer.readUInt16LE(endOffset + 10);
|
||||
let offset = buffer.readUInt32LE(endOffset + 16);
|
||||
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) break;
|
||||
|
||||
const method = buffer.readUInt16LE(offset + 10);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20);
|
||||
const nameLength = buffer.readUInt16LE(offset + 28);
|
||||
const extraLength = buffer.readUInt16LE(offset + 30);
|
||||
const commentLength = buffer.readUInt16LE(offset + 32);
|
||||
const localOffset = buffer.readUInt32LE(offset + 42);
|
||||
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength);
|
||||
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
|
||||
if (!/^word\/media\//i.test(name)) continue;
|
||||
if (compressedSize === 0 || compressedSize > MAX_BYTES) continue;
|
||||
|
||||
const data = readLocalEntry(buffer, localOffset, method, compressedSize);
|
||||
if (!data) continue;
|
||||
|
||||
const info = describeImage(data);
|
||||
if (info) images.push({ ...info, data });
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
/** Nutzdaten eines Eintrags ab seinem lokalen Kopfsatz. */
|
||||
function readLocalEntry(buffer, localOffset, method, compressedSize) {
|
||||
if (localOffset + 30 > buffer.length) return null;
|
||||
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) return null;
|
||||
|
||||
const nameLength = buffer.readUInt16LE(localOffset + 26);
|
||||
const extraLength = buffer.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + nameLength + extraLength;
|
||||
const slice = buffer.subarray(start, start + compressedSize);
|
||||
|
||||
try {
|
||||
if (method === 0) return Buffer.from(slice); // unkomprimiert abgelegt
|
||||
if (method === 8) return zlib.inflateRawSync(slice);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Das "End of Central Directory"-Kennzeichen steht am Dateiende. */
|
||||
function findEndOfCentralDirectory(buffer) {
|
||||
// Der Kommentar am Ende darf 64 KB lang sein - weiter zurueck lohnt nicht.
|
||||
const from = Math.max(0, buffer.length - 22 - 0xffff);
|
||||
for (let index = buffer.length - 22; index >= from; index -= 1) {
|
||||
if (buffer.readUInt32LE(index) === 0x06054b50) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ PDF */
|
||||
|
||||
/**
|
||||
* JPEG-Bilder aus einem PDF schneiden.
|
||||
*
|
||||
* Mit `/DCTDecode` abgelegte Bilder stehen unveraendert in der Datei; ihre
|
||||
* eigenen Markierungen begrenzen sie zuverlaessiger als ein halber
|
||||
* PDF-Parser. Was dabei kein gueltiges JPEG ergibt, faellt bei der
|
||||
* Groessenpruefung ohnehin durch.
|
||||
*/
|
||||
function imagesFromPdf(buffer) {
|
||||
const images = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < buffer.length - 3) {
|
||||
// Start of Image, gefolgt von einer Markierung (FFE0-FFEF oder FFDB).
|
||||
if (buffer[index] === 0xff && buffer[index + 1] === 0xd8 && buffer[index + 2] === 0xff) {
|
||||
const end = findJpegEnd(buffer, index + 2);
|
||||
if (end > index) {
|
||||
const data = buffer.subarray(index, end);
|
||||
const info = describeImage(data);
|
||||
if (info) images.push({ ...info, data: Buffer.from(data) });
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
/** Laeuft die Markierungen entlang bis zum "End of Image". */
|
||||
function findJpegEnd(buffer, start) {
|
||||
let index = start;
|
||||
while (index < buffer.length - 1) {
|
||||
if (buffer[index] !== 0xff) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const marker = buffer[index + 1];
|
||||
if (marker === 0xd9) return index + 2; // EOI
|
||||
if (marker === 0xda) {
|
||||
// Beginn der Bilddaten: darin stehen beliebige Bytes, nur FF00 und die
|
||||
// Restart-Markierungen sind erlaubt. Bis zum naechsten echten Marker.
|
||||
index += 2;
|
||||
while (index < buffer.length - 1) {
|
||||
if (buffer[index] === 0xff && buffer[index + 1] !== 0x00 &&
|
||||
!(buffer[index + 1] >= 0xd0 && buffer[index + 1] <= 0xd7)) {
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (index + 3 >= buffer.length) return -1;
|
||||
index += 2 + buffer.readUInt16BE(index + 2);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Format */
|
||||
|
||||
/**
|
||||
* Art und Abmessungen eines Bildes - ohne es zu dekodieren.
|
||||
*
|
||||
* Gebraucht werden sie fuer die Auswahl: ohne Abmessungen liesse sich ein
|
||||
* Portrait nicht von einem Logo unterscheiden.
|
||||
*/
|
||||
function describeImage(data) {
|
||||
if (data.length < 24) return null;
|
||||
|
||||
// PNG: Abmessungen stehen im IHDR direkt am Anfang.
|
||||
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
|
||||
return { mime: 'image/png', width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
// JPEG: die Abmessungen stehen im Start-of-Frame (C0-CF, ohne C4, C8, CC).
|
||||
if (data[0] === 0xff && data[1] === 0xd8) {
|
||||
let index = 2;
|
||||
while (index < data.length - 9) {
|
||||
if (data[index] !== 0xff) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const marker = data[index + 1];
|
||||
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||||
return {
|
||||
mime: 'image/jpeg',
|
||||
height: data.readUInt16BE(index + 5),
|
||||
width: data.readUInt16BE(index + 7),
|
||||
};
|
||||
}
|
||||
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (index + 3 >= data.length) break;
|
||||
index += 2 + data.readUInt16BE(index + 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/*
|
||||
* Text aus einem Lebenslauf holen.
|
||||
*
|
||||
* Unterstuetzt PDF, DOCX und reinen Text. Das alte .doc-Format (Word 97) bleibt
|
||||
* aussen vor: es braeuchte einen eigenen Parser, und wer eine solche Datei hat,
|
||||
* speichert sie in Sekunden als PDF.
|
||||
*
|
||||
* pdf-parse wird ueber den internen Pfad geladen. Der Paket-Einstieg fuehrt
|
||||
* beim Import eine Selbstprobe aus und sucht dafuer eine Testdatei, die im
|
||||
* npm-Paket nicht mitgeliefert wird - das laesst den Start scheitern.
|
||||
*/
|
||||
const pdfParse = require('pdf-parse/lib/pdf-parse.js');
|
||||
|
||||
/** Mehr Text braucht kein Modell, um einen Lebenslauf zu verstehen. */
|
||||
const MAX_CHARS = 60_000;
|
||||
|
||||
export function isSupported(filename, mime = '') {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return (
|
||||
['.pdf', '.docx', '.txt', '.md'].includes(ext) ||
|
||||
mime === 'application/pdf' ||
|
||||
mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
mime.startsWith('text/')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Datei und gibt ihren Text zurueck.
|
||||
*
|
||||
* @param {string} filePath absoluter Pfad
|
||||
* @param {string} originalName fuer die Endung
|
||||
*/
|
||||
export async function extractText(filePath, originalName) {
|
||||
const ext = path.extname(originalName || filePath).toLowerCase();
|
||||
|
||||
if (ext === '.pdf') {
|
||||
const result = await pdfParse(await fs.readFile(filePath));
|
||||
return clean(result.text);
|
||||
}
|
||||
|
||||
if (ext === '.docx') {
|
||||
// mammoth liefert den Fliesstext ohne Formatierung - genau das, was das
|
||||
// Modell braucht.
|
||||
const mammoth = require('mammoth');
|
||||
const result = await mammoth.extractRawText({ path: filePath });
|
||||
return clean(result.value);
|
||||
}
|
||||
|
||||
if (ext === '.txt' || ext === '.md') {
|
||||
return clean(await fs.readFile(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('Dateityp wird nicht unterstützt (PDF, DOCX oder Text).'), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Aufraeumen: PDF-Auszuege enthalten viele harte Zeilenumbrueche und
|
||||
* Mehrfachleerzeichen aus dem Satzspiegel. Das kostet nur Token und erschwert
|
||||
* dem Modell das Lesen.
|
||||
*/
|
||||
function clean(text) {
|
||||
const result = String(text ?? '')
|
||||
.replace(/\r/g, '')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
if (result.length <= MAX_CHARS) return result;
|
||||
|
||||
// Anfang und Ende behalten: vorn stehen Name und Kontaktdaten, hinten oft
|
||||
// Ausbildung und Zertifikate.
|
||||
const head = result.slice(0, Math.floor(MAX_CHARS * 0.7));
|
||||
const tail = result.slice(-Math.floor(MAX_CHARS * 0.3));
|
||||
return `${head}\n\n[… gekürzt …]\n\n${tail}`;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import mysql from 'mysql2/promise';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'icon_CRM',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 8,
|
||||
queueLimit: 0,
|
||||
charset: 'utf8mb4',
|
||||
// Datumswerte als Zeichenkette: sonst rechnet der Treiber sie in die
|
||||
// Zeitzone des Prozesses um und ein DATE verschiebt sich um einen Tag.
|
||||
dateStrings: true,
|
||||
});
|
||||
|
||||
/** Fuehrt schema.sql aus und legt bei leerer Benutzertabelle den Admin an. */
|
||||
export async function initDatabase() {
|
||||
const sql = await fs.readFile(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||
|
||||
// schema.sql enthaelt mehrere Statements. Statt multipleStatements zu
|
||||
// aktivieren (was SQL-Injection deutlich gefaehrlicher machen wuerde), wird
|
||||
// an Semikolons am Zeilenende getrennt. Kommentarzeilen fliegen vorher raus,
|
||||
// sonst bestuende ein Statement nach dem Split nur aus Kommentar.
|
||||
const statements = sql
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n')
|
||||
.split(/;\s*$/m)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
for (const statement of statements) {
|
||||
await conn.query(statement);
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
|
||||
await migrateColumns();
|
||||
await migrateContactKind();
|
||||
await seedOptions();
|
||||
await ensureAdminUser();
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Kontaktart war ein ENUM mit drei festen Werten. Damit sie sich in den
|
||||
* Einstellungen pflegen laesst, wird daraus ein Textfeld - die erlaubten Werte
|
||||
* stehen jetzt in icon_options.
|
||||
*
|
||||
* Erkannt wird der Altstand am Spaltentyp. Ein ENUM zu erweitern haette bei
|
||||
* jeder neuen Art eine Migration bedeutet.
|
||||
*/
|
||||
async function migrateContactKind() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'icon_contacts'
|
||||
AND COLUMN_NAME = 'kind' LIMIT 1`
|
||||
);
|
||||
if (rows[0]?.DATA_TYPE !== 'enum') return;
|
||||
|
||||
await pool.query("ALTER TABLE icon_contacts MODIFY kind VARCHAR(60) NOT NULL DEFAULT 'Experte'");
|
||||
console.log('[ICON CRM] Kontaktart ist jetzt eine gepflegte Auswahlliste.');
|
||||
}
|
||||
|
||||
/*
|
||||
* Startbestand der Auswahllisten: die Werte, die bis zur Umstellung im
|
||||
* Quelltext standen, plus alles, was in vorhandenen Datensaetzen schon als
|
||||
* Freitext steht.
|
||||
*
|
||||
* Laeuft genau einmal, festgehalten per Merker. Ohne den waere jeder Neustart
|
||||
* ein Wiederherstellen: eine Branche, die jemand bewusst entfernt hat, waere
|
||||
* danach wieder da.
|
||||
*/
|
||||
async function seedOptions() {
|
||||
if ((await getSetting('options_seeded')) === 'true') return;
|
||||
|
||||
const kinds = ['Experte', 'Ansprechpartner', 'Lead'];
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO icon_options (field, value, position)
|
||||
VALUES ${kinds.map((_, index) => `('contact_kind', ?, ${index})`).join(', ')}`,
|
||||
kinds
|
||||
);
|
||||
|
||||
// Vorhandene Freitexte uebernehmen, damit kein Unternehmen eine Branche
|
||||
// traegt, die sein Auswahlfeld nicht kennt.
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO icon_options (field, value)
|
||||
SELECT 'industry', TRIM(industry) FROM icon_companies
|
||||
WHERE industry IS NOT NULL AND TRIM(industry) <> ''
|
||||
GROUP BY TRIM(industry)`
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO icon_options (field, value)
|
||||
SELECT 'contact_kind', TRIM(kind) FROM icon_contacts
|
||||
WHERE kind IS NOT NULL AND TRIM(kind) <> ''
|
||||
GROUP BY TRIM(kind)`
|
||||
);
|
||||
|
||||
await setSetting('options_seeded', 'true');
|
||||
const [[count]] = await pool.query('SELECT COUNT(*) AS count FROM icon_options');
|
||||
console.log(`[ICON CRM] Auswahllisten angelegt - ${count.count} Eintrag/Einträge.`);
|
||||
}
|
||||
|
||||
/*
|
||||
* Spalten, die zu einer bereits bestehenden Tabelle dazugekommen sind.
|
||||
*
|
||||
* `CREATE TABLE IF NOT EXISTS` in schema.sql fasst eine vorhandene Tabelle
|
||||
* nicht an - Nachtraege gehoeren deshalb hierher. Jeder Eintrag prueft erst
|
||||
* INFORMATION_SCHEMA und ist damit bei jedem weiteren Start ein Blick in den
|
||||
* Katalog und sonst nichts.
|
||||
*/
|
||||
async function migrateColumns() {
|
||||
const additions = [
|
||||
// Hauptansprechpartner eines Unternehmens - Auswahl aus den zugeordneten
|
||||
// Kontakten (Featureliste, Abschnitt "Kunden").
|
||||
[
|
||||
'icon_companies',
|
||||
'primary_contact_id',
|
||||
`ALTER TABLE icon_companies
|
||||
ADD COLUMN primary_contact_id INT UNSIGNED NULL AFTER owner_id,
|
||||
ADD CONSTRAINT fk_icon_companies_primary FOREIGN KEY (primary_contact_id)
|
||||
REFERENCES icon_contacts (id) ON DELETE SET NULL`,
|
||||
],
|
||||
// Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen
|
||||
// und Uebernehmen (siehe server/src/cvPhoto.js).
|
||||
[
|
||||
'icon_cv_imports',
|
||||
'photo',
|
||||
'ALTER TABLE icon_cv_imports ADD COLUMN photo MEDIUMTEXT NULL AFTER extracted',
|
||||
],
|
||||
];
|
||||
|
||||
for (const [table, column, statement] of additions) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`,
|
||||
[table, column]
|
||||
);
|
||||
if (rows.length > 0) continue;
|
||||
|
||||
await pool.query(statement);
|
||||
console.log(`[ICON CRM] Spalte ${table}.${column} ergänzt.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest einen Wert aus icon_settings. */
|
||||
export async function getSetting(key) {
|
||||
const [rows] = await pool.query('SELECT value FROM icon_settings WHERE key_name = ? LIMIT 1', [key]);
|
||||
return rows[0]?.value ?? null;
|
||||
}
|
||||
|
||||
/** Schreibt einen Wert nach icon_settings. */
|
||||
export async function setSetting(key, value) {
|
||||
await pool.query(
|
||||
`INSERT INTO icon_settings (key_name, value) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM icon_users');
|
||||
if (rows[0].count > 0) return;
|
||||
|
||||
const username = process.env.ADMIN_USERNAME || 'Admin';
|
||||
const name = process.env.ADMIN_NAME || 'Administrator';
|
||||
const email = process.env.ADMIN_EMAIL || null;
|
||||
|
||||
// Kein festes Ersatzpasswort im Quelltext: ohne ADMIN_PASSWORD wird ein
|
||||
// Zufallswert erzeugt und einmalig ins Log geschrieben. Ein bekannter
|
||||
// Standardwert waere auf jeder Installation derselbe.
|
||||
const generated = !process.env.ADMIN_PASSWORD;
|
||||
const password = process.env.ADMIN_PASSWORD || randomBytes(12).toString('base64url');
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO icon_users (username, email, name, role, password_hash, is_active)
|
||||
VALUES (?, ?, ?, 'admin', ?, 1)`,
|
||||
[username, email, name, hash]
|
||||
);
|
||||
|
||||
console.log(`[ICON CRM] Admin-Benutzer "${username}" wurde initial angelegt.`);
|
||||
if (generated) {
|
||||
console.log(
|
||||
`[ICON CRM] ADMIN_PASSWORD war nicht gesetzt - erzeugtes Passwort: ${password}\n` +
|
||||
' Jetzt notieren und in der Anwendung unter "Mein Konto" aendern.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { pool } from './db.js';
|
||||
|
||||
/*
|
||||
* Historie und Erwaehnungen.
|
||||
*
|
||||
* Jede schreibende Route ruft hier herein, statt selbst in icon_history zu
|
||||
* schreiben. Damit gilt an einer Stelle, was die Featureliste verlangt: in
|
||||
* jeder Historie steht, welcher Benutzer was geaendert hat, und jedes @ im
|
||||
* Text taucht als eigener Eintrag auf.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mention-Token im Text: `@[Anzeigename](contact:12)`.
|
||||
*
|
||||
* Der Name steht mit im Token, damit ein Text auch ohne geladene Listen
|
||||
* lesbar bleibt; beim Rendern gewinnt der aktuelle Name aus den Daten.
|
||||
*/
|
||||
const MENTION_TOKEN = /@\[([^\]\n]+)\]\((contact|company|user):(\d+)\)/g;
|
||||
|
||||
export function extractMentions(text) {
|
||||
if (!text) return [];
|
||||
const found = new Map();
|
||||
MENTION_TOKEN.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = MENTION_TOKEN.exec(String(text))) !== null) {
|
||||
const key = `${match[2]}:${match[3]}`;
|
||||
if (!found.has(key)) found.set(key, { type: match[2], id: Number(match[3]), label: match[1] });
|
||||
}
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
/** Text ohne Token-Syntax - fuer Suche und Vorschauen. */
|
||||
export function mentionPlainText(text) {
|
||||
if (!text) return '';
|
||||
return String(text).replace(MENTION_TOKEN, (_all, label) => `@${label}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt einen Historieneintrag und indiziert die darin enthaltenen
|
||||
* Erwaehnungen.
|
||||
*
|
||||
* @param {object} entry
|
||||
* @param {'contact'|'company'|'tender'|'project'|'lead'|'user'|'system'} entry.entityType
|
||||
* @param {number|null} entry.entityId
|
||||
* @param {string|null} entry.entityLabel Name zum Zeitpunkt des Eintrags
|
||||
* @param {string} entry.action created | updated | deleted | note | ...
|
||||
* @param {string|null} entry.text
|
||||
* @param {object|null} entry.changes { feld: [vorher, nachher] }
|
||||
* @param {number|null} entry.userId
|
||||
* @returns {Promise<number>} id des Eintrags
|
||||
*/
|
||||
export async function logHistory({
|
||||
entityType,
|
||||
entityId = null,
|
||||
entityLabel = null,
|
||||
action = 'note',
|
||||
text = null,
|
||||
changes = null,
|
||||
userId = null,
|
||||
}) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO icon_history (entity_type, entity_id, entity_label, action, text, changes, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entityType,
|
||||
entityId,
|
||||
entityLabel ? String(entityLabel).slice(0, 300) : null,
|
||||
action,
|
||||
text ?? null,
|
||||
changes && Object.keys(changes).length > 0 ? JSON.stringify(changes) : null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
const historyId = result.insertId;
|
||||
await indexMentions('history', historyId, text);
|
||||
return historyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traegt die Erwaehnungen eines Textes in icon_mentions nach.
|
||||
*
|
||||
* Erst loeschen, dann neu schreiben: so verschwindet ein @, das aus einer
|
||||
* Notiz wieder entfernt wurde, auch aus dem Index.
|
||||
*/
|
||||
export async function indexMentions(sourceType, sourceId, text) {
|
||||
const mentions = extractMentions(text);
|
||||
await pool.query('DELETE FROM icon_mentions WHERE source_type = ? AND source_id = ?', [
|
||||
sourceType,
|
||||
sourceId,
|
||||
]);
|
||||
if (mentions.length === 0) return [];
|
||||
|
||||
const values = mentions.flatMap((m) => [sourceType, sourceId, m.type, m.id]);
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO icon_mentions (source_type, source_id, target_type, target_id)
|
||||
VALUES ${mentions.map(() => '(?, ?, ?, ?)').join(', ')}`,
|
||||
values
|
||||
);
|
||||
return mentions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleicht alten und neuen Datensatz und liefert nur die geaenderten Felder.
|
||||
*
|
||||
* Verglichen wird ueber String(), weil MariaDB Zahlen mal als Number und mal
|
||||
* als String liefert (DECIMAL etwa immer als String) - ein strikter Vergleich
|
||||
* wuerde daraus eine Scheinaenderung machen.
|
||||
*/
|
||||
export function diffFields(before, after, fields) {
|
||||
const changes = {};
|
||||
for (const field of fields) {
|
||||
if (after[field] === undefined) continue;
|
||||
const from = before?.[field] ?? null;
|
||||
const to = after[field] ?? null;
|
||||
if (String(from ?? '') === String(to ?? '')) continue;
|
||||
changes[field] = [from, to];
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */
|
||||
export const FIELD_LABELS = {
|
||||
name: 'Name',
|
||||
first_name: 'Vorname',
|
||||
last_name: 'Nachname',
|
||||
position: 'Position',
|
||||
email: 'E-Mail',
|
||||
phone: 'Telefon',
|
||||
linkedin: 'LinkedIn',
|
||||
company_id: 'Unternehmen',
|
||||
kind: 'Art',
|
||||
status: 'Status',
|
||||
lead_source: 'Lead-Quelle',
|
||||
contact_state: 'Kontaktstand',
|
||||
freelancers: 'Freelancer-Einsatz',
|
||||
bio: 'Notiz',
|
||||
tags: 'Schlagworte',
|
||||
rate_onsite: 'Stundensatz vor Ort',
|
||||
rate_remote: 'Stundensatz remote',
|
||||
cv_url: 'CV-Link',
|
||||
avatar: 'Foto',
|
||||
owner_id: 'Zustaendig',
|
||||
group_name: 'Gruppe',
|
||||
industry: 'Branche',
|
||||
employees: 'Mitarbeiterzahl',
|
||||
website: 'Website',
|
||||
address: 'Adresse',
|
||||
logo: 'Logo',
|
||||
rating: 'Bewertung',
|
||||
is_customer: 'Kunde',
|
||||
customer_state: 'Kundenstatus',
|
||||
notes: 'Notizen',
|
||||
title: 'Titel',
|
||||
source: 'Quelle',
|
||||
link: 'Link',
|
||||
stage: 'Phase',
|
||||
online: 'Online',
|
||||
details: 'Details',
|
||||
deadline: 'Frist',
|
||||
started_on: 'Beginn',
|
||||
ended_on: 'Ende',
|
||||
rate: 'Satz',
|
||||
contact_id: 'Kontakt',
|
||||
archived: 'Archiviert',
|
||||
remind_at: 'Erinnerung',
|
||||
reminder_days: 'Erinnerungsintervall',
|
||||
};
|
||||
|
||||
/** "Status, Telefon und 2 weitere Felder geaendert" */
|
||||
export function describeChanges(changes) {
|
||||
const labels = Object.keys(changes).map((key) => FIELD_LABELS[key] || key);
|
||||
if (labels.length === 0) return 'ohne Änderung gespeichert';
|
||||
if (labels.length <= 3) return `${labels.join(', ')} geändert`;
|
||||
return `${labels.slice(0, 2).join(', ')} und ${labels.length - 2} weitere Felder geändert`;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Profilfoto zu einem LinkedIn-Profil besorgen.
|
||||
*
|
||||
* LinkedIn liefert oeffentlichen Profilseiten eine Open-Graph-Vorschau mit,
|
||||
* die das Profilfoto enthaelt (og:image, ausgeliefert ueber media.licdn.com).
|
||||
* Genau diese Vorschau wird hier gelesen - es findet keine Anmeldung und kein
|
||||
* Auslesen geschuetzter Inhalte statt. Ist das Profil nicht oeffentlich oder
|
||||
* blockt LinkedIn die Anfrage, gibt es eine verstaendliche Fehlermeldung
|
||||
* statt eines Bildes; der Benutzer kann das Foto dann wie gehabt hochladen.
|
||||
*
|
||||
* Der Aufruf laeuft bewusst ueber das Backend: aus dem Browser heraus waere er
|
||||
* an der Same-Origin-Policy gescheitert.
|
||||
*/
|
||||
|
||||
/** Ohne Browser-Kennung antwortet LinkedIn haeufig gar nicht erst. */
|
||||
const BROWSER_HEADERS = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' +
|
||||
'Chrome/140.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||
};
|
||||
|
||||
/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
|
||||
const TIMEOUT_MS = 12_000;
|
||||
|
||||
/** Groessere Bilder sind kein Profilfoto mehr; der Client verkleinert ohnehin. */
|
||||
const MAX_IMAGE_BYTES = 5_000_000;
|
||||
|
||||
/** Bilder liefert LinkedIn ausschliesslich ueber dieses CDN aus. */
|
||||
const IMAGE_HOSTS = ['licdn.com'];
|
||||
|
||||
class PhotoError extends Error {
|
||||
constructor(message, status = 502) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function hostMatches(hostname, domain) {
|
||||
return hostname === domain || hostname.endsWith(`.${domain}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eingabe des Benutzers zu einer aufrufbaren Profil-URL machen. Akzeptiert
|
||||
* auch "linkedin.com/in/vorname" ohne Schema.
|
||||
*/
|
||||
export function normalizeProfileUrl(input) {
|
||||
const raw = String(input ?? '').trim();
|
||||
if (!raw) throw new PhotoError('Es ist kein LinkedIn-Profil hinterlegt.', 400);
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
|
||||
} catch {
|
||||
throw new PhotoError('Die LinkedIn-Adresse ist keine gültige URL.', 400);
|
||||
}
|
||||
|
||||
if (!hostMatches(url.hostname.toLowerCase(), 'linkedin.com')) {
|
||||
throw new PhotoError('Die Adresse zeigt nicht auf ein LinkedIn-Profil.', 400);
|
||||
}
|
||||
|
||||
// Tracking-Parameter stoeren nur und aendern die Seite nicht.
|
||||
url.protocol = 'https:';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bild-URL aus dem HTML der Profilseite ziehen - ausschliesslich aus der
|
||||
* Open-Graph-Vorschau (`og:image`, ersatzweise `twitter:image`).
|
||||
*
|
||||
* Bewusst wird NICHT im uebrigen Quelltext nach einer Bildadresse gesucht: eine
|
||||
* Profilseite enthaelt Dutzende Adressen des LinkedIn-CDN - Firmenlogos,
|
||||
* Beitragsbilder und die Fotos der "aehnlichen Profile" am Rand. Die erste
|
||||
* davon zu nehmen, haengt irgendwann einem Kontakt das Gesicht einer fremden
|
||||
* Person an. Nur die Vorschau gehoert nachweislich zum aufgerufenen Profil.
|
||||
*/
|
||||
function extractImageUrl(html) {
|
||||
const metaPattern = /<meta[^>]+>/gi;
|
||||
for (const tag of html.match(metaPattern) ?? []) {
|
||||
const isImageTag = /(property|name)\s*=\s*["'](og:image|twitter:image)["']/i.test(tag);
|
||||
if (!isImageTag) continue;
|
||||
const content = tag.match(/content\s*=\s*["']([^"']+)["']/i);
|
||||
if (content?.[1]) return decodeEntities(content[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Im HTML stehen die URLs mit maskierten Und-Zeichen. */
|
||||
function decodeEntities(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(///gi, '/')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function requestWithTimeout(url, headers) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { headers, redirect: 'follow', signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
throw new PhotoError('LinkedIn hat nicht rechtzeitig geantwortet.', 504);
|
||||
}
|
||||
throw new PhotoError('LinkedIn ist gerade nicht erreichbar.', 502);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt das Profilfoto und gibt es als Data-URL zurueck - so wie ein
|
||||
* hochgeladenes Foto auch gespeichert wird.
|
||||
*/
|
||||
export async function fetchLinkedInPhoto(profileInput) {
|
||||
const profileUrl = normalizeProfileUrl(profileInput);
|
||||
|
||||
const page = await requestWithTimeout(profileUrl, BROWSER_HEADERS);
|
||||
if (page.status === 404 || page.status === 410) {
|
||||
throw new PhotoError('Unter dieser Adresse gibt es kein LinkedIn-Profil.', 404);
|
||||
}
|
||||
if (!page.ok) {
|
||||
// 999 ist LinkedIns Kennzeichen fuer "Anfrage abgewiesen".
|
||||
throw new PhotoError(
|
||||
'LinkedIn hat die Anfrage abgewiesen. Bitte das Foto manuell hochladen.',
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
const html = await page.text();
|
||||
const imageUrl = extractImageUrl(html);
|
||||
if (!imageUrl) {
|
||||
throw new PhotoError(
|
||||
'Das Profil gibt kein öffentliches Foto preis. Bitte das Foto manuell hochladen.',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
let parsedImage;
|
||||
try {
|
||||
parsedImage = new URL(imageUrl);
|
||||
} catch {
|
||||
throw new PhotoError('Die gefundene Bildadresse ist unbrauchbar.', 502);
|
||||
}
|
||||
const isTrustedHost =
|
||||
parsedImage.protocol === 'https:' &&
|
||||
IMAGE_HOSTS.some((domain) => hostMatches(parsedImage.hostname.toLowerCase(), domain));
|
||||
if (!isTrustedHost) {
|
||||
throw new PhotoError('Das gefundene Bild stammt nicht von LinkedIn.', 502);
|
||||
}
|
||||
|
||||
/*
|
||||
* Echte Profilfotos liegen unter media.licdn.com/dms/image/... - alles andere
|
||||
* ist LinkedIns Ersatzbild: die graue Silhouette ('ghost') oder eine feste
|
||||
* Grafik von static.licdn.com (/aero-v1/sc/h/...). Das bekommt zu sehen, wer
|
||||
* nicht angemeldet ist und dessen Profilfoto nur fuer Kontakte sichtbar ist -
|
||||
* von aussen laesst sich das nicht von "gar kein Foto" unterscheiden, deshalb
|
||||
* nennt die Meldung beide Moeglichkeiten.
|
||||
*/
|
||||
const path = parsedImage.pathname.toLowerCase();
|
||||
if (path.includes('ghost') || !path.includes('/dms/image/')) {
|
||||
throw new PhotoError(
|
||||
'LinkedIn gibt zu diesem Profil kein Foto heraus: Es ist nur für Kontakte ' +
|
||||
'sichtbar oder es gibt keines. Bitte das Foto manuell hochladen.',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const image = await requestWithTimeout(parsedImage.toString(), {
|
||||
'User-Agent': BROWSER_HEADERS['User-Agent'],
|
||||
'Accept': 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.8',
|
||||
'Referer': profileUrl,
|
||||
});
|
||||
if (!image.ok) throw new PhotoError('Das Profilfoto konnte nicht geladen werden.', 502);
|
||||
|
||||
const contentType = (image.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||||
if (!contentType.startsWith('image/')) {
|
||||
throw new PhotoError('LinkedIn hat statt eines Bildes etwas anderes geliefert.', 502);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await image.arrayBuffer());
|
||||
if (buffer.length === 0) throw new PhotoError('Das Profilfoto ist leer.', 502);
|
||||
if (buffer.length > MAX_IMAGE_BYTES) {
|
||||
throw new PhotoError('Das Profilfoto ist zu groß.', 502);
|
||||
}
|
||||
|
||||
return {
|
||||
avatar: `data:${contentType};base64,${buffer.toString('base64')}`,
|
||||
bytes: buffer.length,
|
||||
source_url: parsedImage.toString(),
|
||||
profile_url: profileUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export { PhotoError, extractImageUrl, decodeEntities };
|
||||
@@ -0,0 +1,62 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { pool } from '../db.js';
|
||||
|
||||
function secret() {
|
||||
const value = process.env.JWT_SECRET;
|
||||
if (!value || value.length < 16) {
|
||||
throw new Error('JWT_SECRET fehlt oder ist zu kurz (min. 16 Zeichen).');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function signToken(user) {
|
||||
return jwt.sign({ sub: user.id, role: user.role }, secret(), {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '12h',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft das Bearer-Token und laedt den aktuellen Benutzer aus der Datenbank.
|
||||
*
|
||||
* Die Spaltenliste ist bewusst vollstaendig aufgezaehlt und kein SELECT *:
|
||||
* das Passwort-Hash soll nicht am Request haengen. Sie muss aber alles
|
||||
* enthalten, was publicUser() ausliefert - sonst faellt beim Neuladen z. B.
|
||||
* die Theme-Wahl auf "nichts gewaehlt" zurueck.
|
||||
*/
|
||||
export async function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return res.status(401).json({ error: 'Nicht angemeldet.' });
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, secret());
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, username, email, name, role, color, avatar, theme, theme_mode,
|
||||
is_active, created_at, updated_at
|
||||
FROM icon_users WHERE id = ? LIMIT 1`,
|
||||
[payload.sub]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user) return res.status(401).json({ error: 'Benutzer existiert nicht mehr.' });
|
||||
if (!user.is_active) return res.status(403).json({ error: 'Benutzerkonto ist deaktiviert.' });
|
||||
|
||||
req.user = { ...user, is_active: Boolean(user.is_active) };
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur Rolle admin - schuetzt Einstellungen und Nutzerverwaltung. */
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Diese Aktion ist Administratoren vorbehalten.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'dotenv/config';
|
||||
import { pool, initDatabase } from './db.js';
|
||||
|
||||
/*
|
||||
* Einmalige Uebernahme der Daten aus dem alten ICON CRM (Supabase,
|
||||
* icondb.perinco.de) in die neue MariaDB.
|
||||
*
|
||||
* Aufruf auf dem Server:
|
||||
* cd /var/www/icon_CRM/server
|
||||
* SUPABASE_URL=... SUPABASE_KEY=... node src/migrate.js # Probelauf
|
||||
* SUPABASE_URL=... SUPABASE_KEY=... node src/migrate.js --write # schreibt
|
||||
*
|
||||
* Der Probelauf ist die Voreinstellung: er liest, rechnet die Zuordnung aus
|
||||
* und zeigt, was passieren wuerde - ohne eine Zeile zu schreiben.
|
||||
*
|
||||
* Wiederholbar: Unternehmen und Kontakte werden ueber ihren Namen wiedererkannt,
|
||||
* ein zweiter Lauf legt sie nicht erneut an.
|
||||
*/
|
||||
|
||||
const BASE = (process.env.SUPABASE_URL || 'https://icondb.perinco.de').replace(/\/+$/, '');
|
||||
const KEY = process.env.SUPABASE_KEY || '';
|
||||
const WRITE = process.argv.includes('--write');
|
||||
|
||||
const TABLES = {
|
||||
customers: 'icon_customers',
|
||||
contacts: 'icon_contacts',
|
||||
experts: 'icon_experts',
|
||||
tenders: 'icon_tenders',
|
||||
tenderExperts: 'icon_tender_experts',
|
||||
projects: 'icon_projects',
|
||||
users: 'icon_users',
|
||||
};
|
||||
|
||||
/** Status des alten Systems auf den neuen abbilden. */
|
||||
const STATUS_MAP = {
|
||||
Interessiert: 'Interessiert',
|
||||
Interview: 'Interview',
|
||||
Gebucht: 'Gebucht',
|
||||
// Der Verfuegbarkeitsstatus ist entfallen - diese Kontakte landen auf
|
||||
// "Interessiert", der neutralen Ausgangslage.
|
||||
'Verfügbar': 'Interessiert',
|
||||
};
|
||||
|
||||
const STAGE_MAP = {
|
||||
Ausschreibung: 'Ausschreibung',
|
||||
'Experten sichten': 'Kontakte sichten',
|
||||
Interview: 'Interview',
|
||||
'Kunden Interview': 'Kunden Interview',
|
||||
Vertragsgestaltung: 'Vertragsgestaltung',
|
||||
Abschluss: 'Abschluss',
|
||||
Geschlossen: 'Geschlossen',
|
||||
};
|
||||
|
||||
const TENDER_STATUS = new Set(['Aktiv', 'Inaktiv', 'Ausgelaufen', 'Archiviert']);
|
||||
const PROJECT_STATUS = new Set(['Aktiv', 'Nicht aktiv', 'Abgeschlossen']);
|
||||
|
||||
async function fetchTable(table) {
|
||||
if (!KEY) throw new Error('SUPABASE_KEY fehlt - ohne Schluessel liefert PostgREST nichts.');
|
||||
const response = await fetch(`${BASE}/rest/v1/${table}?select=*`, {
|
||||
headers: { apikey: KEY, Authorization: `Bearer ${KEY}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${table}: HTTP ${response.status} ${await response.text().catch(() => '')}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** "Hans Peter Meier" -> { first_name: 'Hans Peter', last_name: 'Meier' } */
|
||||
function splitName(value) {
|
||||
const parts = String(value ?? '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return { first_name: null, last_name: 'Unbenannt' };
|
||||
if (parts.length === 1) return { first_name: null, last_name: parts[0] };
|
||||
return { first_name: parts.slice(0, -1).join(' '), last_name: parts[parts.length - 1] };
|
||||
}
|
||||
|
||||
function clean(value, max) {
|
||||
const text = String(value ?? '').trim();
|
||||
return text ? text.slice(0, max) : null;
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
function isoDate(value) {
|
||||
const text = String(value ?? '').slice(0, 10);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Bilder aus dem Altbestand koennen alles sein: Data-URL, http-Adresse oder
|
||||
* Unsinn. Alles, was das Backend spaeter ablehnen wuerde, faellt hier weg -
|
||||
* ein Kontakt ohne Foto ist besser als ein Datensatz, der nicht mehr
|
||||
* speicherbar ist.
|
||||
*/
|
||||
function image(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return null;
|
||||
const ok = /^data:image\/[a-z+]+;base64,/i.test(text) || /^https?:\/\//i.test(text);
|
||||
if (!ok) return null;
|
||||
return text.length > 400_000 ? null : text;
|
||||
}
|
||||
|
||||
const report = [];
|
||||
function note(line) {
|
||||
report.push(line);
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`ICON CRM Migration – Quelle ${BASE}`);
|
||||
console.log(WRITE ? 'Modus: SCHREIBEN\n' : 'Modus: Probelauf (nichts wird geschrieben)\n');
|
||||
|
||||
await initDatabase();
|
||||
|
||||
const source = {};
|
||||
for (const [key, table] of Object.entries(TABLES)) {
|
||||
try {
|
||||
source[key] = await fetchTable(table);
|
||||
console.log(`gelesen: ${table} – ${source[key].length} Zeile(n)`);
|
||||
} catch (err) {
|
||||
source[key] = [];
|
||||
console.warn(`übersprungen: ${table} – ${err.message}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
/* ------------------------------------------------------------- Benutzer */
|
||||
|
||||
const userIds = new Map();
|
||||
for (const row of source.users ?? []) {
|
||||
const username = clean(row.username, 60);
|
||||
if (!username) continue;
|
||||
|
||||
const [existing] = await pool.query('SELECT id FROM icon_users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existing[0]) {
|
||||
userIds.set(row.id, existing[0].id);
|
||||
continue;
|
||||
}
|
||||
if (!WRITE) {
|
||||
note(`+ Benutzer ${username}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Der alte Hash ist SHA-256. Er wird uebernommen; beim ersten Login
|
||||
// stellt das Backend ihn auf bcrypt um (siehe routes/auth.js).
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO icon_users (username, email, name, role, password_hash, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, 1)`,
|
||||
[
|
||||
username,
|
||||
clean(row.email, 190),
|
||||
clean(row.name, 190) || username,
|
||||
row.role === 'admin' ? 'admin' : 'user',
|
||||
String(row.password_hash ?? ''),
|
||||
]
|
||||
);
|
||||
userIds.set(row.id, result.insertId);
|
||||
note(`+ Benutzer ${username}`);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Unternehmen */
|
||||
|
||||
const companyIds = new Map(); // alte Kennung -> neue Kennung
|
||||
const companyByName = new Map(); // Name (klein) -> neue Kennung
|
||||
|
||||
const [presentCompanies] = await pool.query('SELECT id, name FROM icon_companies');
|
||||
for (const row of presentCompanies) companyByName.set(row.name.toLowerCase(), row.id);
|
||||
|
||||
for (const row of source.customers ?? []) {
|
||||
const name = clean(row.company ?? row.name, 190);
|
||||
if (!name) continue;
|
||||
|
||||
const known = companyByName.get(name.toLowerCase());
|
||||
if (known) {
|
||||
companyIds.set(row.id, known);
|
||||
continue;
|
||||
}
|
||||
if (!WRITE) {
|
||||
note(`+ Unternehmen ${name} (Kunde)`);
|
||||
companyIds.set(row.id, -1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO icon_companies (name, website, address, logo, notes, is_customer, customer_state)
|
||||
VALUES (?, ?, ?, ?, ?, 1, 'aktiv')`,
|
||||
[name, clean(row.website, 300), clean(row.address, 2000), image(row.logo), clean(row.notes, 60000)]
|
||||
);
|
||||
companyIds.set(row.id, result.insertId);
|
||||
companyByName.set(name.toLowerCase(), result.insertId);
|
||||
note(`+ Unternehmen ${name} (Kunde)`);
|
||||
}
|
||||
|
||||
/** Unternehmen zu einem Freitextnamen finden oder anlegen. */
|
||||
async function companyForName(value) {
|
||||
const name = clean(value, 190);
|
||||
if (!name) return null;
|
||||
const known = companyByName.get(name.toLowerCase());
|
||||
if (known) return known;
|
||||
if (!WRITE) {
|
||||
note(`+ Unternehmen ${name} (aus Freitext)`);
|
||||
return null;
|
||||
}
|
||||
const [result] = await pool.query('INSERT INTO icon_companies (name) VALUES (?)', [name]);
|
||||
companyByName.set(name.toLowerCase(), result.insertId);
|
||||
note(`+ Unternehmen ${name} (aus Freitext)`);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Kontakte */
|
||||
|
||||
const contactIds = new Map();
|
||||
const contactByName = new Map();
|
||||
|
||||
const [presentContacts] = await pool.query(
|
||||
"SELECT id, CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts"
|
||||
);
|
||||
for (const row of presentContacts) contactByName.set(String(row.name).toLowerCase(), row.id);
|
||||
|
||||
async function insertContact(row, kind, companyId) {
|
||||
const { first_name, last_name } = splitName(row.name);
|
||||
const full = [first_name, last_name].filter(Boolean).join(' ');
|
||||
|
||||
const known = contactByName.get(full.toLowerCase());
|
||||
if (known) return known;
|
||||
if (!WRITE) {
|
||||
note(`+ Kontakt ${full} (${kind})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO icon_contacts
|
||||
(first_name, last_name, position, email, phone, linkedin, avatar, company_id,
|
||||
kind, status, bio, tags, rate_onsite, rate_remote, cv_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
first_name,
|
||||
last_name,
|
||||
clean(row.role, 190),
|
||||
clean(row.email, 190),
|
||||
clean(row.phone, 80),
|
||||
clean(row.linkedin, 300),
|
||||
image(row.avatar),
|
||||
companyId,
|
||||
kind,
|
||||
STATUS_MAP[row.status] ?? 'Interessiert',
|
||||
clean(row.bio, 60000),
|
||||
Array.isArray(row.tags) ? row.tags.join(', ').slice(0, 1000) : clean(row.tags, 1000),
|
||||
money(row.hourlyRateOnsite ?? row.hourly_rate_onsite),
|
||||
money(row.hourlyRateRemote ?? row.hourly_rate_remote),
|
||||
clean(row.cv_url, 500),
|
||||
]
|
||||
);
|
||||
|
||||
// Der Werdegang beginnt mit dem aktuellen Arbeitgeber - erst dadurch kann
|
||||
// ein spaeterer Wechsel den Zweig "Verlassen" fuellen.
|
||||
if (companyId) {
|
||||
await pool.query(
|
||||
`INSERT INTO icon_employments (contact_id, company_id, position, is_current)
|
||||
VALUES (?, ?, ?, 1)`,
|
||||
[result.insertId, companyId, clean(row.role, 190)]
|
||||
);
|
||||
}
|
||||
|
||||
contactByName.set(full.toLowerCase(), result.insertId);
|
||||
note(`+ Kontakt ${full} (${kind})`);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
for (const row of source.experts ?? []) {
|
||||
const companyId = await companyForName(row.company);
|
||||
const id = await insertContact(row, 'Experte', companyId);
|
||||
if (id) contactIds.set(row.id, id);
|
||||
}
|
||||
|
||||
for (const row of source.contacts ?? []) {
|
||||
const companyId = companyIds.get(row.customer_id) ?? null;
|
||||
const id = await insertContact(row, 'Ansprechpartner', companyId && companyId > 0 ? companyId : null);
|
||||
if (id) contactIds.set(row.id, id);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Ausschreibungen */
|
||||
|
||||
const tenderIds = new Map();
|
||||
for (const row of source.tenders ?? []) {
|
||||
const title = clean(row.title, 300);
|
||||
if (!title) continue;
|
||||
|
||||
const [existing] = await pool.query('SELECT id FROM icon_tenders WHERE title = ? LIMIT 1', [title]);
|
||||
if (existing[0]) {
|
||||
tenderIds.set(row.id, existing[0].id);
|
||||
continue;
|
||||
}
|
||||
if (!WRITE) {
|
||||
note(`+ Ausschreibung ${title}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO icon_tenders (title, source, link, company_id, status, stage, online, details, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
title,
|
||||
clean(row.source, 190),
|
||||
clean(row.link, 600),
|
||||
companyIds.get(row.customer_id) ?? null,
|
||||
TENDER_STATUS.has(row.status) ? row.status : 'Aktiv',
|
||||
STAGE_MAP[row.kanbanStatus ?? row.kanban_status] ?? 'Ausschreibung',
|
||||
row.online === false ? 0 : 1,
|
||||
clean(row.details, 60000),
|
||||
clean(row.notes, 60000),
|
||||
]
|
||||
);
|
||||
tenderIds.set(row.id, result.insertId);
|
||||
note(`+ Ausschreibung ${title}`);
|
||||
}
|
||||
|
||||
for (const row of source.tenderExperts ?? []) {
|
||||
const tenderId = tenderIds.get(row.tender_id);
|
||||
const contactId = contactIds.get(row.expert_id);
|
||||
if (!tenderId || !contactId) continue;
|
||||
if (!WRITE) continue;
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO icon_tender_contacts (tender_id, contact_id) VALUES (?, ?)',
|
||||
[tenderId, contactId]
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Projekte */
|
||||
|
||||
for (const row of source.projects ?? []) {
|
||||
const title = clean(row.title, 300);
|
||||
if (!title) continue;
|
||||
|
||||
const [existing] = await pool.query('SELECT id FROM icon_projects WHERE title = ? LIMIT 1', [title]);
|
||||
if (existing[0]) continue;
|
||||
if (!WRITE) {
|
||||
note(`+ Projekt ${title}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kunde und Experte standen im alten System als Freitext im Projekt.
|
||||
const companyId = await companyForName(row.customer);
|
||||
const expert = clean(row.expert, 190);
|
||||
const contactId = expert ? (contactByName.get(expert.toLowerCase()) ?? null) : null;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO icon_projects (title, company_id, contact_id, started_on, ended_on, status, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
title,
|
||||
companyId,
|
||||
contactId,
|
||||
isoDate(row.start),
|
||||
isoDate(row.end),
|
||||
PROJECT_STATUS.has(row.status) ? row.status : 'Aktiv',
|
||||
clean(row.notes, 60000),
|
||||
]
|
||||
);
|
||||
note(`+ Projekt ${title}`);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Bilanz */
|
||||
|
||||
console.log('');
|
||||
if (WRITE) {
|
||||
const [[counts]] = await pool.query(
|
||||
`SELECT (SELECT COUNT(*) FROM icon_companies) AS unternehmen,
|
||||
(SELECT COUNT(*) FROM icon_contacts) AS kontakte,
|
||||
(SELECT COUNT(*) FROM icon_tenders) AS ausschreibungen,
|
||||
(SELECT COUNT(*) FROM icon_projects) AS projekte,
|
||||
(SELECT COUNT(*) FROM icon_users) AS benutzer`
|
||||
);
|
||||
console.log('Bestand nach der Migration:', counts);
|
||||
} else {
|
||||
console.log(`Probelauf beendet – ${report.length} Datensatz/Datensaetze wuerden angelegt.`);
|
||||
console.log('Zum Schreiben denselben Aufruf mit --write wiederholen.');
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Migration fehlgeschlagen:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import express from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import {
|
||||
askModel,
|
||||
aiConfigured,
|
||||
aiModel,
|
||||
aiStatus,
|
||||
saveAiKey,
|
||||
saveAiModel,
|
||||
} from '../ai.js';
|
||||
import { requireAdmin } from '../middleware/auth.js';
|
||||
import { logHistory } from '../history.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
/*
|
||||
* Schreibhilfe fuer die Ausschreibungstexte.
|
||||
*
|
||||
* Der Editor schickt den aktuellen Text und eine Aufgabe; zurueck kommt HTML in
|
||||
* genau dem Umfang, den der Editor auch darstellen kann.
|
||||
*
|
||||
* Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit
|
||||
* OpenAI.
|
||||
*/
|
||||
|
||||
const TASKS = {
|
||||
write: {
|
||||
label: 'Text erzeugen',
|
||||
instruction:
|
||||
'Schreibe eine vollständige, sachliche Projektausschreibung auf Deutsch. Gliedere sie in ' +
|
||||
'Überschriften: Rolle, Aufgaben, Anforderungen, Rahmenbedingungen.',
|
||||
},
|
||||
improve: {
|
||||
label: 'Verbessern',
|
||||
instruction:
|
||||
'Überarbeite den Text: klarer, sachlicher, ohne Floskeln und Wiederholungen. Inhalt und ' +
|
||||
'Aussage dürfen sich nicht ändern, es darf nichts hinzukommen, was nicht dasteht.',
|
||||
},
|
||||
shorten: {
|
||||
label: 'Kürzen',
|
||||
instruction:
|
||||
'Kürze den Text auf etwa die Hälfte, ohne eine inhaltliche Angabe zu verlieren. ' +
|
||||
'Anforderungen und Rahmenbedingungen bleiben vollständig.',
|
||||
},
|
||||
bullets: {
|
||||
label: 'Als Stichpunkte',
|
||||
instruction:
|
||||
'Forme den Text zu übersichtlichen Stichpunktlisten um, gruppiert nach Aufgaben und ' +
|
||||
'Anforderungen. Keine Angabe weglassen.',
|
||||
},
|
||||
profile: {
|
||||
label: 'Anforderungsprofil',
|
||||
instruction:
|
||||
'Leite aus dem Text ein knappes Anforderungsprofil ab: welche Fähigkeiten, welche Jahre ' +
|
||||
'Erfahrung, welche Branchenkenntnis. Nur, was im Text belegt ist.',
|
||||
},
|
||||
};
|
||||
|
||||
const SYSTEM = `Du bist Texter einer Personalvermittlung für IT- und Management-Beratung.
|
||||
Du antwortest ausschließlich mit dem fertigen Text als einfaches HTML.
|
||||
Erlaubt sind nur: <h2> <h3> <p> <ul> <ol> <li> <strong> <em> <br>.
|
||||
Kein <html>, kein <body>, keine Code-Zäune, keine Erklärung, keine Anrede an den Nutzer.
|
||||
Sprache: Deutsch, sachlich, keine Werbefloskeln. Nichts erfinden, was nicht in der Vorlage steht.`;
|
||||
|
||||
// GET /api/ai/status - die Oberflaeche blendet die Knoepfe ohne Schluessel aus.
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({ configured: aiConfigured(), model: aiModel(), tasks: Object.keys(TASKS) });
|
||||
});
|
||||
|
||||
// POST /api/ai/text { task, current?, prompt? }
|
||||
router.post('/text', async (req, res, next) => {
|
||||
const task = String(req.body?.task || 'write');
|
||||
if (!TASKS[task]) return res.status(400).json({ error: 'Unbekannte Aufgabe.' });
|
||||
|
||||
const current = String(req.body?.current || '').slice(0, 40_000);
|
||||
const prompt = String(req.body?.prompt || '').slice(0, 4_000);
|
||||
|
||||
if (task !== 'write' && !current.trim()) {
|
||||
return res.status(400).json({ error: 'Es gibt noch keinen Text zum Überarbeiten.' });
|
||||
}
|
||||
if (task === 'write' && !prompt.trim() && !current.trim()) {
|
||||
return res.status(400).json({ error: 'Bitte kurz beschreiben, worum es geht.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await askModel({
|
||||
system: SYSTEM,
|
||||
user: [
|
||||
TASKS[task].instruction,
|
||||
prompt.trim() ? `\nVorgabe des Nutzers:\n${prompt.trim()}` : '',
|
||||
current.trim() ? `\nBisheriger Text:\n${current.trim()}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
maxTokens: 6000,
|
||||
});
|
||||
|
||||
res.json({ text: stripFences(text), model: aiModel() });
|
||||
} catch (err) {
|
||||
if (err?.status) return res.status(err.status).json({ error: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Schluessel und Modell verwalten (Einstellungen -> KI, nur Administratoren)
|
||||
*
|
||||
* Der Schluessel geht nie zum Browser zurueck - weder hier noch sonst irgendwo.
|
||||
* Gelesen wird er ausschliesslich verkuerzt; geschrieben wird er einmal und
|
||||
* liegt danach in der Datenbank.
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
// GET /api/ai/config
|
||||
router.get('/config', requireAdmin, (req, res) => {
|
||||
res.json(aiStatus());
|
||||
});
|
||||
|
||||
// PATCH /api/ai/config - { key?, model? }; key === '' entfernt ihn wieder.
|
||||
router.patch('/config', requireAdmin, async (req, res, next) => {
|
||||
const body = req.body || {};
|
||||
const hasKey = Object.prototype.hasOwnProperty.call(body, 'key');
|
||||
const hasModel = Object.prototype.hasOwnProperty.call(body, 'model');
|
||||
if (!hasKey && !hasModel) {
|
||||
return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||
}
|
||||
|
||||
const key = hasKey ? String(body.key ?? '').trim() : null;
|
||||
if (hasKey && key && !/^sk-[A-Za-z0-9_-]{20,}$/.test(key)) {
|
||||
// Nur die Form pruefen, nicht die Gueltigkeit - die zeigt der Test.
|
||||
return res.status(400).json({ error: 'Das sieht nicht nach einem OpenAI-Schlüssel aus (beginnt mit "sk-").' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasKey) await saveAiKey(key);
|
||||
if (hasModel) await saveAiModel(body.model);
|
||||
|
||||
/*
|
||||
* In die Historie kommt, DASS jemand den Schluessel gewechselt hat - nie
|
||||
* der Wert. Ein stiller Wechsel waere spaeter nicht mehr nachvollziehbar.
|
||||
*/
|
||||
await logHistory({
|
||||
entityType: 'system',
|
||||
entityId: null,
|
||||
entityLabel: 'KI',
|
||||
action: 'updated',
|
||||
text: [
|
||||
hasKey ? (key ? 'OpenAI-Schlüssel hinterlegt' : 'OpenAI-Schlüssel entfernt') : null,
|
||||
hasModel ? `Modell: ${aiModel()}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', '),
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
res.json(aiStatus());
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* POST /api/ai/test - ein winziger Aufruf gegen das Modell.
|
||||
*
|
||||
* Ohne ihn merkt man einen falschen Schluessel erst, wenn jemand mitten in der
|
||||
* Arbeit einen Lebenslauf auslesen will.
|
||||
*/
|
||||
router.post('/test', requireAdmin, async (req, res) => {
|
||||
if (!aiConfigured()) {
|
||||
return res.status(503).json({ error: 'Es ist kein Schlüssel hinterlegt.' });
|
||||
}
|
||||
const started = Date.now();
|
||||
try {
|
||||
const text = await askModel({
|
||||
system: 'Antworte mit genau einem Wort.',
|
||||
user: 'Sag OK.',
|
||||
maxTokens: 300,
|
||||
});
|
||||
res.json({ ok: true, model: aiModel(), ms: Date.now() - started, answer: text.trim().slice(0, 40) });
|
||||
} catch (err) {
|
||||
res.status(err?.status ?? 502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Modelle setzen die Antwort gern in ```html … ``` - das waere im Editor
|
||||
* sichtbarer Muell.
|
||||
*/
|
||||
function stripFences(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/^\s*```(?:html)?\s*/i, '')
|
||||
.replace(/\s*```\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,169 @@
|
||||
import express from 'express';
|
||||
import { createHash } from 'node:crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { pool } from '../db.js';
|
||||
import { signToken, requireAuth } from '../middleware/auth.js';
|
||||
import { normalizeAvatar, normalizeColor, normalizeTheme, normalizeThemeMode } from '../userFields.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
export function publicUser(row) {
|
||||
/*
|
||||
* Ein Design, das es nicht mehr gibt, wird als "nicht gesetzt" ausgeliefert.
|
||||
*
|
||||
* Die Prüfung beim Schreiben reicht dafür nicht: sie greift erst ab dem
|
||||
* Zeitpunkt, zu dem ein Wert aus der Liste fliegt. Wer ihn vorher gewählt
|
||||
* hatte, trägt ihn weiter in der Datenbank - und die Oberfläche bekäme einen
|
||||
* Namen, zu dem sie keine Beschriftung kennt.
|
||||
*/
|
||||
const theme = normalizeTheme(row.theme ?? null);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email ?? null,
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
color: row.color ?? null,
|
||||
avatar: row.avatar ?? null,
|
||||
theme: theme.error ? null : theme.value,
|
||||
theme_mode: row.theme_mode ?? null,
|
||||
is_active: Boolean(row.is_active),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// POST /api/auth/login { identifier, password }
|
||||
router.post('/login', async (req, res, next) => {
|
||||
const identifier = String(req.body?.identifier || '').trim();
|
||||
const password = String(req.body?.password || '');
|
||||
|
||||
if (!identifier || !password) {
|
||||
return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT * FROM icon_users WHERE username = ? OR email = ? LIMIT 1',
|
||||
[identifier, identifier.toLowerCase()]
|
||||
);
|
||||
const user = rows[0];
|
||||
|
||||
// Bewusst dieselbe Meldung fuer "kein Benutzer" und "falsches Passwort",
|
||||
// damit sich ueber die API keine Benutzernamen aufzaehlen lassen.
|
||||
const invalid = () => res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' });
|
||||
if (!user) return invalid();
|
||||
|
||||
const ok = await verifyPassword(password, user);
|
||||
if (!ok) return invalid();
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' });
|
||||
}
|
||||
|
||||
res.json({ token: signToken(user), user: publicUser(user) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Passwortpruefung mit Uebergang aus dem alten CRM.
|
||||
*
|
||||
* Dort lagen die Passwoerter als ungesalzenes SHA-256 in Supabase. Solche
|
||||
* Hashes sind an ihrer Form erkennbar (64 Hexziffern) - ein bcrypt-Hash
|
||||
* beginnt immer mit $2. Stimmt ein Altpasswort, wird es im selben Zug auf
|
||||
* bcrypt umgeschrieben: beim naechsten Mal gilt nur noch der starke Hash.
|
||||
*
|
||||
* Das ist bewusst kein dauerhafter zweiter Weg, sondern eine Einbahnstrasse -
|
||||
* neu vergeben wird ausschliesslich bcrypt.
|
||||
*/
|
||||
async function verifyPassword(password, user) {
|
||||
const hash = String(user.password_hash ?? '');
|
||||
|
||||
if (/^[a-f0-9]{64}$/i.test(hash)) {
|
||||
const legacy = createHash('sha256').update(password, 'utf8').digest('hex');
|
||||
if (legacy.toLowerCase() !== hash.toLowerCase()) return false;
|
||||
|
||||
await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [
|
||||
await bcrypt.hash(password, 12),
|
||||
user.id,
|
||||
]);
|
||||
console.log(`[ICON CRM] Passwort von "${user.username}" auf bcrypt umgestellt.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
res.json({ user: publicUser(req.user) });
|
||||
});
|
||||
|
||||
// PATCH /api/auth/me { color?, avatar?, theme?, theme_mode? }
|
||||
// Jeder darf seine eigene Farbe, sein Foto und seine Darstellung setzen.
|
||||
// null bzw. "" setzt das Feld zurueck.
|
||||
router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
const changed = {};
|
||||
|
||||
for (const [field, normalize] of [
|
||||
['color', normalizeColor],
|
||||
['avatar', normalizeAvatar],
|
||||
['theme', normalizeTheme],
|
||||
['theme_mode', normalizeThemeMode],
|
||||
]) {
|
||||
if (req.body?.[field] === undefined) continue;
|
||||
const result = normalize(req.body[field]);
|
||||
if (result.error) return res.status(400).json({ error: result.error });
|
||||
fields.push(`${field} = ?`);
|
||||
values.push(result.value);
|
||||
changed[field] = result.value;
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query(`UPDATE icon_users SET ${fields.join(', ')} WHERE id = ?`, [...values, req.user.id]);
|
||||
res.json({ user: publicUser({ ...req.user, ...changed }) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/change-password { current_password, new_password }
|
||||
router.post('/change-password', requireAuth, async (req, res, next) => {
|
||||
const current = String(req.body?.current_password || '');
|
||||
const next_ = String(req.body?.new_password || '');
|
||||
|
||||
if (!current || !next_) {
|
||||
return res.status(400).json({ error: 'Aktuelles und neues Passwort sind erforderlich.' });
|
||||
}
|
||||
if (next_.length < 8) {
|
||||
return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT password_hash FROM icon_users WHERE id = ? LIMIT 1', [
|
||||
req.user.id,
|
||||
]);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
||||
|
||||
const ok = await bcrypt.compare(current, rows[0].password_hash);
|
||||
if (!ok) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' });
|
||||
|
||||
await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [
|
||||
await bcrypt.hash(next_, 12),
|
||||
req.user.id,
|
||||
]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,379 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { logHistory, diffFields, describeChanges, indexMentions } from '../history.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
/*
|
||||
* Unternehmen - die fruehere Ansicht "Kunden".
|
||||
*
|
||||
* Ein Kunde ist hier kein eigener Datentyp mehr, sondern ein Unternehmen mit
|
||||
* is_customer = 1. Der Umschalter in der Oberflaeche schickt deshalb nur den
|
||||
* Parameter `scope` und bekommt dieselbe Struktur zurueck.
|
||||
*/
|
||||
|
||||
const EDITABLE = [
|
||||
'name',
|
||||
'group_name',
|
||||
'industry',
|
||||
'employees',
|
||||
'website',
|
||||
'address',
|
||||
'logo',
|
||||
'rating',
|
||||
'is_customer',
|
||||
'customer_state',
|
||||
'freelancers',
|
||||
'notes',
|
||||
'owner_id',
|
||||
'primary_contact_id',
|
||||
];
|
||||
|
||||
function readBody(body) {
|
||||
const out = {};
|
||||
if (body.name !== undefined) out.name = String(body.name).trim().slice(0, 190);
|
||||
if (body.group_name !== undefined) out.group_name = emptyToNull(body.group_name, 190);
|
||||
if (body.industry !== undefined) out.industry = emptyToNull(body.industry, 190);
|
||||
if (body.employees !== undefined) {
|
||||
const n = Number(body.employees);
|
||||
out.employees = Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
|
||||
}
|
||||
if (body.website !== undefined) out.website = emptyToNull(body.website, 300);
|
||||
if (body.address !== undefined) out.address = emptyToNull(body.address, 2000);
|
||||
if (body.logo !== undefined) out.logo = body.logo ? String(body.logo) : null;
|
||||
if (body.rating !== undefined) {
|
||||
const n = Number(body.rating);
|
||||
out.rating = Number.isFinite(n) ? Math.min(5, Math.max(0, Math.round(n))) : 0;
|
||||
}
|
||||
if (body.is_customer !== undefined) out.is_customer = body.is_customer ? 1 : 0;
|
||||
if (body.customer_state !== undefined) {
|
||||
out.customer_state = body.customer_state === 'alumni' ? 'alumni' : 'aktiv';
|
||||
}
|
||||
if (body.freelancers !== undefined) {
|
||||
out.freelancers = ['ja', 'nein'].includes(body.freelancers) ? body.freelancers : 'unbekannt';
|
||||
}
|
||||
if (body.notes !== undefined) out.notes = body.notes ? String(body.notes) : null;
|
||||
if (body.owner_id !== undefined) {
|
||||
const n = Number(body.owner_id);
|
||||
out.owner_id = Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
if (body.primary_contact_id !== undefined) {
|
||||
const n = Number(body.primary_contact_id);
|
||||
out.primary_contact_id = Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function emptyToNull(value, max) {
|
||||
const text = String(value ?? '').trim();
|
||||
return text ? text.slice(0, max) : null;
|
||||
}
|
||||
|
||||
// GET /api/companies?scope=all|customers|alumni&q=&industry=&group=&rating_min=
|
||||
router.get('/', async (req, res, next) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
const scope = String(req.query.scope || 'all');
|
||||
if (scope === 'customers') where.push("c.is_customer = 1 AND c.customer_state = 'aktiv'");
|
||||
else if (scope === 'alumni') where.push("c.is_customer = 1 AND c.customer_state = 'alumni'");
|
||||
else if (scope === 'kunden') where.push('c.is_customer = 1');
|
||||
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (q) {
|
||||
where.push('(c.name LIKE ? OR c.group_name LIKE ? OR c.industry LIKE ? OR c.address LIKE ?)');
|
||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
|
||||
}
|
||||
if (req.query.industry) {
|
||||
where.push('c.industry = ?');
|
||||
params.push(String(req.query.industry));
|
||||
}
|
||||
if (req.query.group) {
|
||||
where.push('c.group_name = ?');
|
||||
params.push(String(req.query.group));
|
||||
}
|
||||
if (req.query.rating_min) {
|
||||
where.push('c.rating >= ?');
|
||||
params.push(Number(req.query.rating_min) || 0);
|
||||
}
|
||||
if (req.query.freelancers) {
|
||||
where.push('c.freelancers = ?');
|
||||
params.push(String(req.query.freelancers));
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT c.*,
|
||||
(SELECT COUNT(*) FROM icon_contacts k
|
||||
WHERE k.company_id = c.id) AS contact_count,
|
||||
(SELECT COUNT(*) FROM icon_employments e
|
||||
WHERE e.company_id = c.id AND e.is_current = 0) AS former_count
|
||||
FROM icon_companies c
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY c.name`,
|
||||
params
|
||||
);
|
||||
res.json({ companies: rows.map(normalizeRow) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Auswahllisten fuer Branche und Gruppe - gespeist aus dem Bestand. */
|
||||
router.get('/options', async (req, res, next) => {
|
||||
try {
|
||||
const [industries] = await pool.query(
|
||||
"SELECT DISTINCT industry FROM icon_companies WHERE industry IS NOT NULL AND industry <> '' ORDER BY industry"
|
||||
);
|
||||
const [groups] = await pool.query(
|
||||
"SELECT DISTINCT group_name FROM icon_companies WHERE group_name IS NOT NULL AND group_name <> '' ORDER BY group_name"
|
||||
);
|
||||
res.json({
|
||||
industries: industries.map((r) => r.industry),
|
||||
groups: groups.map((r) => r.group_name),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/companies/:id - Karte inklusive Stammbaum, Interviews und Historie
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM icon_companies WHERE id = ? LIMIT 1', [id]);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Unternehmen nicht gefunden.' });
|
||||
|
||||
// Aktuelle Belegschaft, soweit sie uns bekannt ist.
|
||||
const [current] = await pool.query(
|
||||
`SELECT id, first_name, last_name, position, email, phone, avatar, kind, status
|
||||
FROM icon_contacts WHERE company_id = ? ORDER BY last_name, first_name`,
|
||||
[id]
|
||||
);
|
||||
|
||||
/*
|
||||
* Der Zweig "Verlassen" des Stammbaums: alle, die hier einmal gearbeitet
|
||||
* haben und es jetzt nicht mehr tun. Ausgeschlossen wird, wer laut
|
||||
* icon_contacts aktuell wieder hier ist - sonst stuende derselbe Mensch in
|
||||
* beiden Zweigen.
|
||||
*/
|
||||
const [former] = await pool.query(
|
||||
`SELECT e.id AS employment_id, e.position, e.started_on, e.ended_on,
|
||||
k.id, k.first_name, k.last_name, k.avatar, k.kind, k.status,
|
||||
k.company_id AS now_company_id, n.name AS now_company
|
||||
FROM icon_employments e
|
||||
JOIN icon_contacts k ON k.id = e.contact_id
|
||||
LEFT JOIN icon_companies n ON n.id = k.company_id
|
||||
WHERE e.company_id = ? AND e.is_current = 0
|
||||
AND (k.company_id IS NULL OR k.company_id <> ?)
|
||||
ORDER BY e.ended_on DESC, k.last_name`,
|
||||
[id, id]
|
||||
);
|
||||
|
||||
const [interviews] = await pool.query(
|
||||
`SELECT i.*, u.name AS user_name,
|
||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name
|
||||
FROM icon_interviews i
|
||||
LEFT JOIN icon_users u ON u.id = i.user_id
|
||||
LEFT JOIN icon_contacts k ON k.id = i.contact_id
|
||||
WHERE i.company_id = ? ORDER BY i.held_on DESC`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const [history] = await pool.query(
|
||||
`SELECT h.*, u.name AS user_name, u.color AS user_color, u.avatar AS user_avatar
|
||||
FROM icon_history h
|
||||
LEFT JOIN icon_users u ON u.id = h.user_id
|
||||
WHERE h.entity_type = 'company' AND h.entity_id = ?
|
||||
ORDER BY h.created_at DESC, h.id DESC LIMIT 500`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// Wo wurde dieses Unternehmen ueberall per @ genannt?
|
||||
const [mentioned] = await pool.query(
|
||||
`SELECT h.*, u.name AS user_name, u.color AS user_color
|
||||
FROM icon_mentions m
|
||||
JOIN icon_history h ON h.id = m.source_id AND m.source_type = 'history'
|
||||
LEFT JOIN icon_users u ON u.id = h.user_id
|
||||
WHERE m.target_type = 'company' AND m.target_id = ?
|
||||
ORDER BY h.created_at DESC LIMIT 200`,
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
company: normalizeRow(rows[0]),
|
||||
contacts: current,
|
||||
former,
|
||||
interviews,
|
||||
history: history.map(normalizeHistory),
|
||||
mentioned: mentioned.map(normalizeHistory),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
const data = readBody(req.body || {});
|
||||
if (!data.name) return res.status(400).json({ error: 'Der Name des Unternehmens ist erforderlich.' });
|
||||
|
||||
try {
|
||||
const columns = Object.keys(data);
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO icon_companies (${columns.join(', ')}, created_by)
|
||||
VALUES (${columns.map(() => '?').join(', ')}, ?)`,
|
||||
[...columns.map((c) => data[c]), req.user.id]
|
||||
);
|
||||
|
||||
await logHistory({
|
||||
entityType: 'company',
|
||||
entityId: result.insertId,
|
||||
entityLabel: data.name,
|
||||
action: 'created',
|
||||
text: `${data.name} neu angelegt`,
|
||||
userId: req.user.id,
|
||||
});
|
||||
if (data.notes) await indexMentions('company', result.insertId, data.notes);
|
||||
|
||||
const [rows] = await pool.query('SELECT * FROM icon_companies WHERE id = ?', [result.insertId]);
|
||||
res.status(201).json({ company: normalizeRow(rows[0]) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM icon_companies WHERE id = ? LIMIT 1', [id]);
|
||||
const before = rows[0];
|
||||
if (!before) return res.status(404).json({ error: 'Unternehmen nicht gefunden.' });
|
||||
|
||||
const data = readBody(req.body || {});
|
||||
if (data.name !== undefined && !data.name) {
|
||||
return res.status(400).json({ error: 'Der Name des Unternehmens ist erforderlich.' });
|
||||
}
|
||||
const columns = Object.keys(data);
|
||||
if (columns.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||
|
||||
await pool.query(
|
||||
`UPDATE icon_companies SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...columns.map((c) => data[c]), id]
|
||||
);
|
||||
|
||||
// Das Logo interessiert in der Historie niemanden als Data-URL - der
|
||||
// Vergleich wuerde nur Hunderte Kilobyte in die Tabelle schreiben.
|
||||
const changes = diffFields(before, data, EDITABLE.filter((f) => f !== 'logo'));
|
||||
if (data.logo !== undefined && (before.logo ?? null) !== (data.logo ?? null)) {
|
||||
changes.logo = ['', data.logo ? 'gesetzt' : 'entfernt'];
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await logHistory({
|
||||
entityType: 'company',
|
||||
entityId: id,
|
||||
entityLabel: data.name ?? before.name,
|
||||
action: 'updated',
|
||||
text: `${data.name ?? before.name}: ${describeChanges(changes)}`,
|
||||
changes,
|
||||
userId: req.user.id,
|
||||
});
|
||||
}
|
||||
if (data.notes !== undefined) await indexMentions('company', id, data.notes);
|
||||
|
||||
const [updated] = await pool.query('SELECT * FROM icon_companies WHERE id = ?', [id]);
|
||||
res.json({ company: normalizeRow(updated[0]) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT name FROM icon_companies WHERE id = ? LIMIT 1', [id]);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Unternehmen nicht gefunden.' });
|
||||
|
||||
await pool.query('DELETE FROM icon_companies WHERE id = ?', [id]);
|
||||
await logHistory({
|
||||
entityType: 'company',
|
||||
entityId: null,
|
||||
entityLabel: rows[0].name,
|
||||
action: 'deleted',
|
||||
text: `${rows[0].name} gelöscht`,
|
||||
userId: req.user.id,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/companies/:id/interviews
|
||||
router.post('/:id/interviews', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
const heldOn = String(req.body?.held_on || '').slice(0, 10);
|
||||
if (!Number.isInteger(id) || !/^\d{4}-\d{2}-\d{2}$/.test(heldOn)) {
|
||||
return res.status(400).json({ error: 'Unternehmen und Datum sind erforderlich.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const contactId = Number(req.body?.contact_id) || null;
|
||||
const userId = Number(req.body?.user_id) || req.user.id;
|
||||
const notes = req.body?.notes ? String(req.body.notes) : null;
|
||||
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO icon_interviews (contact_id, company_id, user_id, held_on, notes, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[contactId, id, userId, heldOn, notes, req.user.id]
|
||||
);
|
||||
|
||||
const [[company]] = await pool.query('SELECT name FROM icon_companies WHERE id = ?', [id]);
|
||||
await logHistory({
|
||||
entityType: 'company',
|
||||
entityId: id,
|
||||
entityLabel: company?.name,
|
||||
action: 'interview',
|
||||
text: `Interview am ${heldOn}${notes ? ` – ${notes}` : ''}`,
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
res.status(201).json({ id: result.insertId });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeRow(row) {
|
||||
return {
|
||||
...row,
|
||||
is_customer: Boolean(row.is_customer),
|
||||
rating: Number(row.rating ?? 0),
|
||||
employees: row.employees === null ? null : Number(row.employees),
|
||||
contact_count: row.contact_count === undefined ? undefined : Number(row.contact_count),
|
||||
former_count: row.former_count === undefined ? undefined : Number(row.former_count),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHistory(row) {
|
||||
return { ...row, changes: row.changes ? safeParse(row.changes) : null };
|
||||
}
|
||||
|
||||
function safeParse(value) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
export { normalizeHistory };
|
||||
@@ -0,0 +1,102 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { logHistory } from '../history.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
/*
|
||||
* Verbindungen zwischen Kontakten ("wer kennt wen").
|
||||
*
|
||||
* Das Paar wird immer aufsteigend gespeichert - damit greift der Unique-Key
|
||||
* in beide Richtungen und A-B laesst sich nicht zusaetzlich als B-A anlegen.
|
||||
*/
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT c.*,
|
||||
CONCAT_WS(' ', a.first_name, a.last_name) AS a_name,
|
||||
CONCAT_WS(' ', b.first_name, b.last_name) AS b_name
|
||||
FROM icon_connections c
|
||||
JOIN icon_contacts a ON a.id = c.contact_a
|
||||
JOIN icon_contacts b ON b.id = c.contact_b
|
||||
ORDER BY c.id DESC`
|
||||
);
|
||||
res.json({ connections: rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
const a = Number(req.body?.contact_a);
|
||||
const b = Number(req.body?.contact_b);
|
||||
if (!Number.isInteger(a) || !Number.isInteger(b) || a < 1 || b < 1) {
|
||||
return res.status(400).json({ error: 'Zwei Kontakte sind erforderlich.' });
|
||||
}
|
||||
if (a === b) return res.status(400).json({ error: 'Ein Kontakt kann nicht mit sich selbst verbunden werden.' });
|
||||
|
||||
const [low, high] = a < b ? [a, b] : [b, a];
|
||||
const kind = String(req.body?.kind || 'Kontakt').trim().slice(0, 60) || 'Kontakt';
|
||||
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO icon_connections (contact_a, contact_b, kind, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE kind = VALUES(kind)`,
|
||||
[low, high, kind, req.user.id]
|
||||
);
|
||||
|
||||
const [names] = await pool.query(
|
||||
"SELECT id, CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id IN (?, ?)",
|
||||
[low, high]
|
||||
);
|
||||
const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt';
|
||||
|
||||
for (const id of [low, high]) {
|
||||
await logHistory({
|
||||
entityType: 'contact',
|
||||
entityId: id,
|
||||
entityLabel: nameOf(id),
|
||||
action: 'connection',
|
||||
text: `Verbindung zu ${nameOf(id === low ? high : low)} angelegt (${kind})`,
|
||||
userId: req.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({ id: result.insertId || null, contact_a: low, contact_b: high, kind });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
const kind = String(req.body?.kind || '').trim().slice(0, 60);
|
||||
if (!Number.isInteger(id) || !kind) return res.status(400).json({ error: 'Art der Verbindung fehlt.' });
|
||||
|
||||
try {
|
||||
const [result] = await pool.query('UPDATE icon_connections SET kind = ? WHERE id = ?', [kind, id]);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Verbindung nicht gefunden.' });
|
||||
res.json({ ok: true, kind });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
|
||||
|
||||
try {
|
||||
const [result] = await pool.query('DELETE FROM icon_connections WHERE id = ?', [id]);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Verbindung nicht gefunden.' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||