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