Files
ICON-CRM/server/src/routes/companies.js
T
schakal00783andClaude Opus 5.5 cbac02a96a KI: Modellauswahl als Dropdown + Abgleich mit Produktionsstand
Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den
Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf.

- Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf
  Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct
  usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu)
- Neuer Endpunkt GET /api/ai/models (admin)
- Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet;
  Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist
- Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung

Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten
Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen,
Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:26:30 +02:00

380 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.title, 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 };