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