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