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
+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;