Quellcode des ICON CRM (3)

This commit is contained in:
2026-09-14 16:55:34 +02:00
parent 0a597ae527
commit 28763fed6d
25 changed files with 6158 additions and 0 deletions
+62
View File
@@ -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;
}
+5
View File
@@ -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": []
}
+3426
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+40
View File
@@ -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

+26
View File
@@ -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

+31
View File
@@ -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

+33
View File
@@ -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

+54
View File
@@ -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=
+23
View File
@@ -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"
}
}
+195
View File
@@ -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 };
+280
View File
@@ -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;
}
+83
View File
@@ -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}`;
}
+200
View File
@@ -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.'
);
}
}
+176
View File
@@ -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`;
}
+199
View File
@@ -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(/&amp;/g, '&')
.replace(/&#x2F;/gi, '/')
.replace(/&#38;/g, '&')
.replace(/&quot;/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 };
+62
View File
@@ -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();
}
+388
View File
@@ -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);
});
+196
View File
@@ -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;
+169
View File
@@ -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;
+379
View File
@@ -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 };
+102
View File
@@ -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;