Quellcode des ICON CRM (3)
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user