Quellcode des ICON CRM (4)

This commit is contained in:
2026-09-14 16:55:36 +02:00
parent 28763fed6d
commit e383400d7c
25 changed files with 6030 additions and 0 deletions
+804
View File
@@ -0,0 +1,804 @@
import express from 'express';
import path from 'node:path';
import { pool } from '../db.js';
import { UPLOAD_DIR } from './cvs.js';
import { requireAuth } from '../middleware/auth.js';
import { logHistory, diffFields, describeChanges, indexMentions } from '../history.js';
import { normalizeAvatar } from '../userFields.js';
import { fetchLinkedInPhoto } from '../linkedinPhoto.js';
import { optionValues } from './options.js';
const router = express.Router();
router.use(requireAuth);
/*
* Kontakte - die fruehere Ansicht "Experten".
*
* Sie fasst jetzt beides: die Experten, die wir vermitteln, und die
* Ansprechpartner der Unternehmen. Unterschieden wird ueber `kind`; die
* Lead-Quelle haengt am Kontakt, nicht an einer zweiten Tabelle.
*
* Der frueher vorhandene Verfuegbarkeitsstatus ist entfallen.
*/
const EDITABLE = [
'first_name',
'last_name',
'position',
'email',
'phone',
'linkedin',
'company_id',
'kind',
'status',
'lead_source',
'contact_state',
'freelancers',
'bio',
'tags',
'rate_onsite',
'rate_remote',
'cv_url',
'owner_id',
];
const STATES = ['Interessiert', 'Interview', 'Gebucht', 'Inaktiv'];
/**
* Die Kontaktart ist kein festes ENUM mehr, sondern eine in den Einstellungen
* gepflegte Liste. Geprüft wird deshalb gegen die Datenbank - ein Tippfehler
* soll trotzdem nicht als neue Art durchrutschen.
*/
async function assertKnownKind(kind) {
if (kind === undefined) return null;
const allowed = await optionValues('contact_kind');
if (allowed.length === 0 || allowed.includes(kind)) return null;
return `„${kind}" ist keine gepflegte Kontaktart. Die Liste steht unter Einstellungen → Stammdaten.`;
}
function readBody(body) {
const out = {};
if (body.first_name !== undefined) out.first_name = trimOrNull(body.first_name, 120);
if (body.last_name !== undefined) out.last_name = String(body.last_name ?? '').trim().slice(0, 120);
if (body.position !== undefined) out.position = trimOrNull(body.position, 190);
if (body.email !== undefined) out.email = trimOrNull(body.email, 190);
if (body.phone !== undefined) out.phone = trimOrNull(body.phone, 80);
if (body.linkedin !== undefined) out.linkedin = trimOrNull(body.linkedin, 300);
if (body.company_id !== undefined) out.company_id = positiveOrNull(body.company_id);
if (body.kind !== undefined) out.kind = trimOrNull(body.kind, 60) ?? 'Experte';
if (body.status !== undefined) out.status = STATES.includes(body.status) ? body.status : 'Interessiert';
if (body.lead_source !== undefined) out.lead_source = trimOrNull(body.lead_source, 190);
if (body.contact_state !== undefined) {
out.contact_state = ['persoenlich', 'kein Kontakt'].includes(body.contact_state)
? body.contact_state
: 'unbekannt';
}
if (body.freelancers !== undefined) {
out.freelancers = ['ja', 'nein'].includes(body.freelancers) ? body.freelancers : 'unbekannt';
}
if (body.bio !== undefined) out.bio = body.bio ? String(body.bio) : null;
if (body.tags !== undefined) out.tags = normalizeTags(body.tags);
if (body.rate_onsite !== undefined) out.rate_onsite = numberOrNull(body.rate_onsite);
if (body.rate_remote !== undefined) out.rate_remote = numberOrNull(body.rate_remote);
if (body.cv_url !== undefined) out.cv_url = trimOrNull(body.cv_url, 500);
if (body.owner_id !== undefined) out.owner_id = positiveOrNull(body.owner_id);
return out;
}
/*
* Schlagworte kommen kommagetrennt aus dem Formular - so kann das spaetere
* CV-Auslesewerkzeug seine Ausgabe direkt einfuegen. Gespeichert wird
* normalisiert: getrimmt, ohne Leereintraege, ohne Duplikate.
*/
function normalizeTags(input) {
const list = Array.isArray(input) ? input : String(input ?? '').split(',');
const seen = [];
for (const raw of list) {
const tag = String(raw).trim();
if (tag && !seen.some((t) => t.toLowerCase() === tag.toLowerCase())) seen.push(tag);
}
return seen.length > 0 ? seen.join(', ').slice(0, 1000) : null;
}
const trimOrNull = (value, max) => {
const text = String(value ?? '').trim();
return text ? text.slice(0, max) : null;
};
const positiveOrNull = (value) => {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
};
const numberOrNull = (value) => {
if (value === null || value === undefined || value === '') return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
};
// GET /api/contacts?q=&kind=&status=&company_id=&tag=
router.get('/', async (req, res, next) => {
const where = [];
const params = [];
const q = String(req.query.q || '').trim();
if (q) {
where.push(
`(CONCAT_WS(' ', k.first_name, k.last_name) LIKE ? OR k.email LIKE ? OR k.position LIKE ?
OR k.tags LIKE ? OR k.bio LIKE ? OR co.name LIKE ?)`
);
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
}
if (req.query.kind) {
where.push('k.kind = ?');
params.push(String(req.query.kind));
}
if (req.query.status) {
where.push('k.status = ?');
params.push(String(req.query.status));
}
if (req.query.company_id) {
where.push('k.company_id = ?');
params.push(Number(req.query.company_id) || 0);
}
if (req.query.tag) {
where.push('k.tags LIKE ?');
params.push(`%${String(req.query.tag)}%`);
}
try {
const [rows] = await pool.query(
`SELECT k.*, co.name AS company_name, co.industry AS company_industry,
(SELECT COUNT(*) FROM icon_interviews i WHERE i.contact_id = k.id) AS interview_count
FROM icon_contacts k
LEFT JOIN icon_companies co ON co.id = k.company_id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY k.last_name, k.first_name`,
params
);
res.json({ contacts: rows.map(normalizeRow) });
} catch (err) {
next(err);
}
});
/**
* Automatisch erzeugte Liste "letzter Arbeitgeber" (Branche & Unternehmen).
*
* Genommen wird die zuletzt beendete Anstellung je Kontakt. Kontakte ohne
* beendete Anstellung tauchen nicht auf - sie haben keinen frueheren
* Arbeitgeber, den man auflisten koennte.
*/
router.get('/last-employers', async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT k.id AS contact_id, k.first_name, k.last_name, k.avatar,
co.id AS company_id, co.name AS company, co.industry,
e.position, e.ended_on
FROM icon_employments e
JOIN icon_contacts k ON k.id = e.contact_id
JOIN icon_companies co ON co.id = e.company_id
WHERE e.is_current = 0
AND e.id = (
SELECT e2.id FROM icon_employments e2
WHERE e2.contact_id = e.contact_id AND e2.is_current = 0
ORDER BY e2.ended_on IS NULL, e2.ended_on DESC, e2.id DESC
LIMIT 1
)
ORDER BY co.industry, co.name, k.last_name`
);
res.json({ entries: rows });
} catch (err) {
next(err);
}
});
// GET /api/contacts/:id
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 k.*, co.name AS company_name, co.industry AS company_industry
FROM icon_contacts k
LEFT JOIN icon_companies co ON co.id = k.company_id
WHERE k.id = ? LIMIT 1`,
[id]
);
if (!rows[0]) return res.status(404).json({ error: 'Kontakt nicht gefunden.' });
const [employments] = await pool.query(
`SELECT e.*, co.name AS company, co.industry
FROM icon_employments e
JOIN icon_companies co ON co.id = e.company_id
WHERE e.contact_id = ?
ORDER BY e.is_current DESC, e.ended_on IS NULL DESC, e.ended_on DESC`,
[id]
);
const [interviews] = await pool.query(
`SELECT i.*, u.name AS user_name, co.name AS company
FROM icon_interviews i
LEFT JOIN icon_users u ON u.id = i.user_id
LEFT JOIN icon_companies co ON co.id = i.company_id
WHERE i.contact_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 = 'contact' AND h.entity_id = ?
ORDER BY h.created_at DESC, h.id DESC LIMIT 500`,
[id]
);
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 = 'contact' AND m.target_id = ?
ORDER BY h.created_at DESC LIMIT 200`,
[id]
);
res.json({
contact: normalizeRow(rows[0]),
employments,
interviews,
history: history.map(parseChanges),
mentioned: mentioned.map(parseChanges),
});
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
const data = readBody(req.body || {});
if (!data.last_name) return res.status(400).json({ error: 'Der Nachname ist erforderlich.' });
const kindError = await assertKnownKind(data.kind);
if (kindError) return res.status(400).json({ error: kindError });
if (req.body?.avatar !== undefined) {
const avatar = normalizeAvatar(req.body.avatar);
if (avatar.error) return res.status(400).json({ error: avatar.error });
data.avatar = avatar.value;
}
try {
const columns = Object.keys(data);
const [result] = await pool.query(
`INSERT INTO icon_contacts (${columns.join(', ')}, created_by)
VALUES (${columns.map(() => '?').join(', ')}, ?)`,
[...columns.map((c) => data[c]), req.user.id]
);
const id = result.insertId;
if (data.company_id) await openEmployment(id, data.company_id, data.position ?? null);
const label = fullName(data);
await logHistory({
entityType: 'contact',
entityId: id,
entityLabel: label,
action: 'created',
text: `${label} neu angelegt`,
userId: req.user.id,
});
if (data.bio) await indexMentions('contact', id, data.bio);
res.status(201).json({ contact: await loadOne(id) });
} 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_contacts WHERE id = ? LIMIT 1', [id]);
const before = rows[0];
if (!before) return res.status(404).json({ error: 'Kontakt nicht gefunden.' });
const data = readBody(req.body || {});
if (data.last_name !== undefined && !data.last_name) {
return res.status(400).json({ error: 'Der Nachname ist erforderlich.' });
}
const kindError = await assertKnownKind(data.kind);
if (kindError) return res.status(400).json({ error: kindError });
if (req.body?.avatar !== undefined) {
const avatar = normalizeAvatar(req.body.avatar);
if (avatar.error) return res.status(400).json({ error: avatar.error });
data.avatar = avatar.value;
}
const columns = Object.keys(data);
if (columns.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
await pool.query(
`UPDATE icon_contacts SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...columns.map((c) => data[c]), id]
);
/*
* Arbeitgeberwechsel: die bisherige Anstellung wird beendet, die neue
* eroeffnet. Daraus speist sich der Zweig "Verlassen" im Stammbaum des
* alten Unternehmens - und beide Karten bekommen einen Historieneintrag,
* ohne dass jemand das von Hand pflegen muss.
*/
if (data.company_id !== undefined && (before.company_id ?? null) !== (data.company_id ?? null)) {
await closeEmployment(id, before.company_id);
if (data.company_id) await openEmployment(id, data.company_id, data.position ?? before.position);
await logEmployerChange(id, before, data, req.user.id);
}
const changes = diffFields(before, data, EDITABLE);
if (data.avatar !== undefined && (before.avatar ?? null) !== (data.avatar ?? null)) {
changes.avatar = ['', data.avatar ? 'gesetzt' : 'entfernt'];
}
if (Object.keys(changes).length > 0) {
const label = fullName({ ...before, ...data });
await logHistory({
entityType: 'contact',
entityId: id,
entityLabel: label,
action: 'updated',
text: `${label}: ${describeChanges(changes)}`,
changes,
userId: req.user.id,
});
}
if (data.bio !== undefined) await indexMentions('contact', id, data.bio);
res.json({ contact: await loadOne(id) });
} 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 * FROM icon_contacts WHERE id = ? LIMIT 1', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Kontakt nicht gefunden.' });
await pool.query('DELETE FROM icon_contacts WHERE id = ?', [id]);
await logHistory({
entityType: 'contact',
entityId: null,
entityLabel: fullName(rows[0]),
action: 'deleted',
text: `${fullName(rows[0])} gelöscht`,
userId: req.user.id,
});
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// POST /api/contacts/:id/interviews { held_on, company_id?, user_id?, notes? }
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: 'Kontakt und Datum sind erforderlich.' });
}
try {
const [rows] = await pool.query('SELECT * FROM icon_contacts WHERE id = ? LIMIT 1', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Kontakt nicht gefunden.' });
const companyId = positiveOrNull(req.body?.company_id) ?? rows[0].company_id;
const userId = positiveOrNull(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 (?, ?, ?, ?, ?, ?)`,
[id, companyId, userId, heldOn, notes, req.user.id]
);
await logHistory({
entityType: 'contact',
entityId: id,
entityLabel: fullName(rows[0]),
action: 'interview',
text: `Interview am ${heldOn}${notes ? ` – ${notes}` : ''}`,
userId: req.user.id,
});
if (companyId) {
const [[company]] = await pool.query('SELECT name FROM icon_companies WHERE id = ?', [companyId]);
await logHistory({
entityType: 'company',
entityId: companyId,
entityLabel: company?.name,
action: 'interview',
text: `Interview mit ${fullName(rows[0])} am ${heldOn}`,
userId: req.user.id,
});
}
res.status(201).json({ id: result.insertId });
} catch (err) {
next(err);
}
});
/* ---------------------------------------------------------------- Werdegang
*
* Die Stationen entstehen an drei Stellen von selbst: beim Setzen eines
* Arbeitgebers, beim Wechsel und beim CV-Import. Das genuegt nicht - ein
* Lebenslauf wird selten vollstaendig erkannt, und ein Tippfehler im Firmennamen
* liess sich bisher gar nicht mehr geradeziehen. Deshalb hier der Weg von Hand.
*
* Zwei Regeln halten Station und Kontakt zusammen:
*
* * Genau EINE Station kann "aktuell" sein. Wird eine dazu erklaert, werden
* die uebrigen geschlossen.
* * Die aktuelle Station IST der Arbeitgeber des Kontakts. Beides
* auseinanderlaufen zu lassen, waere die Quelle fuer "im Kontakt steht A,
* im Werdegang B".
*/
/** Findet ein Unternehmen ueber Kennung oder Namen; legt es notfalls an. */
async function companyRef(body, userId) {
const byId = positiveOrNull(body?.company_id);
if (byId) {
const [rows] = await pool.query('SELECT id, name FROM icon_companies WHERE id = ? LIMIT 1', [byId]);
return rows[0] ?? null;
}
const name = String(body?.company ?? '').trim().slice(0, 190);
if (!name) return null;
const [rows] = await pool.query('SELECT id, name FROM icon_companies WHERE name = ? LIMIT 1', [name]);
if (rows[0]) return rows[0];
const [result] = await pool.query('INSERT INTO icon_companies (name, created_by) VALUES (?, ?)', [
name,
userId,
]);
await logHistory({
entityType: 'company',
entityId: result.insertId,
entityLabel: name,
action: 'created',
text: `${name} ueber den Werdegang eines Kontakts angelegt`,
userId,
});
return { id: result.insertId, name };
}
/** "2019", "2019-03" und "2019-03-01" werden alle zu einem gueltigen Datum. */
function toDateOrNull(value) {
const text = String(value ?? '').trim();
if (/^\d{4}$/.test(text)) return `${text}-01-01`;
if (/^\d{4}-\d{2}$/.test(text)) return `${text}-01`;
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
return null;
}
/** Nur eine Station darf aktuell sein - und sie bestimmt den Arbeitgeber. */
async function applyCurrent(contactId, employmentId, companyId) {
await pool.query(
`UPDATE icon_employments
SET is_current = 0, ended_on = COALESCE(ended_on, CURDATE())
WHERE contact_id = ? AND id <> ? AND is_current = 1`,
[contactId, employmentId]
);
await pool.query('UPDATE icon_contacts SET company_id = ? WHERE id = ?', [companyId, contactId]);
}
// POST /api/contacts/:id/employments
router.post('/:id/employments', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Kennung.' });
try {
const [rows] = await pool.query('SELECT * FROM icon_contacts WHERE id = ? LIMIT 1', [id]);
const contact = rows[0];
if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden.' });
const company = await companyRef(req.body, req.user.id);
if (!company) return res.status(400).json({ error: 'Ohne Unternehmen keine Station.' });
const isCurrent = Boolean(req.body?.is_current);
const [result] = await pool.query(
`INSERT INTO icon_employments (contact_id, company_id, position, started_on, ended_on, is_current)
VALUES (?, ?, ?, ?, ?, ?)`,
[
id,
company.id,
String(req.body?.position ?? '').trim().slice(0, 190) || null,
toDateOrNull(req.body?.started_on),
isCurrent ? null : toDateOrNull(req.body?.ended_on),
isCurrent ? 1 : 0,
]
);
if (isCurrent) await applyCurrent(id, result.insertId, company.id);
await logHistory({
entityType: 'contact',
entityId: id,
entityLabel: fullName(contact),
action: 'employment',
text: `Station ergaenzt: ${company.name}${isCurrent ? ' (aktuell)' : ''}`,
userId: req.user.id,
});
res.status(201).json({ id: result.insertId });
} catch (err) {
next(err);
}
});
// PATCH /api/contacts/employments/:employmentId
router.patch('/employments/:employmentId', async (req, res, next) => {
const employmentId = Number(req.params.employmentId);
if (!Number.isInteger(employmentId)) return res.status(400).json({ error: 'Ungueltige Kennung.' });
try {
const [rows] = await pool.query('SELECT * FROM icon_employments WHERE id = ? LIMIT 1', [employmentId]);
const before = rows[0];
if (!before) return res.status(404).json({ error: 'Station nicht gefunden.' });
const [[contact]] = await pool.query('SELECT * FROM icon_contacts WHERE id = ? LIMIT 1', [
before.contact_id,
]);
const company = (await companyRef(req.body, req.user.id)) ?? {
id: before.company_id,
name: null,
};
const isCurrent = req.body?.is_current === undefined
? Boolean(before.is_current)
: Boolean(req.body.is_current);
await pool.query(
`UPDATE icon_employments
SET company_id = ?, position = ?, started_on = ?, ended_on = ?, is_current = ?
WHERE id = ?`,
[
company.id,
req.body?.position === undefined
? before.position
: String(req.body.position ?? '').trim().slice(0, 190) || null,
req.body?.started_on === undefined ? before.started_on : toDateOrNull(req.body.started_on),
isCurrent
? null
: req.body?.ended_on === undefined
? before.ended_on
: toDateOrNull(req.body.ended_on),
isCurrent ? 1 : 0,
employmentId,
]
);
if (isCurrent) await applyCurrent(before.contact_id, employmentId, company.id);
/*
* Wurde die bisher aktuelle Station beendet, verliert der Kontakt seinen
* Arbeitgeber - sonst stuende im Kontakt weiter eine Firma, die im
* Werdegang als verlassen gilt.
*/
if (!isCurrent && before.is_current && contact?.company_id === before.company_id) {
await pool.query('UPDATE icon_contacts SET company_id = NULL WHERE id = ?', [before.contact_id]);
}
const [[named]] = await pool.query('SELECT name FROM icon_companies WHERE id = ?', [company.id]);
await logHistory({
entityType: 'contact',
entityId: before.contact_id,
entityLabel: fullName(contact ?? {}),
action: 'employment',
text: `Station geaendert: ${named?.name ?? 'Unternehmen'}${isCurrent ? ' (aktuell)' : ''}`,
userId: req.user.id,
});
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// DELETE /api/contacts/employments/:employmentId
router.delete('/employments/:employmentId', async (req, res, next) => {
const employmentId = Number(req.params.employmentId);
if (!Number.isInteger(employmentId)) return res.status(400).json({ error: 'Ungueltige Kennung.' });
try {
const [rows] = await pool.query('SELECT * FROM icon_employments WHERE id = ? LIMIT 1', [employmentId]);
const before = rows[0];
if (!before) return res.status(404).json({ error: 'Station nicht gefunden.' });
const [[contact]] = await pool.query('SELECT * FROM icon_contacts WHERE id = ? LIMIT 1', [
before.contact_id,
]);
const [[company]] = await pool.query('SELECT name FROM icon_companies WHERE id = ?', [
before.company_id,
]);
await pool.query('DELETE FROM icon_employments WHERE id = ?', [employmentId]);
// Die aktuelle Station zu loeschen heisst: der Kontakt arbeitet dort nicht
// mehr. Das Feld im Kontakt muss mit, sonst widersprechen sich beide.
if (before.is_current && contact?.company_id === before.company_id) {
await pool.query('UPDATE icon_contacts SET company_id = NULL WHERE id = ?', [before.contact_id]);
}
await logHistory({
entityType: 'contact',
entityId: before.contact_id,
entityLabel: fullName(contact ?? {}),
action: 'employment',
text: `Station entfernt: ${company?.name ?? 'Unternehmen'}`,
userId: req.user.id,
});
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/**
* Den am Kontakt haengenden Lebenslauf herunterladen.
*
* In der Spalte steht nur der Dateiname aus dem Upload-Verzeichnis. Der Pfad
* wird hier zusammengesetzt und der Name vorher geprueft - ein aus der
* Datenbank kommender Name darf nicht aus dem Verzeichnis herausfuehren.
*/
router.get('/:id/cv', 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 k.cv_file, CONCAT_WS(' ', k.first_name, k.last_name) AS name,
(SELECT original_name FROM icon_cv_imports i WHERE i.filename = k.cv_file LIMIT 1) AS original_name
FROM icon_contacts k WHERE k.id = ? LIMIT 1`,
[id]
);
const row = rows[0];
if (!row?.cv_file) return res.status(404).json({ error: 'Zu diesem Kontakt liegt kein Lebenslauf.' });
if (row.cv_file.includes('/') || row.cv_file.includes('\\') || row.cv_file.includes('..')) {
return res.status(400).json({ error: 'Ungültiger Dateiname.' });
}
res.download(
path.join(UPLOAD_DIR, row.cv_file),
row.original_name || `CV ${row.name}${path.extname(row.cv_file)}`
);
} catch (err) {
next(err);
}
});
/*
* Profilfoto von LinkedIn.
*
* Der Abruf laeuft ueber das Backend, weil die Same-Origin-Policy ihn im
* Browser verhindern wuerde. Gelesen wird ausschliesslich die
* Open-Graph-Vorschau der oeffentlichen Profilseite.
*/
router.post('/linkedin-photo', async (req, res, next) => {
const url = String(req.body?.url || req.body?.linkedin || '').trim();
if (!url) return res.status(400).json({ error: 'Keine LinkedIn-Adresse übergeben.' });
try {
const result = await fetchLinkedInPhoto(url);
res.json(result);
} catch (err) {
// PhotoError traegt einen eigenen Status samt verstaendlicher Meldung -
// alles andere geht als echter Serverfehler weiter.
if (err?.status) return res.status(err.status).json({ error: err.message });
next(err);
}
});
async function openEmployment(contactId, companyId, position) {
await pool.query(
`INSERT INTO icon_employments (contact_id, company_id, position, started_on, is_current)
VALUES (?, ?, ?, CURDATE(), 1)`,
[contactId, companyId, position]
);
}
async function closeEmployment(contactId, companyId) {
if (!companyId) return;
await pool.query(
`UPDATE icon_employments
SET is_current = 0, ended_on = COALESCE(ended_on, CURDATE())
WHERE contact_id = ? AND company_id = ? AND is_current = 1`,
[contactId, companyId]
);
}
async function logEmployerChange(contactId, before, data, userId) {
const label = fullName({ ...before, ...data });
const [names] = await pool.query(
'SELECT id, name FROM icon_companies WHERE id IN (?, ?)',
[before.company_id || 0, data.company_id || 0]
);
const nameOf = (id) => names.find((row) => row.id === id)?.name;
if (before.company_id) {
await logHistory({
entityType: 'company',
entityId: before.company_id,
entityLabel: nameOf(before.company_id),
action: 'left',
text: `${label} hat das Unternehmen verlassen`,
userId,
});
}
if (data.company_id) {
await logHistory({
entityType: 'company',
entityId: data.company_id,
entityLabel: nameOf(data.company_id),
action: 'joined',
text: `${label} ist jetzt hier tätig`,
userId,
});
}
await logHistory({
entityType: 'contact',
entityId: contactId,
entityLabel: label,
action: 'employer',
text: before.company_id
? `Arbeitgeber gewechselt: ${nameOf(before.company_id) ?? 'unbekannt'} → ${
data.company_id ? nameOf(data.company_id) ?? 'unbekannt' : 'kein Unternehmen'
}`
: `Arbeitgeber gesetzt: ${nameOf(data.company_id) ?? 'unbekannt'}`,
userId,
});
}
async function loadOne(id) {
const [rows] = await pool.query(
`SELECT k.*, co.name AS company_name, co.industry AS company_industry
FROM icon_contacts k
LEFT JOIN icon_companies co ON co.id = k.company_id
WHERE k.id = ? LIMIT 1`,
[id]
);
return rows[0] ? normalizeRow(rows[0]) : null;
}
function fullName(row) {
return [row.first_name, row.last_name].filter(Boolean).join(' ').trim() || 'Unbenannt';
}
function normalizeRow(row) {
return {
...row,
name: fullName(row),
tags: row.tags ? String(row.tags).split(',').map((t) => t.trim()).filter(Boolean) : [],
rate_onsite: row.rate_onsite === null ? null : Number(row.rate_onsite),
rate_remote: row.rate_remote === null ? null : Number(row.rate_remote),
interview_count: row.interview_count === undefined ? undefined : Number(row.interview_count),
};
}
function parseChanges(row) {
let changes = null;
try {
changes = row.changes ? JSON.parse(row.changes) : null;
} catch {
changes = null;
}
return { ...row, changes };
}
export default router;
+581
View File
@@ -0,0 +1,581 @@
import express from 'express';
import multer from 'multer';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { logHistory } from '../history.js';
import { askModelJson, aiConfigured, aiModel } from '../ai.js';
import { extractText, isSupported } from '../cvText.js';
import { extractPhoto } from '../cvPhoto.js';
const router = express.Router();
router.use(requireAuth);
/*
* CV-Import.
*
* Der Ablauf folgt der Featureliste: hochladen (auch mehrere auf einmal),
* danach entscheidet ein Mensch je Datei mit Ja oder Nein, ob sie an die KI
* geht. Erst deren Vorschlag - noch einmal bestaetigt - legt den Kontakt an.
*
* Es wird bewusst nichts automatisch uebernommen: ein falsch gelesener Name
* oder Stundensatz waere in der Datenbank schwerer zu finden als im Formular.
*/
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve(process.cwd(), '../uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
// Der Originalname wandert in die Datenbank; auf der Platte steht ein
// zufaelliger Name. Damit kann ein Dateiname weder kollidieren noch aus
// dem Verzeichnis ausbrechen.
filename: (req, file, cb) =>
cb(null, `${randomUUID()}${path.extname(file.originalname).toLowerCase().slice(0, 10)}`),
}),
limits: { fileSize: 15 * 1024 * 1024, files: 20 },
fileFilter: (req, file, cb) => {
if (!isSupported(file.originalname, file.mimetype)) {
cb(Object.assign(new Error(`${file.originalname}: nur PDF, DOCX oder Text.`), { status: 400 }));
return;
}
cb(null, true);
},
});
const SYSTEM_PROMPT = `Du liest Lebensläufe von IT- und Management-Beratern und gibst die Angaben als JSON zurück.
Antworte ausschließlich mit JSON in genau dieser Form:
{
"first_name": string|null,
"last_name": string|null,
"position": string|null,
"email": string|null,
"phone": string|null,
"linkedin": string|null,
"company": string|null,
"tags": string[],
"bio": string|null,
"rate_onsite": number|null,
"rate_remote": number|null,
"employments": [{ "company": string, "position": string|null, "from": string|null, "to": string|null }],
"confidence": number
}
Regeln:
- "company" ist der AKTUELLE Arbeitgeber. Steht dort "freiberuflich", "selbstständig" oder Ähnliches, setze null.
- "employments" enthält ALLE beruflichen Stationen, neueste zuerst, Datumsangaben als YYYY-MM-DD, YYYY-MM oder YYYY, sonst null. Bei freiberuflichen Einsätzen ist der KUNDE das Unternehmen. Schule, Studium und Zertifikate gehören NICHT dazu.
- "tags" sind fachliche Schlagworte (Technologien, Methoden, Branchen), höchstens 15, ohne Dubletten.
- "bio" ist eine sachliche Zusammenfassung in höchstens 4 Sätzen auf Deutsch.
- "rate_onsite"/"rate_remote" sind STUNDENsätze in Euro. Nur übernehmen, wenn sie ausdrücklich als Stundensatz im Text stehen; niemals schätzen. Steht dort ein Tagessatz, nicht umrechnen, sondern null setzen.
- Was nicht im Text steht, ist null bzw. eine leere Liste. Nichts erfinden.
- "confidence" ist eine Zahl zwischen 0 und 1: wie sicher die Zuordnung von Name und Kontaktdaten ist.`;
/*
* Zweiter Durchgang, nur fuer den Werdegang.
*
* Der grosse Prompt muss Name, Kontaktdaten, Schlagworte, Zusammenfassung und
* Saetze zugleich liefern - die Stationen kamen dabei unzuverlaessig heraus:
* derselbe Lebenslauf ergab einmal drei, einmal zwei, ein reiner Projektliste
* gar keine. Ein eigener Durchgang mit einer einzigen Aufgabe trifft deutlich
* besser, und er kostet nur einen zweiten kleinen Aufruf.
*
* Beide Listen werden anschliessend zusammengefuehrt - faellt dieser Durchgang
* aus, bleibt das Ergebnis des ersten.
*/
const STATIONS_PROMPT = `Du liest den beruflichen Werdegang aus einem Lebenslauf und gibst ihn als JSON zurück.
Antworte ausschließlich mit JSON in genau dieser Form:
{ "employments": [{ "company": string, "position": string|null, "from": string|null, "to": string|null, "is_current": boolean }] }
Regeln:
- VOLLSTÄNDIGKEIT geht vor Kürze: jede berufliche Station gehört in die Liste, auch kurze, auch nebenberufliche.
- Berücksichtige alle Abschnitte, die Stationen enthalten können: "Berufserfahrung", "Beruflicher Werdegang", "Tätigkeiten", "Projekte", "Projekterfahrung", "Referenzen", "Ausgewählte Projekte", "Kunden".
- Bei freiberuflicher Tätigkeit oder Beratungsprojekten ist der KUNDE das Unternehmen, nicht die eigene Firma. Steht beides da ("für Kunde X über Y"), nimm den Kunden.
- Wird derselbe Kunde mehrfach zu verschiedenen Zeiten genannt, gehört er mehrfach in die Liste. Nicht zusammenfassen.
- Schule, Studium, Ausbildung, Zertifikate und Weiterbildungen gehören NICHT in die Liste.
- Datumsangaben als YYYY-MM-DD, YYYY-MM oder YYYY; ist keine angegeben, null. Eine fehlende Zeitangabe ist KEIN Grund, eine Station wegzulassen.
- "is_current" ist true bei "seit", "heute", "aktuell", "bis heute" oder fehlendem Enddatum bei der jüngsten Station.
- Der Text stammt aus PDF oder DOCX und kann durch mehrspaltige Layouts durcheinandergeraten sein: Zeitangaben stehen dann als Block vor oder nach den Firmennamen. Ordne sie der Reihenfolge nach zu.
- Reihenfolge: neueste zuerst. Höchstens 60 Einträge.
- Nichts erfinden. Steht im Text keine einzige Station, gib eine leere Liste zurück.`;
// GET /api/cvs?status=
router.get('/', async (req, res, next) => {
const where = [];
const params = [];
if (req.query.status) {
where.push('c.status = ?');
params.push(String(req.query.status));
}
try {
const [rows] = await pool.query(
/*
`c.photo` steht ausdruecklich NICHT in der Auswahl: die Liste laedt bei
jedem Oeffnen, und ein Foto je Zeile waere ein Vielfaches der uebrigen
Nutzlast. Wer es sehen will, holt es einzeln (GET /:id/photo).
*/
`SELECT c.id, c.filename, c.original_name, c.mime, c.size_bytes, c.status,
c.extracted, c.error, c.contact_id, c.uploaded_by, c.created_at,
c.processed_at, c.photo IS NOT NULL AS has_photo,
u.name AS uploaded_by_name,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name
FROM icon_cv_imports c
LEFT JOIN icon_users u ON u.id = c.uploaded_by
LEFT JOIN icon_contacts k ON k.id = c.contact_id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY c.created_at DESC, c.id DESC`,
params
);
res.json({
imports: rows.map(normalize),
ai: { configured: aiConfigured(), model: aiModel() },
});
} catch (err) {
next(err);
}
});
// POST /api/cvs (multipart, Feld "files", mehrere erlaubt)
router.post('/', upload.array('files', 20), async (req, res, next) => {
const files = req.files ?? [];
if (files.length === 0) return res.status(400).json({ error: 'Keine Datei übergeben.' });
try {
const ids = [];
for (const file of files) {
const [result] = await pool.query(
`INSERT INTO icon_cv_imports (filename, original_name, mime, size_bytes, uploaded_by)
VALUES (?, ?, ?, ?, ?)`,
[file.filename, file.originalname.slice(0, 255), file.mimetype, file.size, req.user.id]
);
ids.push(result.insertId);
}
await logHistory({
entityType: 'system',
action: 'cv_upload',
text: `${files.length} Lebenslauf/Lebensläufe hochgeladen`,
userId: req.user.id,
});
res.status(201).json({ ids, count: files.length });
} catch (err) {
next(err);
}
});
/**
* Die Datei an die KI geben.
*
* Das ist das "Ja" aus der Liste: Text herausziehen, Modell fragen, Ergebnis
* als Vorschlag ablegen. Angelegt wird noch nichts.
*/
router.post('/:id/analyze', 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_cv_imports WHERE id = ? LIMIT 1', [id]);
const row = rows[0];
if (!row) return res.status(404).json({ error: 'Datei nicht gefunden.' });
const text = await extractText(path.join(UPLOAD_DIR, row.filename), row.original_name);
if (text.length < 40) {
throw Object.assign(
new Error('Aus der Datei ließ sich kaum Text lesen – vermutlich ein Scan ohne Texterkennung.'),
{ status: 422 }
);
}
const extracted = await askModelJson({
system: SYSTEM_PROMPT,
user: `Lebenslauf (Dateiname: ${row.original_name}):\n\n${text}`,
maxTokens: 6000,
});
/*
* Der Werdegang aus dem eigenen Durchgang. Schlaegt er fehl, bleibt es bei
* dem, was der erste Durchgang gefunden hat - ein zweiter Aufruf darf das
* Auslesen nicht scheitern lassen.
*/
const stations = await askModelJson({
system: STATIONS_PROMPT,
user: `Lebenslauf (Dateiname: ${row.original_name}):\n\n${text}`,
maxTokens: 8000,
}).catch(() => null);
extracted.employments = mergeEmployments(extracted.employments, stations?.employments);
/*
* Das Bewerbungsfoto, sofern die Datei eines traegt (siehe cvPhoto.js). Es
* wandert nur in die Zwischenablage dieser Zeile - uebernommen wird es erst
* mit dem Kontakt, und auch dann nur, wenn der Haken im Formular steht.
*
* Ein misslungener Fund darf das Auslesen nicht scheitern lassen: der Text
* ist das Wesentliche, das Foto die Zugabe.
*/
const photo = await extractPhoto(
path.join(UPLOAD_DIR, row.filename),
row.original_name
).catch(() => null);
await pool.query(
`UPDATE icon_cv_imports
SET status = 'screening', extracted = ?, photo = ?, error = NULL, processed_at = NOW()
WHERE id = ?`,
[JSON.stringify(extracted), photo?.dataUrl ?? null, id]
);
res.json({ extracted, photo: photo?.dataUrl ?? null });
} catch (err) {
// Der Fehler gehoert an die Zeile, sonst sucht der Benutzer ihn im Log.
const message = err?.message ? String(err.message).slice(0, 500) : 'Auslesen fehlgeschlagen.';
await pool.query('UPDATE icon_cv_imports SET error = ? WHERE id = ?', [message, id]).catch(() => {});
if (err?.status) return res.status(err.status).json({ error: message });
next(err);
}
});
/**
* Das gefundene Bewerbungsfoto - unveraendert, wie es in der Datei lag.
*
* Eigener Endpunkt, weil es nicht in die Liste gehoert: dort waere es bei
* jedem Oeffnen erneut Ballast, gebraucht wird es nur im Formular.
*/
router.get('/:id/photo', 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 photo FROM icon_cv_imports WHERE id = ? LIMIT 1', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Datei nicht gefunden.' });
res.json({ photo: rows[0].photo ?? null });
} catch (err) {
next(err);
}
});
/**
* Vorschlag uebernehmen: Kontakt anlegen, CV anhaengen, Werdegang schreiben.
*
* Der Koerper darf jedes Feld ueberschreiben - die Oberflaeche zeigt den
* Vorschlag im Formular, und was dort korrigiert wurde, gewinnt.
*/
router.post('/:id/accept', 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_cv_imports WHERE id = ? LIMIT 1', [id]);
const row = rows[0];
if (!row) return res.status(404).json({ error: 'Datei nicht gefunden.' });
if (row.contact_id) return res.status(400).json({ error: 'Dieser Lebenslauf wurde bereits übernommen.' });
const suggestion = safeParse(row.extracted) ?? {};
const data = { ...suggestion, ...(req.body ?? {}) };
const lastName = String(data.last_name ?? '').trim();
if (!lastName) return res.status(400).json({ error: 'Ohne Nachnamen lässt sich kein Kontakt anlegen.' });
const companyId = await companyForName(data.company, req.user.id);
const [result] = await pool.query(
`INSERT INTO icon_contacts
(first_name, last_name, position, email, phone, linkedin, company_id, kind, status,
lead_source, bio, tags, rate_onsite, rate_remote, avatar, cv_file, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, 'Experte', 'Interessiert', ?, ?, ?, ?, ?, ?, ?, ?)`,
[
trimOrNull(data.first_name, 120),
lastName.slice(0, 120),
trimOrNull(data.position, 190),
trimOrNull(data.email, 190),
trimOrNull(data.phone, 80),
trimOrNull(data.linkedin, 300),
companyId,
trimOrNull(data.lead_source, 190) ?? 'CV-Import',
data.bio ? String(data.bio) : null,
normalizeTags(data.tags),
numberOrNull(data.rate_onsite),
numberOrNull(data.rate_remote),
/*
* Das Foto kommt verkleinert aus dem Formular - der Browser skaliert es
* wie jedes andere Foto auch. Angenommen wird nur ein eingebettetes
* Bild; ein fremder Verweis hat an dieser Stelle nichts zu suchen.
*/
imageOrNull(data.avatar),
row.filename,
req.user.id,
]
);
const contactId = result.insertId;
const stations = Array.isArray(data.employments) ? data.employments.slice(0, 60) : [];
/*
* Welche der gelesenen Stationen beschreibt den aktuellen Arbeitgeber?
*
* Zwei Fehler haengen daran, und beide sahen in der Karte nach einem
* Lesefehler aus:
*
* * Ohne sie begann die laufende Station am Tag des Imports. Im
* Lebenslauf stand "seit 03/2023", in der Karte das heutige Datum.
* * Und sie wurde ein zweites Mal als frueherer Eintrag angelegt -
* dasselbe Unternehmen, dieselbe Rolle, einmal mit und einmal ohne
* Datum.
*
* Erkannt wird sie am Firmennamen (ohne Rechtsform) und daran, dass sie
* laufend ist: als aktuell gekennzeichnet oder ohne Enddatum. Eine frueher
* datierte Station beim selben Unternehmen bleibt davon unberuehrt - ein
* Berater ist oft zweimal beim selben Kunden.
*/
const currentKey = simplifyCompany(data.company ?? '');
const currentStation = currentKey
? stations.find(
(entry) =>
simplifyCompany(entry?.company ?? '') === currentKey &&
(entry?.is_current || !entry?.to)
)
: null;
// Aktueller Arbeitgeber eroeffnet den Werdegang.
if (companyId) {
await pool.query(
`INSERT INTO icon_employments (contact_id, company_id, position, started_on, is_current)
VALUES (?, ?, ?, COALESCE(?, CURDATE()), 1)`,
[
contactId,
companyId,
trimOrNull(currentStation?.position, 190) ?? trimOrNull(data.position, 190),
toDate(currentStation?.from),
]
);
}
/*
* Fruehere Stationen als beendete Anstellungen - daraus speist sich der
* Zweig "Verlassen" beim jeweiligen Unternehmen.
*
* Die Grenze liegt bei 60 statt bei 20: Beraterlebenslaeufe mit einer
* Projektliste kommen leicht ueber zwanzig Kunden, und alles darueber
* verschwand vorher stillschweigend.
*/
for (const entry of stations) {
if (entry === currentStation) continue;
const formerId = await companyForName(entry?.company, req.user.id);
// Ohne Unternehmen keine Station; ein Eintrag beim aktuellen Arbeitgeber
// ohne jede Zeitangabe waere nur eine Wiederholung der laufenden.
if (!formerId) continue;
if (formerId === companyId && !entry?.from && !entry?.to) continue;
await pool.query(
`INSERT INTO icon_employments (contact_id, company_id, position, started_on, ended_on, is_current)
VALUES (?, ?, ?, ?, ?, 0)`,
[contactId, formerId, trimOrNull(entry?.position, 190), toDate(entry?.from), toDate(entry?.to)]
);
}
// Das Foto haengt jetzt am Kontakt - die Zwischenablage wird geleert,
// sonst laege dasselbe Bild zweimal in der Datenbank.
await pool.query(
`UPDATE icon_cv_imports
SET status = 'auswahl', contact_id = ?, photo = NULL, processed_at = NOW()
WHERE id = ?`,
[contactId, id]
);
const label = [data.first_name, lastName].filter(Boolean).join(' ');
await logHistory({
entityType: 'contact',
entityId: contactId,
entityLabel: label,
action: 'created',
text: `${label} aus Lebenslauf "${row.original_name}" übernommen (${aiModel()})`,
userId: req.user.id,
});
res.status(201).json({ contact_id: contactId });
} catch (err) {
next(err);
}
});
router.post('/:id/reject', 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(
"UPDATE icon_cv_imports SET status = 'verworfen', processed_at = NOW() WHERE id = ?",
[id]
);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Datei nicht gefunden.' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/** Datei herunterladen - auch der am Kontakt haengende CV laeuft hierueber. */
router.get('/:id/file', 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 filename, original_name, mime FROM icon_cv_imports WHERE id = ? LIMIT 1',
[id]
);
if (!rows[0]) return res.status(404).json({ error: 'Datei nicht gefunden.' });
res.download(path.join(UPLOAD_DIR, rows[0].filename), rows[0].original_name);
} 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 filename, contact_id FROM icon_cv_imports WHERE id = ?', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Datei nicht gefunden.' });
// Haengt der CV an einem Kontakt, bleibt die Datei liegen - sonst zeigt
// dessen Karte auf ein Nichts.
if (!rows[0].contact_id) {
await fsp.unlink(path.join(UPLOAD_DIR, rows[0].filename)).catch(() => {});
}
await pool.query('DELETE FROM icon_cv_imports WHERE id = ?', [id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/** Unternehmen zu einem Namen finden oder anlegen. */
async function companyForName(value, userId) {
const name = trimOrNull(value, 190);
if (!name) return null;
const [rows] = await pool.query('SELECT id FROM icon_companies WHERE name = ? LIMIT 1', [name]);
if (rows[0]) return rows[0].id;
const [result] = await pool.query('INSERT INTO icon_companies (name, created_by) VALUES (?, ?)', [
name,
userId,
]);
await logHistory({
entityType: 'company',
entityId: result.insertId,
entityLabel: name,
action: 'created',
text: `${name} beim CV-Import angelegt`,
userId,
});
return result.insertId;
}
function normalizeTags(input) {
const list = Array.isArray(input) ? input : String(input ?? '').split(',');
const seen = [];
for (const raw of list) {
const tag = String(raw ?? '').trim();
if (tag && !seen.some((t) => t.toLowerCase() === tag.toLowerCase())) seen.push(tag);
}
return seen.length > 0 ? seen.join(', ').slice(0, 1000) : null;
}
const trimOrNull = (value, max) => {
const text = String(value ?? '').trim();
return text && text.toLowerCase() !== 'null' ? text.slice(0, max) : null;
};
/**
* Die Stationen beider Durchgaenge zu einer Liste.
*
* Verglichen wird ueber Firmenname und Beginn - beides klein geschrieben und
* ohne Rechtsform, damit "Bayerische Versorgungskammer" und "Bayerische
* Versorgungskammer (BVK)" nicht zweimal erscheinen. Die Reihenfolge des
* ausfuehrlicheren Durchgangs bleibt erhalten.
*/
function mergeEmployments(fromMain, fromStations) {
const lists = [
Array.isArray(fromStations) ? fromStations : [],
Array.isArray(fromMain) ? fromMain : [],
];
const merged = [];
const seen = new Set();
for (const list of lists) {
for (const entry of list) {
const company = String(entry?.company ?? '').trim();
if (!company) continue;
const key = `${simplifyCompany(company)}|${String(entry?.from ?? '').slice(0, 4)}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push({
company: company.slice(0, 190),
position: entry?.position ? String(entry.position).slice(0, 190) : null,
from: entry?.from ?? null,
to: entry?.to ?? null,
is_current: Boolean(entry?.is_current),
});
}
}
return merged.slice(0, 60);
}
/** Firmenname auf das Vergleichbare herunterbrechen. */
function simplifyCompany(name) {
return String(name)
.toLowerCase()
.replace(/\b(gmbh|ag|se|kg|mbh|co|ohg|ug|e\.? ?v|inc|ltd|llc|plc|s\.? ?a|b\.? ?v)\b/g, ' ')
.replace(/[^a-z0-9äöüß]+/g, ' ')
.trim();
}
/** Nur eingebettete Bilder - keine fremden Adressen, kein sonstiger Text. */
const imageOrNull = (value) => {
const text = String(value ?? '').trim();
if (!/^data:image\/(png|jpeg|webp);base64,/.test(text)) return null;
// In dieser Groesse ist das Bild bereits verkleinert; alles darueber waere
// ein Versehen und bliese die Zeile auf.
return text.length <= 1_400_000 ? text : null;
};
const numberOrNull = (value) => {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : null;
};
/** "2019", "2019-03" und "2019-03-01" werden alle zu einem gueltigen Datum. */
function toDate(value) {
const text = String(value ?? '').trim();
if (/^\d{4}$/.test(text)) return `${text}-01-01`;
if (/^\d{4}-\d{2}$/.test(text)) return `${text}-01`;
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
return null;
}
function normalize(row) {
return {
...row,
extracted: safeParse(row.extracted),
size_bytes: Number(row.size_bytes ?? 0),
has_photo: Boolean(row.has_photo),
};
}
function safeParse(value) {
try {
return value ? JSON.parse(value) : null;
} catch {
return null;
}
}
export default router;
export { UPLOAD_DIR };
+64
View File
@@ -0,0 +1,64 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
/*
* Kennzahlen und Zaehler in einer Anfrage.
*
* Die Seitenleiste und das Dashboard brauchen dieselben Zahlen; getrennte
* Endpunkte wuerden bei jedem Ansichtswechsel dieselben COUNT(*) doppelt
* ausfuehren.
*/
router.get('/', async (req, res, next) => {
try {
const [[counts]] = await pool.query(
`SELECT
(SELECT COUNT(*) FROM icon_contacts) AS contacts,
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Experte') AS experts,
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Ansprechpartner') AS partners,
(SELECT COUNT(*) FROM icon_companies) AS companies,
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'aktiv') AS customers,
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'alumni') AS alumni,
(SELECT COUNT(*) FROM icon_tenders WHERE status <> 'Archiviert') AS tenders_active,
(SELECT COUNT(*) FROM icon_tenders WHERE status = 'Archiviert') AS tenders_archived,
(SELECT COUNT(*) FROM icon_tenders) AS tenders,
(SELECT COUNT(*) FROM icon_projects WHERE status = 'Aktiv') AS projects_active,
(SELECT COUNT(*) FROM icon_leads WHERE archived = 0) AS leads_open,
(SELECT COUNT(*) FROM icon_leads WHERE archived = 1) AS leads_archived`
);
const [byStatus] = await pool.query(
'SELECT status, COUNT(*) AS count FROM icon_contacts GROUP BY status'
);
const [byIndustry] = await pool.query(
`SELECT COALESCE(NULLIF(industry, ''), 'Ohne Branche') AS industry, COUNT(*) AS count
FROM icon_companies GROUP BY industry ORDER BY count DESC LIMIT 10`
);
const [byStage] = await pool.query(
'SELECT stage, COUNT(*) AS count FROM icon_tenders GROUP BY stage'
);
// Die letzten Ereignisse - dieselbe Quelle wie der Reiter Historie.
const [recent] = await pool.query(
`SELECT h.id, h.entity_type, h.entity_id, h.entity_label, h.action, h.text,
h.created_at, 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
ORDER BY h.id DESC LIMIT 15`
);
res.json({
counts: Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, Number(value)])),
contacts_by_status: byStatus.map((row) => ({ ...row, count: Number(row.count) })),
companies_by_industry: byIndustry.map((row) => ({ ...row, count: Number(row.count) })),
tenders_by_stage: byStage.map((row) => ({ ...row, count: Number(row.count) })),
recent,
});
} catch (err) {
next(err);
}
});
export default router;
+160
View File
@@ -0,0 +1,160 @@
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);
/*
* Reiter "Historie": alle Taetigkeiten chronologisch.
*
* Format der Zeile in der Oberflaeche:
* 14.09.'26 09:35 | Hans Peter neu angelegt
* Der Zeitstempel und der Benutzer kommen aus der Tabelle, der Text steht
* fertig darin - die Route formatiert nichts.
*/
// GET /api/history?entity_type=&entity_id=&user_id=&action=&q=&limit=&before_id=
router.get('/', async (req, res, next) => {
const where = [];
const params = [];
if (req.query.entity_type) {
where.push('h.entity_type = ?');
params.push(String(req.query.entity_type));
}
if (req.query.entity_id) {
where.push('h.entity_id = ?');
params.push(Number(req.query.entity_id) || 0);
}
if (req.query.user_id) {
where.push('h.user_id = ?');
params.push(Number(req.query.user_id) || 0);
}
if (req.query.action) {
where.push('h.action = ?');
params.push(String(req.query.action));
}
const q = String(req.query.q || '').trim();
if (q) {
where.push('(h.text LIKE ? OR h.entity_label LIKE ?)');
params.push(`%${q}%`, `%${q}%`);
}
// Blaettern ueber die Kennung statt ueber OFFSET: bei laufend neuen
// Eintraegen wuerde ein Offset Zeilen doppelt oder gar nicht liefern.
if (req.query.before_id) {
where.push('h.id < ?');
params.push(Number(req.query.before_id) || 0);
}
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
try {
const [rows] = 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.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY h.id DESC
LIMIT ${limit}`,
params
);
res.json({ entries: rows.map(parseChanges) });
} catch (err) {
next(err);
}
});
// POST /api/history { entity_type, entity_id, text }
// Freie Notiz an einer Karte. @-Erwaehnungen im Text werden mitindiziert.
router.post('/', async (req, res, next) => {
const entityType = String(req.body?.entity_type || '');
const text = String(req.body?.text || '').trim();
const allowed = ['contact', 'company', 'tender', 'project', 'lead', 'system'];
if (!allowed.includes(entityType)) return res.status(400).json({ error: 'Unbekannter Bezug.' });
if (!text) return res.status(400).json({ error: 'Der Text darf nicht leer sein.' });
try {
const entityId = Number(req.body?.entity_id) || null;
const id = await logHistory({
entityType,
entityId,
entityLabel: await labelFor(entityType, entityId),
action: 'note',
text,
userId: req.user.id,
});
const [rows] = 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.id = ?`,
[id]
);
res.status(201).json({ entry: parseChanges(rows[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 user_id, action FROM icon_history WHERE id = ? LIMIT 1', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
// Nur eigene Notizen duerfen weg. Systemeintraege sind das Protokoll und
// bleiben stehen - sonst waere die Historie nichts wert.
if (rows[0].action !== 'note') {
return res.status(400).json({ error: 'Nur eigene Notizen können gelöscht werden.' });
}
if (rows[0].user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Diese Notiz gehört jemand anderem.' });
}
await pool.query('DELETE FROM icon_history WHERE id = ?', [id]);
await pool.query("DELETE FROM icon_mentions WHERE source_type = 'history' AND source_id = ?", [id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
async function labelFor(entityType, entityId) {
if (!entityId) return null;
if (entityType === 'contact') {
const [rows] = await pool.query(
"SELECT CONCAT_WS(' ', first_name, last_name) AS label FROM icon_contacts WHERE id = ?",
[entityId]
);
return rows[0]?.label ?? null;
}
const table = {
company: 'icon_companies',
tender: 'icon_tenders',
project: 'icon_projects',
lead: 'icon_leads',
}[entityType];
if (!table) return null;
const column = table === 'icon_companies' ? 'name' : 'title';
const [rows] = await pool.query(`SELECT ${column} AS label FROM ${table} WHERE id = ?`, [entityId]);
return rows[0]?.label ?? null;
}
function parseChanges(row) {
if (!row) return row;
let changes = null;
try {
changes = row.changes ? JSON.parse(row.changes) : null;
} catch {
changes = null;
}
return { ...row, changes };
}
export default router;
+85
View File
@@ -0,0 +1,85 @@
import express from 'express';
import { timingSafeEqual } from 'node:crypto';
import { pool } from '../db.js';
const router = express.Router();
/*
* Leseschnittstelle für das perinco CRM.
*
* Das Gegenstück zu `routes/perinco.js`: dort holt dieses CRM die Kontakte von
* drüben, hier gibt es die eigenen heraus. Beide Richtungen sind bewusst gleich
* gebaut - fester Token statt JWT, nur GET, kein Schreibweg.
*
* Ausgeliefert wird nur, was drüben ein Feld hat. `crm_leads` kennt keinen
* Status, keine Tagessätze und keine Lebensläufe - die bleiben hier.
*/
function tokenMatches(given) {
const expected = process.env.PERINCO_INBOUND_TOKEN || '';
if (!expected || !given) return false;
const a = Buffer.from(given);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
router.use((req, res, next) => {
if (!process.env.PERINCO_INBOUND_TOKEN) {
return res.status(503).json({ error: 'Schnittstelle ist nicht eingerichtet.' });
}
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!tokenMatches(token)) return res.status(401).json({ error: 'Nicht berechtigt.' });
next();
});
router.get('/contacts', async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT k.id, k.first_name, k.last_name, k.position, k.email, k.phone, k.linkedin,
k.kind, k.lead_source, k.tags, k.bio, k.avatar, k.created_at, k.updated_at,
co.name AS company
FROM icon_contacts k
LEFT JOIN icon_companies co ON co.id = k.company_id
ORDER BY k.last_name, k.first_name`
);
res.set('Cache-Control', 'private, max-age=60');
res.json({
contacts: rows.map((row) => ({
id: row.id,
first_name: row.first_name,
last_name: row.last_name,
position: row.position,
company: row.company,
email: row.email,
phone: row.phone,
linkedin: row.linkedin,
// Die Arten hier sind andere als drüben (Experte/Ansprechpartner/Lead
// gegen Recruiter/Partner/Endkunde). Sie wandern unverändert mit - eine
// erfundene Zuordnung wäre schlechter als die ehrliche Fremdbezeichnung.
kind: row.kind,
source: row.lead_source,
tags: row.tags
? String(row.tags)
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: [],
notes: row.bio,
avatar: row.avatar,
created_at: row.created_at,
updated_at: row.updated_at,
})),
count: rows.length,
});
} catch (err) {
next(err);
}
});
export default router;
+440
View File
@@ -0,0 +1,440 @@
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);
/*
* Lead-Pipeline.
*
* Ein Lead ist die Vorstufe zu Unternehmen und Kontakt: er haengt an beidem,
* hat eine Phase, eine Checkliste und eine Erinnerung.
*
* Zwei Erinnerungsarten, beide in `remind_at` bzw. `last_contact_at`:
* * die gesetzte Erinnerung ("in 2 Tagen anrufen")
* * die automatische Nachfrage, wenn ein Lead einen Monat lang unberuehrt
* bleibt. Wird er dann nicht archiviert, laeuft der Monat von vorn - das
* passiert, indem `last_contact_at` auf jetzt gesetzt wird.
*/
const STAGES = [
'Fuer Spaeter',
'Neuer Lead',
'Erstkontakt',
'1. Gespraech',
'Followup geplant',
'Followup Gespraech',
'Closed',
'Absage',
];
const EDITABLE = [
'title',
'company_id',
'contact_id',
'stage',
'contact_state',
'archived',
'notes',
'remind_at',
'reminder_days',
'owner_id',
];
/** Nach wie vielen Tagen ohne Beruehrung nachgefragt wird. */
const STALE_DAYS = 30;
function readBody(body) {
const out = {};
if (body.title !== undefined) out.title = String(body.title ?? '').trim().slice(0, 300);
if (body.company_id !== undefined) out.company_id = positiveOrNull(body.company_id);
if (body.contact_id !== undefined) out.contact_id = positiveOrNull(body.contact_id);
if (body.stage !== undefined) out.stage = STAGES.includes(body.stage) ? body.stage : 'Neuer Lead';
if (body.contact_state !== undefined) {
out.contact_state = ['persoenlich', 'kein Kontakt'].includes(body.contact_state)
? body.contact_state
: 'unbekannt';
}
if (body.archived !== undefined) out.archived = body.archived ? 1 : 0;
if (body.notes !== undefined) out.notes = body.notes ? String(body.notes) : null;
if (body.owner_id !== undefined) out.owner_id = positiveOrNull(body.owner_id);
if (body.position !== undefined) {
const n = Number(body.position);
out.position = Number.isFinite(n) ? Math.round(n) : 0;
}
if (body.reminder_days !== undefined) {
const n = Number(body.reminder_days);
out.reminder_days = Number.isFinite(n) && n > 0 ? Math.round(n) : null;
}
/*
* Die Erinnerung wird als Anzahl Tage gesetzt, nicht als Datum: "in zwei
* Tagen anrufen" ist das, was jemand im Kopf hat. Das Datum rechnet der
* Server - sonst haengt es an der Zeitzone des Browsers.
*/
if (body.remind_in_days !== undefined) {
const n = Number(body.remind_in_days);
out.remind_at =
Number.isFinite(n) && n > 0
? new Date(Date.now() + n * 86_400_000).toISOString().slice(0, 19).replace('T', ' ')
: null;
} else if (body.remind_at !== undefined) {
out.remind_at = body.remind_at ? String(body.remind_at).slice(0, 19).replace('T', ' ') : null;
}
return out;
}
const positiveOrNull = (value) => {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
};
const SELECT = `
SELECT l.*, co.name AS company_name, co.industry AS company_industry, co.logo AS company_logo,
co.rating AS company_rating,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name,
k.email AS contact_email, k.phone AS contact_phone, k.avatar AS contact_avatar,
u.name AS owner_name,
(SELECT COUNT(*) FROM icon_lead_todos t WHERE t.lead_id = l.id) AS todo_count,
(SELECT COUNT(*) FROM icon_lead_todos t WHERE t.lead_id = l.id AND t.done = 1) AS todo_done
FROM icon_leads l
LEFT JOIN icon_companies co ON co.id = l.company_id
LEFT JOIN icon_contacts k ON k.id = l.contact_id
LEFT JOIN icon_users u ON u.id = l.owner_id`;
router.get('/', async (req, res, next) => {
const archived = String(req.query.archived ?? '0') === '1';
try {
const [rows] = await pool.query(`${SELECT} WHERE l.archived = ? ORDER BY l.position, l.id DESC`, [
archived ? 1 : 0,
]);
res.json({ leads: rows.map(normalize) });
} catch (err) {
next(err);
}
});
/**
* Faellige Erinnerungen.
*
* Zwei Toepfe in einer Antwort: die gesetzten Erinnerungen und die Leads, die
* seit einem Monat niemand angefasst hat. Die Oberflaeche fragt das beim
* Anmelden und danach stuendlich ab.
*/
router.get('/reminders', async (req, res, next) => {
try {
const [due] = await pool.query(
`${SELECT} WHERE l.archived = 0 AND l.remind_at IS NOT NULL AND l.remind_at <= NOW()
ORDER BY l.remind_at`
);
const [stale] = await pool.query(
`${SELECT} WHERE l.archived = 0
AND COALESCE(l.last_contact_at, l.created_at) < DATE_SUB(NOW(), INTERVAL ? DAY)
ORDER BY COALESCE(l.last_contact_at, l.created_at)`,
[STALE_DAYS]
);
// Ein Lead, der schon wegen einer gesetzten Erinnerung dran ist, soll nicht
// zusaetzlich als "lange nichts passiert" auftauchen.
const dueIds = new Set(due.map((row) => row.id));
res.json({
due: due.map(normalize),
stale: stale.filter((row) => !dueIds.has(row.id)).map(normalize),
stale_days: STALE_DAYS,
});
} catch (err) {
next(err);
}
});
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} WHERE l.id = ? LIMIT 1`, [id]);
if (!rows[0]) return res.status(404).json({ error: 'Lead nicht gefunden.' });
const [todos] = await pool.query(
'SELECT * FROM icon_lead_todos WHERE lead_id = ? ORDER BY done, position, id',
[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 = 'lead' AND h.entity_id = ?
ORDER BY h.id DESC LIMIT 300`,
[id]
);
res.json({
lead: normalize(rows[0]),
todos: todos.map((row) => ({ ...row, done: Boolean(row.done) })),
history: history.map(parseChanges),
});
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
const data = readBody(req.body || {});
if (!data.title) return res.status(400).json({ error: 'Der Titel ist erforderlich.' });
try {
const columns = Object.keys(data);
const [result] = await pool.query(
`INSERT INTO icon_leads (${columns.join(', ')}, created_by, last_contact_at)
VALUES (${columns.map(() => '?').join(', ')}, ?, NOW())`,
[...columns.map((c) => data[c]), req.user.id]
);
await logHistory({
entityType: 'lead',
entityId: result.insertId,
entityLabel: data.title,
action: 'created',
text: `Lead "${data.title}" angelegt`,
userId: req.user.id,
});
if (data.notes) await indexMentions('lead', result.insertId, data.notes);
const [rows] = await pool.query(`${SELECT} WHERE l.id = ?`, [result.insertId]);
res.status(201).json({ lead: normalize(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_leads WHERE id = ? LIMIT 1', [id]);
const before = rows[0];
if (!before) return res.status(404).json({ error: 'Lead nicht gefunden.' });
const data = readBody(req.body || {});
const columns = Object.keys(data);
if (columns.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
/*
* Jede Bearbeitung gilt als Beruehrung: damit setzt sie die Monatsfrist der
* automatischen Nachfrage zurueck. Ein reines Verschieben der Position
* (Sortierung innerhalb einer Spalte) zaehlt bewusst nicht dazu.
*/
const touches = columns.some((column) => column !== 'position');
await pool.query(
`UPDATE icon_leads SET ${columns.map((c) => `${c} = ?`).join(', ')}
${touches ? ', last_contact_at = NOW()' : ''} WHERE id = ?`,
[...columns.map((c) => data[c]), id]
);
const changes = diffFields(before, data, EDITABLE);
if (Object.keys(changes).length > 0) {
await logHistory({
entityType: 'lead',
entityId: id,
entityLabel: data.title ?? before.title,
action: changes.stage ? 'stage' : changes.archived ? 'archived' : 'updated',
text: changes.stage
? `"${data.title ?? before.title}": ${changes.stage[0]} → ${changes.stage[1]}`
: `"${data.title ?? before.title}": ${describeChanges(changes)}`,
changes,
userId: req.user.id,
});
}
if (data.notes !== undefined) await indexMentions('lead', id, data.notes);
const [updated] = await pool.query(`${SELECT} WHERE l.id = ?`, [id]);
res.json({ lead: normalize(updated[0]) });
} catch (err) {
next(err);
}
});
/**
* Erinnerung quittieren.
*
* `snooze_days` verschiebt sie, ohne Angabe verschwindet sie. In beiden Faellen
* gilt der Lead als beruehrt - sonst meldete sich die automatische Nachfrage
* sofort wieder.
*/
router.post('/:id/acknowledge', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
const days = Number(req.body?.snooze_days);
const remindAt =
Number.isFinite(days) && days > 0
? new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ')
: null;
try {
const [result] = await pool.query(
'UPDATE icon_leads SET remind_at = ?, last_contact_at = NOW() WHERE id = ?',
[remindAt, id]
);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Lead nicht gefunden.' });
const [[lead]] = await pool.query('SELECT title FROM icon_leads WHERE id = ?', [id]);
await logHistory({
entityType: 'lead',
entityId: id,
entityLabel: lead?.title,
action: 'reminder',
text: remindAt ? `Erinnerung um ${days} Tag(e) verschoben` : 'Erinnerung erledigt',
userId: req.user.id,
});
res.json({ ok: true, remind_at: remindAt });
} 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 title FROM icon_leads WHERE id = ? LIMIT 1', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Lead nicht gefunden.' });
await pool.query('DELETE FROM icon_leads WHERE id = ?', [id]);
await logHistory({
entityType: 'lead',
entityId: null,
entityLabel: rows[0].title,
action: 'deleted',
text: `Lead "${rows[0].title}" gelöscht`,
userId: req.user.id,
});
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/* ------------------------------------------------------------- Checkliste */
router.post('/:id/todos', async (req, res, next) => {
const leadId = Number(req.params.id);
const text = String(req.body?.text || '').trim();
if (!Number.isInteger(leadId) || !text) {
return res.status(400).json({ error: 'Lead und Text sind erforderlich.' });
}
try {
const [[position]] = await pool.query(
'SELECT COALESCE(MAX(position), 0) + 1 AS next FROM icon_lead_todos WHERE lead_id = ?',
[leadId]
);
const [result] = await pool.query(
'INSERT INTO icon_lead_todos (lead_id, text, due_on, position, created_by) VALUES (?, ?, ?, ?, ?)',
[leadId, text.slice(0, 500), dateOrNull(req.body?.due_on), position.next, req.user.id]
);
const [[lead]] = await pool.query('SELECT title FROM icon_leads WHERE id = ?', [leadId]);
await logHistory({
entityType: 'lead',
entityId: leadId,
entityLabel: lead?.title,
action: 'todo',
text: `Aufgabe ergänzt: ${text}`,
userId: req.user.id,
});
res.status(201).json({ id: result.insertId });
} catch (err) {
next(err);
}
});
router.patch('/todos/:todoId', async (req, res, next) => {
const id = Number(req.params.todoId);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
try {
const [rows] = await pool.query('SELECT * FROM icon_lead_todos WHERE id = ? LIMIT 1', [id]);
const todo = rows[0];
if (!todo) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
const fields = [];
const values = [];
if (req.body?.text !== undefined) {
fields.push('text = ?');
values.push(String(req.body.text).trim().slice(0, 500));
}
if (req.body?.done !== undefined) {
fields.push('done = ?');
values.push(req.body.done ? 1 : 0);
}
if (req.body?.due_on !== undefined) {
fields.push('due_on = ?');
values.push(dateOrNull(req.body.due_on));
}
if (fields.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
await pool.query(`UPDATE icon_lead_todos SET ${fields.join(', ')} WHERE id = ?`, [...values, id]);
// Abgehakt ist ein Ereignis, das in die Historie gehoert - der reine
// Text-Feinschliff nicht.
if (req.body?.done !== undefined && Boolean(todo.done) !== Boolean(req.body.done)) {
const [[lead]] = await pool.query('SELECT title FROM icon_leads WHERE id = ?', [todo.lead_id]);
await logHistory({
entityType: 'lead',
entityId: todo.lead_id,
entityLabel: lead?.title,
action: 'todo',
text: `${req.body.done ? 'Erledigt' : 'Wieder offen'}: ${todo.text}`,
userId: req.user.id,
});
}
res.json({ ok: true });
} catch (err) {
next(err);
}
});
router.delete('/todos/:todoId', async (req, res, next) => {
const id = Number(req.params.todoId);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
try {
const [result] = await pool.query('DELETE FROM icon_lead_todos WHERE id = ?', [id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
function dateOrNull(value) {
const text = String(value ?? '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
}
function normalize(row) {
return {
...row,
archived: Boolean(row.archived),
todo_count: Number(row.todo_count ?? 0),
todo_done: Number(row.todo_done ?? 0),
company_rating: row.company_rating === null ? null : Number(row.company_rating),
};
}
function parseChanges(row) {
let changes = null;
try {
changes = row.changes ? JSON.parse(row.changes) : null;
} catch {
changes = null;
}
return { ...row, changes };
}
export default router;
export { STAGES };
+123
View File
@@ -0,0 +1,123 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
/*
* Datengrundlage der Unternehmenskarte.
*
* Geliefert wird ein fertiger Graph: Unternehmen und Kontakte als Knoten,
* dazwischen drei Arten von Kanten.
*
* employment Kontakt arbeitet dort aktuell
* former Kontakt hat dort gearbeitet (der Zweig "Verlassen")
* group Unternehmen gehoert zu einer Dachgesellschaft
* contact Kontakt kennt Kontakt (aus icon_connections)
*
* Das Layout rechnet der Browser (d3-force); hier wird bewusst nur die
* Struktur geliefert, damit ein Filter keine zweite Abfragelogik braucht.
*/
router.get('/', async (req, res, next) => {
const ratingMin = Number(req.query.rating_min) || 0;
const onlyCustomers = String(req.query.scope || '') === 'customers';
const where = [];
const params = [];
if (ratingMin > 0) {
where.push('c.rating >= ?');
params.push(ratingMin);
}
if (onlyCustomers) where.push('c.is_customer = 1');
if (req.query.industry) {
where.push('c.industry = ?');
params.push(String(req.query.industry));
}
try {
const [companies] = await pool.query(
`SELECT c.id, c.name, c.industry, c.group_name, c.rating, c.is_customer,
c.customer_state, c.employees, c.logo
FROM icon_companies c
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY c.name`,
params
);
const ids = companies.map((row) => row.id);
if (ids.length === 0) {
return res.json({ companies: [], contacts: [], edges: [], groups: [] });
}
const [contacts] = await pool.query(
`SELECT DISTINCT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS name,
k.position, k.avatar, k.kind, k.status, k.company_id
FROM icon_contacts k
LEFT JOIN icon_employments e ON e.contact_id = k.id
WHERE k.company_id IN (?) OR e.company_id IN (?)`,
[ids, ids]
);
const [former] = await pool.query(
`SELECT e.contact_id, e.company_id, e.position, e.started_on, e.ended_on
FROM icon_employments e
WHERE e.company_id IN (?) AND e.is_current = 0`,
[ids]
);
const [links] = await pool.query(
`SELECT id, contact_a, contact_b, kind, strength FROM icon_connections`
);
const edges = [];
for (const contact of contacts) {
if (contact.company_id && ids.includes(contact.company_id)) {
edges.push({ type: 'employment', contact_id: contact.id, company_id: contact.company_id });
}
}
for (const row of former) {
// Wer inzwischen wieder dort ist, steht schon als aktuelle Kante drin.
const contact = contacts.find((c) => c.id === row.contact_id);
if (contact?.company_id === row.company_id) continue;
edges.push({
type: 'former',
contact_id: row.contact_id,
company_id: row.company_id,
position: row.position,
ended_on: row.ended_on,
});
}
for (const row of links) {
edges.push({ type: 'contact', id: row.id, a: row.contact_a, b: row.contact_b, kind: row.kind });
}
// Dachgesellschaften: nur solche, unter denen mehr als ein Unternehmen
// haengt - eine Gruppe mit einem Mitglied ist keine.
const groupCounts = new Map();
for (const row of companies) {
if (!row.group_name) continue;
groupCounts.set(row.group_name, (groupCounts.get(row.group_name) ?? 0) + 1);
}
const groups = [...groupCounts.entries()]
.filter(([, count]) => count > 1)
.map(([name, count]) => ({ name, count }));
for (const row of companies) {
if (row.group_name && groupCounts.get(row.group_name) > 1) {
edges.push({ type: 'group', company_id: row.id, group: row.group_name });
}
}
res.json({
companies: companies.map((row) => ({ ...row, is_customer: Boolean(row.is_customer) })),
contacts,
edges,
groups,
});
} catch (err) {
next(err);
}
});
export default router;
+159
View File
@@ -0,0 +1,159 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAuth, requireAdmin } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
/*
* Stammdaten: die gepflegten Auswahllisten.
*
* Lesen darf jeder - die Listen speisen Auswahlfelder in fast jedem Formular.
* Pflegen dürfen nur Administratoren: ein umbenannter Wert wirkt sich auf
* jeden Datensatz aus, der ihn trägt.
*/
/** Bekannte Listen. Eine weitere hinzuzufügen heißt: hier einen Eintrag mehr. */
export const FIELDS = {
industry: { label: 'Branchen', table: 'icon_companies', column: 'industry' },
contact_kind: { label: 'Arten der Kontakte', table: 'icon_contacts', column: 'kind' },
};
/** Die Werte einer Liste - in der gepflegten Reihenfolge. */
export async function optionValues(field) {
const [rows] = await pool.query(
'SELECT value FROM icon_options WHERE field = ? ORDER BY position, value',
[field]
);
return rows.map((row) => row.value);
}
// GET /api/options - alle Listen auf einmal; die Oberfläche lädt sie beim Start.
router.get('/', async (req, res, next) => {
try {
const [rows] = await pool.query(
'SELECT id, field, value, position FROM icon_options ORDER BY field, position, value'
);
// Wie oft ein Wert benutzt wird - die Nutzerverwaltung zeigt es an, damit
// niemand versehentlich etwas entfernt, das hundertfach in Gebrauch ist.
const used = {};
for (const [field, config] of Object.entries(FIELDS)) {
const [counts] = await pool.query(
`SELECT ${config.column} AS value, COUNT(*) AS count FROM ${config.table}
WHERE ${config.column} IS NOT NULL AND ${config.column} <> ''
GROUP BY ${config.column}`
);
used[field] = Object.fromEntries(counts.map((row) => [row.value, Number(row.count)]));
}
const fields = Object.entries(FIELDS).map(([field, config]) => ({
field,
label: config.label,
values: rows
.filter((row) => row.field === field)
.map((row) => ({ ...row, used: used[field]?.[row.value] ?? 0 })),
}));
res.json({ fields });
} catch (err) {
next(err);
}
});
router.use(requireAdmin);
router.post('/', async (req, res, next) => {
const field = String(req.body?.field || '');
const value = String(req.body?.value || '').trim().slice(0, 190);
if (!FIELDS[field]) return res.status(400).json({ error: 'Unbekannte Liste.' });
if (!value) return res.status(400).json({ error: 'Der Wert darf nicht leer sein.' });
try {
const [[position]] = await pool.query(
'SELECT COALESCE(MAX(position), 0) + 1 AS next FROM icon_options WHERE field = ?',
[field]
);
const [result] = await pool.query(
'INSERT INTO icon_options (field, value, position) VALUES (?, ?, ?)',
[field, value, position.next]
);
res.status(201).json({ id: result.insertId, field, value });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Diesen Wert gibt es in der Liste bereits.' });
}
next(err);
}
});
/**
* Umbenennen.
*
* Der alte Wert steht als Text in den Datensätzen - er wird mitgezogen.
* Andernfalls trüge ein Unternehmen eine Branche, die seine Auswahlliste nicht
* mehr kennt, und das Feld stünde beim nächsten Öffnen leer.
*/
router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id);
const value = String(req.body?.value || '').trim().slice(0, 190);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
if (!value) return res.status(400).json({ error: 'Der Wert darf nicht leer sein.' });
try {
const [rows] = await pool.query('SELECT field, value FROM icon_options WHERE id = ? LIMIT 1', [id]);
const option = rows[0];
if (!option) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
if (option.value === value) return res.json({ ok: true, renamed: 0 });
const config = FIELDS[option.field];
await pool.query('UPDATE icon_options SET value = ? WHERE id = ?', [value, id]);
const [result] = await pool.query(
`UPDATE ${config.table} SET ${config.column} = ? WHERE ${config.column} = ?`,
[value, option.value]
);
res.json({ ok: true, renamed: result.affectedRows });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Diesen Wert gibt es in der Liste bereits.' });
}
next(err);
}
});
/**
* Entfernen.
*
* Ein Wert, der noch benutzt wird, bleibt stehen: ihn zu löschen würde die
* Datensätze nicht ändern, aber ihr Auswahlfeld leer wirken lassen. Wer ihn
* wirklich loswerden will, benennt ihn vorher um oder pflegt die Datensätze.
*/
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 field, value FROM icon_options WHERE id = ? LIMIT 1', [id]);
const option = rows[0];
if (!option) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
const config = FIELDS[option.field];
const [[count]] = await pool.query(
`SELECT COUNT(*) AS count FROM ${config.table} WHERE ${config.column} = ?`,
[option.value]
);
if (Number(count.count) > 0) {
return res.status(409).json({
error: `„${option.value}" wird noch ${count.count}× verwendet und kann nicht entfernt werden.`,
});
}
await pool.query('DELETE FROM icon_options WHERE id = ?', [id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;
+134
View File
@@ -0,0 +1,134 @@
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
/*
* Lesender Zugriff auf die Kontakte des perinco CRM.
*
* Es wird NICHTS gespeichert. Die Kontakte gehören dem perinco CRM, werden dort
* gepflegt und hier nur angezeigt - gekennzeichnet, nicht vermischt. Ein
* nächtlicher Abgleich in die eigene Datenbank wäre der falsche Weg: dann
* stünde derselbe Mensch in zwei Systemen, und irgendwann wäre zu klären,
* welcher Eintrag gilt.
*
* Die Felder werden auf die vorhandenen Felder eines ICON-Kontakts abgebildet.
* Neue Felder anzulegen war ausdrücklich nicht gewollt - was drüben kein
* Gegenstück hat (Anrede, Anschrift, Website), bleibt drüben.
*/
/** Eine Minute Zwischenspeicher - die Liste ändert sich nicht im Sekundentakt. */
const CACHE_MS = 60_000;
let cache = { at: 0, contacts: [] };
const CONFIGURED = () => Boolean(process.env.PERINCO_API_URL && process.env.PERINCO_API_TOKEN);
router.get('/status', (req, res) => {
res.json({ configured: CONFIGURED(), url: process.env.PERINCO_API_URL ?? null });
});
/**
* Die Kontakte von drueben holen - mit Zwischenspeicher.
*
* Liegt ausserhalb der Route, weil auch die Suche sie braucht. Ohne den
* gemeinsamen Zwischenspeicher wuerde jede Suchanfrage einen zweiten Aufruf
* nach drueben ausloesen.
*
* Wirft nie: die Suche darf nicht scheitern, nur weil das andere Haus gerade
* nicht erreichbar ist.
*/
export async function fetchPerincoContacts() {
if (!CONFIGURED()) return { contacts: [], cached: false, error: 'nicht eingerichtet' };
if (Date.now() - cache.at < CACHE_MS) return { contacts: cache.contacts, cached: true };
const base = String(process.env.PERINCO_API_URL).replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch(`${base}/api/integrations/contacts`, {
headers: {
Authorization: `Bearer ${process.env.PERINCO_API_TOKEN}`,
Accept: 'application/json',
},
signal: controller.signal,
});
if (!response.ok) {
// Die Meldung nennt den Grund, aber niemals den Token.
return {
contacts: [],
cached: false,
error:
response.status === 401
? 'Das perinco CRM hat den Zugang abgelehnt (Token stimmt nicht).'
: `Das perinco CRM antwortete mit HTTP ${response.status}.`,
};
}
const payload = await response.json();
const contacts = (payload?.contacts ?? []).map(toIconShape);
cache = { at: Date.now(), contacts };
return { contacts, cached: false };
} catch (err) {
return {
contacts: [],
cached: false,
error:
err?.name === 'AbortError'
? 'Das perinco CRM hat nicht rechtzeitig geantwortet.'
: 'Das perinco CRM ist gerade nicht erreichbar.',
};
} finally {
clearTimeout(timer);
}
}
router.get('/contacts', async (req, res) => {
if (!CONFIGURED()) {
return res.status(503).json({
error: 'Der Zugriff auf das perinco CRM ist nicht eingerichtet (PERINCO_API_URL, PERINCO_API_TOKEN).',
});
}
const result = await fetchPerincoContacts();
if (result.error) return res.status(502).json({ error: result.error });
res.json({ contacts: result.contacts, cached: result.cached });
});
/**
* Ein perinco-Kontakt in der Form, die die ICON-Oberfläche ohnehin darstellt.
*
* Die Kennung bekommt ein Präfix: sie zählt in einer anderen Datenbank und darf
* nie mit einer ICON-Kennung verwechselt werden - sonst öffnete ein Klick den
* falschen Datensatz.
*/
function toIconShape(row) {
const name = [row.first_name, row.last_name].filter(Boolean).join(' ').trim();
return {
id: `perinco-${row.id}`,
first_name: row.first_name ?? null,
last_name: row.last_name ?? '',
name: name || 'Unbenannt',
position: row.position ?? null,
company_name: row.company ?? null,
email: row.email ?? null,
phone: row.phone ?? null,
linkedin: row.linkedin ?? null,
avatar: row.avatar ?? null,
// Drüben sind das Recruiter / Partner / Endkunde - die Werte werden
// unverändert gezeigt und NICHT auf die ICON-Arten umgebogen. Eine
// erfundene Zuordnung wäre schlechter als die ehrliche Fremdbezeichnung.
kind: row.kind ?? null,
lead_source: row.source ?? null,
tags: Array.isArray(row.tags) ? row.tags : [],
bio: row.notes ?? null,
created_at: row.created_at ?? null,
updated_at: row.updated_at ?? null,
/** Kennzeichen für die Oberfläche: grüner Rahmen, keine Bearbeitung. */
source_system: 'perinco',
};
}
export default router;
+191
View File
@@ -0,0 +1,191 @@
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);
const STATUS = ['Aktiv', 'Nicht aktiv', 'Abgeschlossen'];
const EDITABLE = ['title', 'company_id', 'contact_id', 'started_on', 'ended_on', 'status', 'rate', 'notes'];
function readBody(body) {
const out = {};
if (body.title !== undefined) out.title = String(body.title ?? '').trim().slice(0, 300);
if (body.company_id !== undefined) out.company_id = positiveOrNull(body.company_id);
if (body.contact_id !== undefined) out.contact_id = positiveOrNull(body.contact_id);
if (body.started_on !== undefined) out.started_on = dateOrNull(body.started_on);
if (body.ended_on !== undefined) out.ended_on = dateOrNull(body.ended_on);
if (body.status !== undefined) out.status = STATUS.includes(body.status) ? body.status : 'Aktiv';
if (body.rate !== undefined) {
const n = Number(body.rate);
out.rate = body.rate === '' || body.rate === null || !Number.isFinite(n) ? null : n;
}
if (body.notes !== undefined) out.notes = body.notes ? String(body.notes) : null;
return out;
}
const positiveOrNull = (value) => {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
};
const dateOrNull = (value) => {
const text = String(value ?? '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
};
router.get('/', async (req, res, next) => {
const where = [];
const params = [];
const q = String(req.query.q || '').trim();
if (q) {
where.push(
"(p.title LIKE ? OR co.name LIKE ? OR CONCAT_WS(' ', k.first_name, k.last_name) LIKE ?)"
);
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
if (req.query.status) {
where.push('p.status = ?');
params.push(String(req.query.status));
}
try {
const [rows] = await pool.query(
`SELECT p.*, co.name AS company_name,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name, k.avatar AS contact_avatar
FROM icon_projects p
LEFT JOIN icon_companies co ON co.id = p.company_id
LEFT JOIN icon_contacts k ON k.id = p.contact_id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY p.started_on DESC, p.id DESC`,
params
);
res.json({ projects: rows.map((row) => ({ ...row, rate: row.rate === null ? null : Number(row.rate) })) });
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
const data = readBody(req.body || {});
if (!data.title) return res.status(400).json({ error: 'Der Titel ist erforderlich.' });
try {
const columns = Object.keys(data);
const [result] = await pool.query(
`INSERT INTO icon_projects (${columns.join(', ')}, created_by)
VALUES (${columns.map(() => '?').join(', ')}, ?)`,
[...columns.map((c) => data[c]), req.user.id]
);
await logHistory({
entityType: 'project',
entityId: result.insertId,
entityLabel: data.title,
action: 'created',
text: `Projekt "${data.title}" angelegt`,
userId: req.user.id,
});
// Ein Projekt betrifft immer beide Karten - deshalb steht es auch in deren
// Historie und nicht nur in der eigenen.
await logRelated(data, `Projekt "${data.title}" angelegt`, req.user.id);
if (data.notes) await indexMentions('project', result.insertId, data.notes);
const [rows] = await pool.query('SELECT * FROM icon_projects WHERE id = ?', [result.insertId]);
res.status(201).json({ project: 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_projects WHERE id = ? LIMIT 1', [id]);
const before = rows[0];
if (!before) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const data = readBody(req.body || {});
const columns = Object.keys(data);
if (columns.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
await pool.query(
`UPDATE icon_projects SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...columns.map((c) => data[c]), id]
);
const changes = diffFields(before, data, EDITABLE);
if (Object.keys(changes).length > 0) {
await logHistory({
entityType: 'project',
entityId: id,
entityLabel: data.title ?? before.title,
action: 'updated',
text: `Projekt "${data.title ?? before.title}": ${describeChanges(changes)}`,
changes,
userId: req.user.id,
});
}
if (data.notes !== undefined) await indexMentions('project', id, data.notes);
const [updated] = await pool.query('SELECT * FROM icon_projects WHERE id = ?', [id]);
res.json({ project: 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 title FROM icon_projects WHERE id = ? LIMIT 1', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
await pool.query('DELETE FROM icon_projects WHERE id = ?', [id]);
await logHistory({
entityType: 'project',
entityId: null,
entityLabel: rows[0].title,
action: 'deleted',
text: `Projekt "${rows[0].title}" gelöscht`,
userId: req.user.id,
});
res.json({ ok: true });
} catch (err) {
next(err);
}
});
async function logRelated(data, text, userId) {
if (data.company_id) {
const [[company]] = await pool.query('SELECT name FROM icon_companies WHERE id = ?', [data.company_id]);
await logHistory({
entityType: 'company',
entityId: data.company_id,
entityLabel: company?.name,
action: 'project',
text,
userId,
});
}
if (data.contact_id) {
const [[contact]] = await pool.query(
"SELECT CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id = ?",
[data.contact_id]
);
await logHistory({
entityType: 'contact',
entityId: data.contact_id,
entityLabel: contact?.name,
action: 'project',
text,
userId,
});
}
}
export default router;
+76
View File
@@ -0,0 +1,76 @@
import express from 'express';
import cors from 'cors';
import { pool } from '../db.js';
const router = express.Router();
/*
* Öffentliche Leseschnittstelle für die ICON Website.
*
* Sie ersetzt den bisherigen Weg, bei dem die Website mit dem Supabase-Anon-Key
* direkt in der Datenbank las. Hier kommt nur heraus, was ohnehin
* veröffentlicht ist - und nur die Felder, die die Website anzeigt.
*
* Drei bewusste Entscheidungen:
*
* * KEINE Anmeldung. Die Seite ist öffentlich, ein Schlüssel im
* Browser-Bundle wäre keiner.
* * Nur Zeilen mit `online = 1` UND nicht archiviert. Der Schalter ist die
* Zusage "erscheint auf der Website"; das Archiv ist die Aussage "aus dem
* Verkehr gezogen". Beides muss zutreffen - im Altbestand steht eine
* archivierte Ausschreibung, deren Schalter noch an ist, und die soll auf
* der Website nicht mit der Kategorie "Archiviert" auftauchen.
* * Nur Lesen. Es gibt hier kein POST, PATCH oder DELETE - auch nicht später.
*
* Nicht ausgeliefert werden: der zugeordnete Kunde, die zugewiesenen Experten,
* die Quelle der Ausschreibung, Fristen und jede interne Kennung darüber
* hinaus. Der alte Weg über den Anon-Key hat `select('*')` erlaubt und damit
* all das offengelegt.
*/
// Die Website läuft auf einer anderen Herkunft als das CRM - ohne diese
// Freigabe blockt der Browser die Antwort. Erlaubt wird ausschliesslich Lesen.
router.use(cors({ origin: '*', methods: ['GET', 'HEAD'] }));
/** Genau die Felder, die die Projektbörse der Website anzeigt. */
const FIELDS = `id, title, status, notes, created_at, updated_at`;
// GET /api/public/tenders
router.get('/tenders', async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT ${FIELDS} FROM icon_tenders
WHERE online = 1 AND status <> 'Archiviert'
ORDER BY created_at DESC, id DESC`
);
// Eine Minute Zwischenspeicher: die Projektbörse ändert sich nicht im
// Sekundentakt, und ein Seitenaufruf soll nicht jedes Mal die Datenbank
// treffen.
res.set('Cache-Control', 'public, max-age=60');
res.json(rows);
} catch (err) {
next(err);
}
});
// GET /api/public/tenders/:id - für eine Detailseite oder einen direkten Link.
router.get('/tenders/: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 ${FIELDS} FROM icon_tenders WHERE id = ? AND online = 1 LIMIT 1`,
[id]
);
if (!rows[0]) return res.status(404).json({ error: 'Nicht gefunden.' });
res.set('Cache-Control', 'public, max-age=60');
res.json(rows[0]);
} catch (err) {
next(err);
}
});
export default router;
+315
View File
@@ -0,0 +1,315 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { mentionPlainText } from '../history.js';
import { askModelJson, aiConfigured, aiModel } from '../ai.js';
import { fetchPerincoContacts } from './perinco.js';
const router = express.Router();
router.use(requireAuth);
/*
* Globale Suche - "wie Google, nur ueber das CRM".
*
* Eine Anfrage, ein Ergebnis ueber alle Bereiche: Kontakte, Unternehmen,
* Ausschreibungen, Projekte, Leads und die Historie. Jeder Treffer traegt
* seinen Typ und seine Kennung, damit die Oberflaeche direkt die richtige
* Karte oeffnen kann.
*
* Bewusst LIKE statt Volltextindex: die Datenmengen sind klein (Tausende
* Zeilen, nicht Millionen), und ein FULLTEXT-Index in MariaDB wuerde bei
* Teilwortsuche - "informat" soll "Finanz Informatik" finden - nicht helfen.
*/
const LIMIT_PER_GROUP = 12;
router.get('/', async (req, res, next) => {
const q = String(req.query.q || '').trim();
if (q.length < 2) return res.json({ query: q, groups: [], total: 0 });
const like = `%${q}%`;
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || LIMIT_PER_GROUP));
try {
const [contacts, companies, tenders, projects, leads, history] = await Promise.all([
pool.query(
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS title,
k.position, k.email, k.avatar, k.kind, k.status, co.name AS company
FROM icon_contacts k
LEFT JOIN icon_companies co ON co.id = k.company_id
WHERE CONCAT_WS(' ', k.first_name, k.last_name) LIKE ? OR k.email LIKE ?
OR k.phone LIKE ? OR k.position LIKE ? OR k.tags LIKE ? OR k.bio LIKE ?
OR k.lead_source LIKE ? OR co.name LIKE ?
ORDER BY k.last_name LIMIT ${limit}`,
[like, like, like, like, like, like, like, like]
),
pool.query(
`SELECT id, name AS title, industry, group_name, address, website, logo,
is_customer, customer_state, rating
FROM icon_companies
WHERE name LIKE ? OR group_name LIKE ? OR industry LIKE ?
OR address LIKE ? OR website LIKE ? OR notes LIKE ?
ORDER BY name LIMIT ${limit}`,
[like, like, like, like, like, like]
),
pool.query(
`SELECT t.id, t.title, t.source, t.status, t.stage, co.name AS company
FROM icon_tenders t
LEFT JOIN icon_companies co ON co.id = t.company_id
WHERE t.title LIKE ? OR t.source LIKE ? OR t.details LIKE ? OR t.notes LIKE ?
ORDER BY t.updated_at DESC LIMIT ${limit}`,
[like, like, like, like]
),
pool.query(
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on,
co.name AS company, CONCAT_WS(' ', k.first_name, k.last_name) AS contact
FROM icon_projects p
LEFT JOIN icon_companies co ON co.id = p.company_id
LEFT JOIN icon_contacts k ON k.id = p.contact_id
WHERE p.title LIKE ? OR p.notes LIKE ?
ORDER BY p.updated_at DESC LIMIT ${limit}`,
[like, like]
),
pool.query(
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact
FROM icon_leads l
LEFT JOIN icon_companies co ON co.id = l.company_id
LEFT JOIN icon_contacts k ON k.id = l.contact_id
WHERE l.title LIKE ? OR l.notes LIKE ?
ORDER BY l.updated_at DESC LIMIT ${limit}`,
[like, like]
),
pool.query(
`SELECT h.id, h.entity_type, h.entity_id, h.entity_label, h.action, h.text,
h.created_at, u.name AS user_name
FROM icon_history h
LEFT JOIN icon_users u ON u.id = h.user_id
WHERE h.text LIKE ? OR h.entity_label LIKE ?
ORDER BY h.id DESC LIMIT ${limit}`,
[like, like]
),
]);
/*
* Die Kontakte des perinco CRM kommen ueber HTTP und liegen nicht in dieser
* Datenbank - gefiltert wird deshalb hier im Speicher. Faellt das andere
* Haus aus, fehlt nur diese Gruppe; die uebrige Suche bleibt benutzbar.
*/
const perinco = await fetchPerincoContacts();
const needle = q.toLowerCase();
const perincoHits = perinco.contacts
.filter((contact) =>
[contact.name, contact.position, contact.company_name, contact.email, contact.phone,
(contact.tags ?? []).join(' ')]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(needle)
)
.slice(0, limit);
const groups = [
{ type: 'contact', label: 'Kontakte', items: contacts[0] },
{
type: 'perinco',
label: 'Kontakte (perinco CRM)',
items: perincoHits.map((contact) => ({
id: contact.id,
title: contact.name,
position: contact.position,
company: contact.company_name,
email: contact.email,
avatar: contact.avatar,
kind: contact.kind,
})),
},
{ type: 'company', label: 'Unternehmen', items: companies[0] },
{ type: 'tender', label: 'Ausschreibungen', items: tenders[0] },
{ type: 'project', label: 'Projekte', items: projects[0] },
{ type: 'lead', label: 'Leads', items: leads[0] },
{
type: 'history',
label: 'Historie',
// Der Rohtext enthaelt Mention-Token; fuer die Trefferliste wird
// daraus wieder lesbarer Text.
items: history[0].map((row) => ({ ...row, text: mentionPlainText(row.text) })),
},
].filter((group) => group.items.length > 0);
res.json({
query: q,
groups,
total: groups.reduce((sum, group) => sum + group.items.length, 0),
});
} catch (err) {
next(err);
}
});
/**
* Vorschlagsliste fuer die @-Erwaehnung.
*
* Liefert Kontakte, Unternehmen und Benutzer in einem Rutsch - die Oberflaeche
* mischt sie in eine Liste und baut daraus den Token.
*/
router.get('/mentions', async (req, res, next) => {
const q = String(req.query.q || '').trim();
const like = `%${q}%`;
try {
const [contacts] = await pool.query(
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS label,
k.avatar, co.name AS hint
FROM icon_contacts k
LEFT JOIN icon_companies co ON co.id = k.company_id
${q ? "WHERE CONCAT_WS(' ', k.first_name, k.last_name) LIKE ?" : ''}
ORDER BY k.last_name LIMIT 20`,
q ? [like] : []
);
const [companies] = await pool.query(
`SELECT id, name AS label, logo AS avatar, industry AS hint
FROM icon_companies ${q ? 'WHERE name LIKE ?' : ''} ORDER BY name LIMIT 20`,
q ? [like] : []
);
const [users] = await pool.query(
`SELECT id, name AS label, avatar, username AS hint, color
FROM icon_users WHERE is_active = 1 ${q ? 'AND name LIKE ?' : ''} ORDER BY name LIMIT 20`,
q ? [like] : []
);
res.json({
candidates: [
...users.map((row) => ({ ...row, kind: 'user' })),
...contacts.map((row) => ({ ...row, kind: 'contact' })),
...companies.map((row) => ({ ...row, kind: 'company' })),
],
});
} catch (err) {
next(err);
}
});
/* ------------------------------------------------------------- KI-Suche */
/*
* Erweiterte Suche: eine Frage in Worten statt eines Suchbegriffs.
*
* Vorgehen: der Bestand wird zusammengefasst und zusammen mit der Frage an das
* Modell gegeben. Bewusst kein erzeugtes SQL - ein Modell, das Abfragen auf die
* Datenbank schreiben darf, ist eine Angriffsfläche, und der Bestand ist klein
* genug, um ihn vollständig mitzugeben.
*
* Die Obergrenze je Liste ist das Sicherheitsnetz: wächst der Bestand über
* diese Größe, wird der Kontext gekürzt, und die Antwort sagt das dazu.
*/
const AI_LIMIT = 400;
const AI_SYSTEM = `Du beantwortest Fragen zum Datenbestand eines CRM für die Vermittlung von IT- und Management-Beratern.
Du bekommst den Bestand als JSON und eine Frage. Antworte ausschließlich mit JSON:
{ "answer": string, "refs": [{ "type": "contact"|"company"|"tender"|"project"|"lead", "id": number, "label": string }] }
Regeln:
- Antworte auf Deutsch, knapp und sachlich. Keine Anrede, keine Einleitung.
- Stütze dich AUSSCHLIESSLICH auf die übergebenen Daten. Nichts hinzuerfinden, nicht schätzen.
- Findest du nichts, sage das klar ("Dazu steht nichts im Bestand.") und gib refs als leere Liste.
- "refs" enthält jeden Datensatz, den die Antwort nennt, damit die Oberfläche ihn verlinken kann. Höchstens 30.
- Zahlenfragen ("wie viele") beantwortest du mit der Zahl und nennst die Datensätze in refs.
- "kind" ist die Art eines Kontakts, "status" sein Stand, "former_employers" frühere Arbeitgeber.
- "rate_onsite" und "rate_remote" sind STUNDENsätze in Euro (vor Ort bzw. remote), keine Tagessätze. null heißt: nicht hinterlegt, nicht null Euro. Bei Fragen nach dem höchsten oder niedrigsten Satz nur Kontakte mit hinterlegtem Wert vergleichen und dazusagen, wie viele keinen Satz hinterlegt haben. Fragt jemand nach Tagessätzen, antworte mit den Stundensätzen und weise darauf hin, dass im CRM Stundensätze gepflegt sind.
- "perinco_contacts" sind Kontakte des Schwestersystems perinco CRM. Sie zählen mit, sind aber klar als solche zu benennen. Ihre "id" ist eine Zeichenkette ("perinco-31") und gehört NICHT in refs.`;
router.post('/ai', async (req, res, next) => {
const question = String(req.body?.question || '').trim().slice(0, 2000);
if (!question) return res.status(400).json({ error: 'Keine Frage übergeben.' });
if (!aiConfigured()) {
return res.status(503).json({ error: 'Es ist kein OpenAI-Schlüssel hinterlegt.' });
}
try {
const [contacts] = await pool.query(
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS name, k.position, k.kind, k.status,
k.email, k.phone, k.lead_source, k.tags, k.freelancers, k.contact_state,
k.rate_onsite, k.rate_remote, k.linkedin, co.name AS company,
co.industry AS company_industry
FROM icon_contacts k
LEFT JOIN icon_companies co ON co.id = k.company_id
ORDER BY k.last_name LIMIT ${AI_LIMIT}`
);
const [companies] = await pool.query(
`SELECT id, name, industry, group_name, employees, rating, is_customer, customer_state,
freelancers, website
FROM icon_companies ORDER BY name LIMIT ${AI_LIMIT}`
);
const [former] = await pool.query(
`SELECT CONCAT_WS(' ', k.first_name, k.last_name) AS contact, co.name AS company,
e.position, e.ended_on
FROM icon_employments e
JOIN icon_contacts k ON k.id = e.contact_id
JOIN icon_companies co ON co.id = e.company_id
WHERE e.is_current = 0 ORDER BY e.ended_on DESC LIMIT ${AI_LIMIT}`
);
const [tenders] = await pool.query(
`SELECT t.id, t.title, t.status, t.stage, t.source, t.online, co.name AS company
FROM icon_tenders t LEFT JOIN icon_companies co ON co.id = t.company_id
ORDER BY t.updated_at DESC LIMIT ${AI_LIMIT}`
);
const [projects] = await pool.query(
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on, co.name AS company,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact
FROM icon_projects p
LEFT JOIN icon_companies co ON co.id = p.company_id
LEFT JOIN icon_contacts k ON k.id = p.contact_id
ORDER BY p.started_on DESC LIMIT ${AI_LIMIT}`
);
const [leads] = await pool.query(
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact
FROM icon_leads l
LEFT JOIN icon_companies co ON co.id = l.company_id
LEFT JOIN icon_contacts k ON k.id = l.contact_id
ORDER BY l.updated_at DESC LIMIT ${AI_LIMIT}`
);
// Die Kontakte von drueben gehoeren zum Bild - sonst antwortet das Modell
// "dazu steht nichts im Bestand", obwohl die Person in der Liste steht.
const perinco = await fetchPerincoContacts();
const truncated = [contacts, companies, tenders, projects, leads].some(
(list) => list.length >= AI_LIMIT
);
const result = await askModelJson({
system: AI_SYSTEM,
user: `Frage: ${question}\n\nBestand:\n${JSON.stringify({
contacts,
perinco_contacts: perinco.contacts.map((contact) => ({
id: contact.id,
name: contact.name,
position: contact.position,
company: contact.company_name,
email: contact.email,
kind: contact.kind,
tags: contact.tags,
})),
companies,
former_employers: former,
tenders,
projects,
leads,
})}`,
maxTokens: 4000,
});
res.json({
answer: String(result?.answer ?? '').trim(),
refs: Array.isArray(result?.refs) ? result.refs.slice(0, 30) : [],
model: aiModel(),
truncated,
});
} catch (err) {
if (err?.status) return res.status(err.status).json({ error: err.message });
next(err);
}
});
export default router;
+271
View File
@@ -0,0 +1,271 @@
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);
const STATUS = ['Aktiv', 'Inaktiv', 'Ausgelaufen', 'Archiviert'];
const STAGES = [
'Ausschreibung',
'Kontakte sichten',
'Interview',
'Kunden Interview',
'Vertragsgestaltung',
'Abschluss',
'Geschlossen',
];
const EDITABLE = [
'title',
'source',
'link',
'company_id',
'status',
'stage',
'online',
'details',
'notes',
'deadline',
];
function readBody(body) {
const out = {};
if (body.title !== undefined) out.title = String(body.title ?? '').trim().slice(0, 300);
if (body.source !== undefined) out.source = trimOrNull(body.source, 190);
if (body.link !== undefined) out.link = trimOrNull(body.link, 600);
if (body.company_id !== undefined) out.company_id = positiveOrNull(body.company_id);
if (body.status !== undefined) out.status = STATUS.includes(body.status) ? body.status : 'Aktiv';
if (body.stage !== undefined) out.stage = STAGES.includes(body.stage) ? body.stage : 'Ausschreibung';
if (body.online !== undefined) out.online = body.online ? 1 : 0;
if (body.details !== undefined) out.details = body.details ? String(body.details) : null;
if (body.notes !== undefined) out.notes = body.notes ? String(body.notes) : null;
if (body.deadline !== undefined) out.deadline = dateOrNull(body.deadline);
return out;
}
const trimOrNull = (value, max) => {
const text = String(value ?? '').trim();
return text ? text.slice(0, max) : null;
};
const positiveOrNull = (value) => {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
};
const dateOrNull = (value) => {
const text = String(value ?? '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
};
router.get('/', async (req, res, next) => {
const where = [];
const params = [];
const q = String(req.query.q || '').trim();
if (q) {
where.push('(t.title LIKE ? OR t.source LIKE ? OR t.details LIKE ? OR co.name LIKE ?)');
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
}
if (req.query.status) {
where.push('t.status = ?');
params.push(String(req.query.status));
}
if (req.query.stage) {
where.push('t.stage = ?');
params.push(String(req.query.stage));
}
try {
const [rows] = await pool.query(
`SELECT t.*, co.name AS company_name, co.logo AS company_logo,
(SELECT COUNT(*) FROM icon_tender_contacts tc WHERE tc.tender_id = t.id) AS contact_count
FROM icon_tenders t
LEFT JOIN icon_companies co ON co.id = t.company_id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY t.updated_at DESC`,
params
);
/*
* Die zugeordneten Kontakte kommen in einer zweiten Abfrage und werden im
* Speicher zugeordnet. Ein JOIN haette jede Ausschreibung so oft geliefert,
* wie Kontakte daran haengen - und die Ausschreibungsliste zeigt sie
* ohnehin alle auf einmal, sobald eine Zeile aufgeklappt wird.
*/
const ids = rows.map((row) => row.id);
const byTender = new Map();
if (ids.length > 0) {
const [links] = await pool.query(
`SELECT tc.tender_id, k.id, k.avatar, k.position, k.status,
CONCAT_WS(' ', k.first_name, k.last_name) AS name
FROM icon_tender_contacts tc
JOIN icon_contacts k ON k.id = tc.contact_id
WHERE tc.tender_id IN (?)
ORDER BY k.last_name, k.first_name`,
[ids]
);
for (const link of links) {
byTender.set(link.tender_id, [...(byTender.get(link.tender_id) ?? []), link]);
}
}
res.json({
tenders: rows.map((row) => ({
...row,
online: Boolean(row.online),
contacts: byTender.get(row.id) ?? [],
})),
});
} catch (err) {
next(err);
}
});
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 t.*, co.name AS company_name FROM icon_tenders t
LEFT JOIN icon_companies co ON co.id = t.company_id WHERE t.id = ? LIMIT 1`,
[id]
);
if (!rows[0]) return res.status(404).json({ error: 'Ausschreibung nicht gefunden.' });
const [contacts] = await pool.query(
`SELECT tc.role, k.id, k.first_name, k.last_name, k.position, k.avatar, k.status
FROM icon_tender_contacts tc
JOIN icon_contacts k ON k.id = tc.contact_id
WHERE tc.tender_id = ? ORDER BY k.last_name`,
[id]
);
const [history] = await pool.query(
`SELECT h.*, u.name AS user_name, u.color AS user_color
FROM icon_history h LEFT JOIN icon_users u ON u.id = h.user_id
WHERE h.entity_type = 'tender' AND h.entity_id = ?
ORDER BY h.id DESC LIMIT 300`,
[id]
);
res.json({
tender: { ...rows[0], online: Boolean(rows[0].online) },
contacts,
history,
});
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
const data = readBody(req.body || {});
if (!data.title) return res.status(400).json({ error: 'Der Titel ist erforderlich.' });
try {
const columns = Object.keys(data);
const [result] = await pool.query(
`INSERT INTO icon_tenders (${columns.join(', ')}, created_by)
VALUES (${columns.map(() => '?').join(', ')}, ?)`,
[...columns.map((c) => data[c]), req.user.id]
);
await setContacts(result.insertId, req.body?.contact_ids);
await logHistory({
entityType: 'tender',
entityId: result.insertId,
entityLabel: data.title,
action: 'created',
text: `Ausschreibung "${data.title}" angelegt`,
userId: req.user.id,
});
if (data.notes) await indexMentions('tender', result.insertId, data.notes);
const [rows] = await pool.query('SELECT * FROM icon_tenders WHERE id = ?', [result.insertId]);
res.status(201).json({ tender: { ...rows[0], online: Boolean(rows[0].online) } });
} 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_tenders WHERE id = ? LIMIT 1', [id]);
const before = rows[0];
if (!before) return res.status(404).json({ error: 'Ausschreibung nicht gefunden.' });
const data = readBody(req.body || {});
const columns = Object.keys(data);
if (columns.length > 0) {
await pool.query(
`UPDATE icon_tenders SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...columns.map((c) => data[c]), id]
);
}
if (req.body?.contact_ids !== undefined) await setContacts(id, req.body.contact_ids);
const changes = diffFields(before, data, EDITABLE);
if (Object.keys(changes).length > 0) {
await logHistory({
entityType: 'tender',
entityId: id,
entityLabel: data.title ?? before.title,
action: changes.stage ? 'stage' : 'updated',
text: changes.stage
? `"${data.title ?? before.title}": Phase ${changes.stage[0]} → ${changes.stage[1]}`
: `"${data.title ?? before.title}": ${describeChanges(changes)}`,
changes,
userId: req.user.id,
});
}
if (data.notes !== undefined) await indexMentions('tender', id, data.notes);
const [updated] = await pool.query('SELECT * FROM icon_tenders WHERE id = ?', [id]);
res.json({ tender: { ...updated[0], online: Boolean(updated[0].online) } });
} 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 title FROM icon_tenders WHERE id = ? LIMIT 1', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Ausschreibung nicht gefunden.' });
await pool.query('DELETE FROM icon_tenders WHERE id = ?', [id]);
await logHistory({
entityType: 'tender',
entityId: null,
entityLabel: rows[0].title,
action: 'deleted',
text: `Ausschreibung "${rows[0].title}" gelöscht`,
userId: req.user.id,
});
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/** Ersetzt die Zuordnung der Kontakte komplett - das Formular schickt die Liste. */
async function setContacts(tenderId, ids) {
if (ids === undefined) return;
const list = Array.isArray(ids) ? ids.map(Number).filter((n) => Number.isInteger(n) && n > 0) : [];
await pool.query('DELETE FROM icon_tender_contacts WHERE tender_id = ?', [tenderId]);
if (list.length === 0) return;
await pool.query(
`INSERT IGNORE INTO icon_tender_contacts (tender_id, contact_id)
VALUES ${list.map(() => '(?, ?)').join(', ')}`,
list.flatMap((contactId) => [tenderId, contactId])
);
}
export default router;
+202
View File
@@ -0,0 +1,202 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
import { requireAuth, requireAdmin } from '../middleware/auth.js';
import { publicUser } from './auth.js';
import { normalizeAvatar, normalizeColor } from '../userFields.js';
import { logHistory } from '../history.js';
const router = express.Router();
router.use(requireAuth);
/*
* Ausnahme von der Admin-Regel: die Benutzerliste brauchen alle Rollen -
* fuer @-Erwaehnungen, Zustaendigkeiten, Farben und die Historie. Der
* Endpunkt liefert deshalb bewusst nur die Anzeigefelder.
*/
router.get('/directory', async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT id, username, name, role, color, avatar, is_active
FROM icon_users ORDER BY name`
);
res.json({ users: rows.map((row) => ({ ...row, is_active: Boolean(row.is_active) })) });
} catch (err) {
next(err);
}
});
router.use(requireAdmin);
router.get('/', async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT id, username, email, name, role, color, avatar, theme, theme_mode,
is_active, created_at, updated_at
FROM icon_users ORDER BY name`
);
res.json({ users: rows.map(publicUser) });
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
const username = String(req.body?.username || '').trim();
const name = String(req.body?.name || '').trim();
const email = String(req.body?.email || '').trim().toLowerCase() || null;
const role = req.body?.role === 'admin' ? 'admin' : 'user';
const password = String(req.body?.password || '');
if (!username || !name) return res.status(400).json({ error: 'Benutzername und Name sind erforderlich.' });
if (password.length < 8) return res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen haben.' });
const color = normalizeColor(req.body?.color);
if (color.error) return res.status(400).json({ error: color.error });
const avatar = normalizeAvatar(req.body?.avatar);
if (avatar.error) return res.status(400).json({ error: avatar.error });
try {
const [result] = await pool.query(
`INSERT INTO icon_users (username, email, name, role, password_hash, color, avatar, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
[username, email, name, role, await bcrypt.hash(password, 12), color.value, avatar.value]
);
await logHistory({
entityType: 'user',
entityId: result.insertId,
entityLabel: name,
action: 'created',
text: `Benutzer ${name} angelegt`,
userId: req.user.id,
});
const [rows] = await pool.query('SELECT * FROM icon_users WHERE id = ?', [result.insertId]);
res.status(201).json({ user: publicUser(rows[0]) });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Benutzername oder E-Mail ist bereits vergeben.' });
}
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_users WHERE id = ? LIMIT 1', [id]);
const target = rows[0];
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
const fields = [];
const values = [];
if (req.body?.name !== undefined) {
const name = String(req.body.name).trim();
if (!name) return res.status(400).json({ error: 'Der Name darf nicht leer sein.' });
fields.push('name = ?');
values.push(name);
}
if (req.body?.email !== undefined) {
fields.push('email = ?');
values.push(String(req.body.email).trim().toLowerCase() || null);
}
if (req.body?.color !== undefined) {
const color = normalizeColor(req.body.color);
if (color.error) return res.status(400).json({ error: color.error });
fields.push('color = ?');
values.push(color.value);
}
if (req.body?.avatar !== undefined) {
const avatar = normalizeAvatar(req.body.avatar);
if (avatar.error) return res.status(400).json({ error: avatar.error });
fields.push('avatar = ?');
values.push(avatar.value);
}
if (req.body?.password) {
const password = String(req.body.password);
if (password.length < 8) return res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen haben.' });
fields.push('password_hash = ?');
values.push(await bcrypt.hash(password, 12));
}
// Sicherheitsnetz: der letzte aktive Administrator darf sich weder
// degradieren noch abschalten - sonst kommt niemand mehr an die
// Nutzerverwaltung.
const losesAdmin =
(req.body?.role !== undefined && req.body.role !== 'admin' && target.role === 'admin') ||
(req.body?.is_active !== undefined && !req.body.is_active && target.role === 'admin');
if (losesAdmin && (await countOtherActiveAdmins(id)) === 0) {
return res.status(400).json({ error: 'Der letzte aktive Administrator kann nicht geändert werden.' });
}
if (req.body?.role !== undefined) {
fields.push('role = ?');
values.push(req.body.role === 'admin' ? 'admin' : 'user');
}
if (req.body?.is_active !== undefined) {
fields.push('is_active = ?');
values.push(req.body.is_active ? 1 : 0);
}
if (fields.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
await pool.query(`UPDATE icon_users SET ${fields.join(', ')} WHERE id = ?`, [...values, id]);
await logHistory({
entityType: 'user',
entityId: id,
entityLabel: target.name,
action: 'updated',
text: `Benutzer ${target.name} bearbeitet`,
userId: req.user.id,
});
const [updated] = await pool.query('SELECT * FROM icon_users WHERE id = ?', [id]);
res.json({ user: publicUser(updated[0]) });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Benutzername oder E-Mail ist bereits vergeben.' });
}
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.' });
if (id === req.user.id) return res.status(400).json({ error: 'Das eigene Konto kann nicht gelöscht werden.' });
try {
const [rows] = await pool.query('SELECT * FROM icon_users WHERE id = ? LIMIT 1', [id]);
const target = rows[0];
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
if (target.role === 'admin' && (await countOtherActiveAdmins(id)) === 0) {
return res.status(400).json({ error: 'Der letzte aktive Administrator kann nicht gelöscht werden.' });
}
await pool.query('DELETE FROM icon_users WHERE id = ?', [id]);
await logHistory({
entityType: 'user',
entityId: null,
entityLabel: target.name,
action: 'deleted',
text: `Benutzer ${target.name} gelöscht`,
userId: req.user.id,
});
res.json({ ok: true });
} catch (err) {
next(err);
}
});
async function countOtherActiveAdmins(excludeId) {
const [rows] = await pool.query(
"SELECT COUNT(*) AS count FROM icon_users WHERE role = 'admin' AND is_active = 1 AND id <> ?",
[excludeId]
);
return Number(rows[0].count);
}
export default router;
+418
View File
@@ -0,0 +1,418 @@
-- ============================================================================
-- ICON CRM - Datenbankschema (MariaDB, Datenbank icon_CRM)
--
-- Idempotent: die Datei laeuft bei jedem Backend-Start durch. Jedes Statement
-- muss deshalb ein IF NOT EXISTS tragen oder sonst wiederholbar sein.
-- Spaltenaenderungen an bestehenden Tabellen gehoeren nicht hierher, sondern in
-- die Migrationen in db.js - CREATE TABLE IF NOT EXISTS fasst eine vorhandene
-- Tabelle nicht an.
--
-- Grundgedanke des Modells (aus der Featureliste vom 14.09.2026):
-- * "Experten" heissen jetzt Kontakte und umfassen auch die Ansprechpartner
-- der Kunden - eine Tabelle, unterschieden ueber `kind`.
-- * "Kunden" heissen jetzt Unternehmen. Ein Kunde ist ein Unternehmen mit
-- is_customer = 1; der Umschalter in der Oberflaeche filtert nur darauf.
-- * Jede Aenderung landet in icon_history. Wer wann was getan hat, steht
-- damit an genau einer Stelle - je Kontakt, je Unternehmen und global.
-- ============================================================================
-- ---------------------------------------------------------------------------
-- Benutzer
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(60) NOT NULL,
email VARCHAR(190) NULL,
name VARCHAR(190) NOT NULL,
role ENUM('admin','user') NOT NULL DEFAULT 'user',
password_hash VARCHAR(255) NOT NULL,
-- Persoenliche Farbe: traegt Initialen-Avatare, Historie und Netzwerkknoten.
color VARCHAR(9) NULL,
-- Foto als Data-URL, im Browser auf 256 px vorkomprimiert.
avatar MEDIUMTEXT NULL,
theme VARCHAR(40) NULL,
theme_mode ENUM('light','dark') NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_icon_users_username (username),
UNIQUE KEY uq_icon_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Unternehmen (fruehere "Kunden")
--
-- `is_customer` unterscheidet unsere Kunden von allen uebrigen bekannten
-- Unternehmen; `customer_state` trennt darin aktiv von alumni. Ein Unternehmen
-- ohne Kundenhaken ist trotzdem vollwertig - dort haengen Werdegaenge, Leads
-- und die Unternehmenskarte dran.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_companies (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL,
-- Dachgesellschaft, z. B. "Finanz Informatik" ueber FI Hamburg/Frankfurt.
group_name VARCHAR(190) NULL,
industry VARCHAR(190) NULL,
employees INT UNSIGNED NULL,
website VARCHAR(300) NULL,
address TEXT NULL,
logo MEDIUMTEXT NULL,
-- 0 = nicht bewertet, 1..5 Sterne: wie interessant ist das Unternehmen.
rating TINYINT UNSIGNED NOT NULL DEFAULT 0,
is_customer TINYINT(1) NOT NULL DEFAULT 0,
customer_state ENUM('aktiv','alumni') NOT NULL DEFAULT 'aktiv',
freelancers ENUM('unbekannt','ja','nein') NOT NULL DEFAULT 'unbekannt',
notes MEDIUMTEXT NULL,
created_by INT UNSIGNED NULL,
owner_id INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_icon_companies_name (name),
KEY idx_icon_companies_group (group_name),
KEY idx_icon_companies_customer (is_customer, customer_state),
CONSTRAINT fk_icon_companies_creator FOREIGN KEY (created_by)
REFERENCES icon_users (id) ON DELETE SET NULL,
CONSTRAINT fk_icon_companies_owner FOREIGN KEY (owner_id)
REFERENCES icon_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Kontakte (fruehere "Experten" plus Ansprechpartner der Unternehmen)
--
-- Der Verfuegbarkeitsstatus ist bewusst entfallen (Featureliste 14.09.2026).
-- `status` beschreibt nur noch den Stand der Zusammenarbeit.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_contacts (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
first_name VARCHAR(120) NULL,
last_name VARCHAR(120) NOT NULL,
position VARCHAR(190) NULL,
email VARCHAR(190) NULL,
phone VARCHAR(80) NULL,
linkedin VARCHAR(300) NULL,
avatar MEDIUMTEXT NULL,
-- Aktueller Arbeitgeber. Die Historie der frueheren steht in
-- icon_employments - daraus speist sich der Zweig "Verlassen".
company_id INT UNSIGNED NULL,
kind ENUM('Experte','Ansprechpartner','Lead') NOT NULL DEFAULT 'Experte',
status ENUM('Interessiert','Interview','Gebucht','Inaktiv') NOT NULL DEFAULT 'Interessiert',
-- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...).
lead_source VARCHAR(190) NULL,
-- Featureliste: "persoenlicher Kontakt" oder "noch kein Kontakt gehabt".
contact_state ENUM('unbekannt','persoenlich','kein Kontakt') NOT NULL DEFAULT 'unbekannt',
freelancers ENUM('unbekannt','ja','nein') NOT NULL DEFAULT 'unbekannt',
bio MEDIUMTEXT NULL,
-- Kommagetrennte Schlagworte; die Oberflaeche zerlegt sie in Chips.
tags VARCHAR(1000) NULL,
rate_onsite DECIMAL(8,2) NULL,
rate_remote DECIMAL(8,2) NULL,
cv_url VARCHAR(500) NULL,
cv_file VARCHAR(255) NULL,
created_by INT UNSIGNED NULL,
owner_id INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_icon_contacts_name (last_name, first_name),
KEY idx_icon_contacts_company (company_id),
KEY idx_icon_contacts_kind (kind),
KEY idx_icon_contacts_status (status),
CONSTRAINT fk_icon_contacts_company FOREIGN KEY (company_id)
REFERENCES icon_companies (id) ON DELETE SET NULL,
CONSTRAINT fk_icon_contacts_creator FOREIGN KEY (created_by)
REFERENCES icon_users (id) ON DELETE SET NULL,
CONSTRAINT fk_icon_contacts_owner FOREIGN KEY (owner_id)
REFERENCES icon_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Werdegang: welcher Kontakt war wann bei welchem Unternehmen
--
-- Traegt zwei Dinge aus der Featureliste: die automatische Liste "letzter
-- Arbeitgeber (Branche & Unternehmen)" und den Zweig "Verlassen" im Stammbaum
-- eines Unternehmens (is_current = 0).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_employments (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
contact_id INT UNSIGNED NOT NULL,
company_id INT UNSIGNED NOT NULL,
position VARCHAR(190) NULL,
started_on DATE NULL,
ended_on DATE NULL,
is_current TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_icon_employments_contact (contact_id),
KEY idx_icon_employments_company (company_id, is_current),
CONSTRAINT fk_icon_employments_contact FOREIGN KEY (contact_id)
REFERENCES icon_contacts (id) ON DELETE CASCADE,
CONSTRAINT fk_icon_employments_company FOREIGN KEY (company_id)
REFERENCES icon_companies (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Interviews: "Hatten wir schon einmal ein Interview, und falls ja - wann,
-- mit wem?" Eigene Tabelle statt Freitext, damit danach gefiltert werden kann.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_interviews (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
contact_id INT UNSIGNED NULL,
company_id INT UNSIGNED NULL,
-- Wer von uns das Gespraech gefuehrt hat.
user_id INT UNSIGNED NULL,
held_on DATE NOT NULL,
notes MEDIUMTEXT NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_icon_interviews_contact (contact_id, held_on),
KEY idx_icon_interviews_company (company_id, held_on),
CONSTRAINT fk_icon_interviews_contact FOREIGN KEY (contact_id)
REFERENCES icon_contacts (id) ON DELETE CASCADE,
CONSTRAINT fk_icon_interviews_company FOREIGN KEY (company_id)
REFERENCES icon_companies (id) ON DELETE CASCADE,
CONSTRAINT fk_icon_interviews_user FOREIGN KEY (user_id)
REFERENCES icon_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Historie / Aktivitaetsprotokoll
--
-- Eine Tabelle fuer alles: Systemereignisse (angelegt, geaendert, geloescht)
-- und von Hand erfasste Notizen. `entity_type`/`entity_id` sagen, an welcher
-- Karte der Eintrag haengt; `user_id` immer, wer es war.
--
-- Absichtlich keine Fremdschluessel auf die Ziel-Tabellen: die Historie soll
-- einen geloeschten Kontakt ueberleben. Deshalb steht mit `entity_label` auch
-- der Name zum Zeitpunkt des Eintrags mit drin.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_history (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
entity_type ENUM('contact','company','tender','project','lead','user','system') NOT NULL,
entity_id INT UNSIGNED NULL,
entity_label VARCHAR(300) NULL,
-- created | updated | deleted | note | mention | interview | stage | ...
action VARCHAR(40) NOT NULL DEFAULT 'note',
-- Anzeigetext, darf Mention-Token @[Name](contact:12) enthalten.
text MEDIUMTEXT NULL,
-- Feldaenderungen als JSON: { feld: [vorher, nachher], ... }
changes MEDIUMTEXT NULL,
user_id INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_icon_history_entity (entity_type, entity_id, created_at),
KEY idx_icon_history_created (created_at),
CONSTRAINT fk_icon_history_user FOREIGN KEY (user_id)
REFERENCES icon_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Erwaehnungen (@-Mentions)
--
-- Der Token steht im Text; diese Tabelle ist der Index dazu. Damit beantwortet
-- eine Abfrage "wo wurde dieses Unternehmen ueberall genannt?", ohne jeden
-- Freitext der Datenbank durchsuchen zu muessen.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_mentions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
-- Wo die Erwaehnung steht.
source_type ENUM('history','contact','company','tender','project','lead') NOT NULL,
source_id BIGINT UNSIGNED NOT NULL,
-- Wer bzw. was erwaehnt wurde.
target_type ENUM('contact','company','user') NOT NULL,
target_id INT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_icon_mentions (source_type, source_id, target_type, target_id),
KEY idx_icon_mentions_target (target_type, target_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Netzwerk: wer kennt wen
--
-- Das Paar wird aufsteigend gespeichert (a < b), der Unique-Key verhindert
-- damit Duplikate in beide Richtungen.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_connections (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
contact_a INT UNSIGNED NOT NULL,
contact_b INT UNSIGNED NOT NULL,
kind VARCHAR(60) NOT NULL DEFAULT 'Kontakt',
strength TINYINT UNSIGNED NOT NULL DEFAULT 1,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_icon_connections (contact_a, contact_b),
CONSTRAINT fk_icon_connections_a FOREIGN KEY (contact_a)
REFERENCES icon_contacts (id) ON DELETE CASCADE,
CONSTRAINT fk_icon_connections_b FOREIGN KEY (contact_b)
REFERENCES icon_contacts (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Ausschreibungen
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_tenders (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(300) NOT NULL,
source VARCHAR(190) NULL,
link VARCHAR(600) NULL,
company_id INT UNSIGNED NULL,
status ENUM('Aktiv','Inaktiv','Ausgelaufen','Archiviert') NOT NULL DEFAULT 'Aktiv',
stage ENUM('Ausschreibung','Kontakte sichten','Interview','Kunden Interview',
'Vertragsgestaltung','Abschluss','Geschlossen') NOT NULL DEFAULT 'Ausschreibung',
online TINYINT(1) NOT NULL DEFAULT 1,
details MEDIUMTEXT NULL,
notes MEDIUMTEXT NULL,
deadline DATE NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_icon_tenders_stage (stage),
KEY idx_icon_tenders_status (status),
CONSTRAINT fk_icon_tenders_company FOREIGN KEY (company_id)
REFERENCES icon_companies (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS icon_tender_contacts (
tender_id INT UNSIGNED NOT NULL,
contact_id INT UNSIGNED NOT NULL,
role VARCHAR(120) NULL,
PRIMARY KEY (tender_id, contact_id),
CONSTRAINT fk_icon_tc_tender FOREIGN KEY (tender_id)
REFERENCES icon_tenders (id) ON DELETE CASCADE,
CONSTRAINT fk_icon_tc_contact FOREIGN KEY (contact_id)
REFERENCES icon_contacts (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Laufende Projekte
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_projects (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(300) NOT NULL,
company_id INT UNSIGNED NULL,
contact_id INT UNSIGNED NULL,
started_on DATE NULL,
ended_on DATE NULL,
status ENUM('Aktiv','Nicht aktiv','Abgeschlossen') NOT NULL DEFAULT 'Aktiv',
rate DECIMAL(8,2) NULL,
notes MEDIUMTEXT NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_icon_projects_status (status),
CONSTRAINT fk_icon_projects_company FOREIGN KEY (company_id)
REFERENCES icon_companies (id) ON DELETE SET NULL,
CONSTRAINT fk_icon_projects_contact FOREIGN KEY (contact_id)
REFERENCES icon_contacts (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Lead-Pipeline (Kanban)
--
-- Schema steht schon; die Oberflaeche dazu kommt in der zweiten Ausbaustufe.
-- `remind_at` traegt die Erinnerungsfunktion, `reminder_days` das Intervall.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_leads (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(300) NOT NULL,
company_id INT UNSIGNED NULL,
contact_id INT UNSIGNED NULL,
stage ENUM('Fuer Spaeter','Neuer Lead','Erstkontakt','1. Gespraech',
'Followup geplant','Followup Gespraech','Closed','Absage')
NOT NULL DEFAULT 'Neuer Lead',
contact_state ENUM('unbekannt','persoenlich','kein Kontakt') NOT NULL DEFAULT 'unbekannt',
archived TINYINT(1) NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0,
notes MEDIUMTEXT NULL,
-- Zeitpunkt der naechsten Erinnerung; NULL = keine.
remind_at DATETIME NULL,
reminder_days INT UNSIGNED NULL,
last_contact_at DATETIME NULL,
owner_id INT UNSIGNED NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_icon_leads_stage (stage, archived, position),
KEY idx_icon_leads_remind (remind_at),
CONSTRAINT fk_icon_leads_company FOREIGN KEY (company_id)
REFERENCES icon_companies (id) ON DELETE SET NULL,
CONSTRAINT fk_icon_leads_contact FOREIGN KEY (contact_id)
REFERENCES icon_contacts (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS icon_lead_todos (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
lead_id INT UNSIGNED NOT NULL,
text VARCHAR(500) NOT NULL,
done TINYINT(1) NOT NULL DEFAULT 0,
due_on DATE NULL,
position INT NOT NULL DEFAULT 0,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_icon_lead_todos_lead (lead_id, position),
CONSTRAINT fk_icon_lead_todos_lead FOREIGN KEY (lead_id)
REFERENCES icon_leads (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- CV-Import
--
-- Der Ablauf aus der Featureliste als drei Ordner in einer Tabelle:
-- eingang hochgeladen, noch nicht ausgelesen
-- screening von der KI ausgelesen, wartet auf Ja oder Nein
-- auswahl uebernommen - der Kontakt ist angelegt, der CV haengt dran
-- verworfen abgelehnt; die Datei bleibt, bis sie jemand loescht
--
-- `extracted` haelt die Ausgabe des Modells als JSON. Sie bleibt auch nach der
-- Uebernahme stehen: so laesst sich spaeter nachvollziehen, was die KI gelesen
-- hat und was ein Mensch daran geaendert hat.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_cv_imports (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
mime VARCHAR(120) NULL,
size_bytes INT UNSIGNED NOT NULL DEFAULT 0,
status ENUM('eingang','screening','auswahl','verworfen') NOT NULL DEFAULT 'eingang',
extracted MEDIUMTEXT NULL,
-- Das im Lebenslauf gefundene Portrait, unveraendert als Data-URL. Es steht
-- hier nur zwischen Auslesen und Uebernehmen; beim Anlegen des Kontakts geht
-- es verkleinert an icon_contacts.avatar und wird hier geleert.
photo MEDIUMTEXT NULL,
error VARCHAR(500) NULL,
contact_id INT UNSIGNED NULL,
uploaded_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL DEFAULT NULL,
KEY idx_icon_cv_status (status, created_at),
CONSTRAINT fk_icon_cv_contact FOREIGN KEY (contact_id)
REFERENCES icon_contacts (id) ON DELETE SET NULL,
CONSTRAINT fk_icon_cv_user FOREIGN KEY (uploaded_by)
REFERENCES icon_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Stammdaten: gepflegte Auswahllisten
--
-- Branche und Kontaktart waren Freitext bzw. ein festes ENUM. Beides wird in
-- der Praxis erweitert - eine neue Branche, eine neue Art von Kontakt - und
-- beides soll ohne Codeaenderung gehen. Die Werte stehen deshalb hier und
-- speisen die Auswahlfelder der Oberflaeche.
--
-- `field` ist bewusst kein ENUM: eine weitere Liste soll keine Migration
-- kosten.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_options (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
field VARCHAR(40) NOT NULL,
value VARCHAR(190) NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_icon_options (field, value),
KEY idx_icon_options_field (field, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Einstellungen (Schluessel/Wert) - auch fuer einmalige Migrationsmerker
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_settings (
key_name VARCHAR(100) NOT NULL PRIMARY KEY,
value MEDIUMTEXT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+115
View File
@@ -0,0 +1,115 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { initDatabase, pool } from './db.js';
import { loadAiConfig } from './ai.js';
import publicRoutes from './routes/public.js';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import companyRoutes from './routes/companies.js';
import contactRoutes from './routes/contacts.js';
import tenderRoutes from './routes/tenders.js';
import projectRoutes from './routes/projects.js';
import leadRoutes from './routes/leads.js';
import cvRoutes from './routes/cvs.js';
import aiRoutes from './routes/ai.js';
import perincoRoutes from './routes/perinco.js';
import integrationRoutes from './routes/integrations.js';
import optionRoutes from './routes/options.js';
import historyRoutes from './routes/history.js';
import searchRoutes from './routes/search.js';
import networkRoutes from './routes/network.js';
import connectionRoutes from './routes/connections.js';
import dashboardRoutes from './routes/dashboard.js';
const app = express();
const PORT = Number(process.env.PORT || 5007);
const HOST = process.env.HOST || '127.0.0.1';
// Fotos und Logos kommen als Data-URL - deshalb ein groesseres Limit als der
// Standard von 100 kB.
app.use(express.json({ limit: '8mb' }));
// Im Betrieb liefert Apache Oberflaeche und API unter derselben Herkunft aus,
// CORS ist dann ohne Bedeutung. Fuer den lokalen Vite-Server auf einem anderen
// Port braucht es die Freigabe.
app.use(
cors({
origin: (process.env.CORS_ORIGINS || 'http://localhost:3101,http://127.0.0.1:3101').split(','),
})
);
app.get('/api/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ ok: true, db: 'up' });
} catch {
res.status(503).json({ ok: false, db: 'down' });
}
});
// Öffentlich und ohne Anmeldung: die Projektbörse der ICON Website.
// Steht bewusst vor allen geschützten Routen und kennt nur GET.
app.use('/api/public', publicRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/companies', companyRoutes);
app.use('/api/contacts', contactRoutes);
app.use('/api/tenders', tenderRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/leads', leadRoutes);
app.use('/api/cvs', cvRoutes);
app.use('/api/ai', aiRoutes);
// Lesender Zugriff auf die Kontakte des perinco CRM - nichts wird gespeichert.
app.use('/api/perinco', perincoRoutes);
// Gegenrichtung: das perinco CRM liest hier die Kontakte, fester Token, nur GET.
app.use('/api/integrations', integrationRoutes);
app.use('/api/options', optionRoutes);
app.use('/api/history', historyRoutes);
app.use('/api/search', searchRoutes);
app.use('/api/network', networkRoutes);
app.use('/api/connections', connectionRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
// Zentrale Fehlerbehandlung: Einzelheiten ins Log, generische Meldung nach aussen.
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
const status = err?.status || err?.statusCode;
if (err?.type === 'entity.parse.failed' || status === 400) {
return res.status(400).json({ error: 'Ungültige Anfrage (JSON konnte nicht gelesen werden).' });
}
if (err?.type === 'entity.too.large' || status === 413) {
return res.status(413).json({ error: 'Die Anfrage ist zu groß (max. 8 MB).' });
}
// Fehler des Datei-Uploads tragen keinen Status, wohl aber einen sprechenden
// Code. Ohne diesen Zweig saehe der Benutzer nur "Interner Serverfehler".
if (err?.name === 'MulterError') {
const messages = {
LIMIT_FILE_SIZE: 'Die Datei ist zu groß (max. 15 MB).',
LIMIT_FILE_COUNT: 'Es lassen sich höchstens 20 Dateien auf einmal hochladen.',
LIMIT_UNEXPECTED_FILE: 'Unerwartetes Feld im Upload.',
};
return res.status(400).json({ error: messages[err.code] || 'Der Upload ist fehlgeschlagen.' });
}
console.error('[ICON CRM] Unbehandelter Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
try {
await initDatabase();
// Schluessel und Modell koennen in der Datenbank stehen - erst danach weiss
// das Backend, ob die KI ueberhaupt eingerichtet ist.
await loadAiConfig();
app.listen(PORT, HOST, () => {
console.log(`[ICON CRM] Backend läuft auf http://${HOST}:${PORT}`);
});
} catch (err) {
console.error('[ICON CRM] Start fehlgeschlagen:', err);
process.exit(1);
}
+55
View File
@@ -0,0 +1,55 @@
/*
* Pruefung der Felder, die sowohl die Nutzerverwaltung als auch "Mein Konto"
* setzen duerfen. An einer Stelle, damit beide Wege dieselben Grenzen haben.
*/
/** Farbe als #rgb, #rrggbb oder #rrggbbaa; leer setzt zurueck. */
export function normalizeColor(input) {
if (input === null || input === undefined || input === '') return { value: null };
const value = String(input).trim();
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value)) {
return { error: 'Farbe muss ein Hex-Wert sein, z. B. #C1862F.' };
}
return { value: value.toLowerCase() };
}
// Ein Foto liegt als Data-URL in der Datenbank. Der Browser komprimiert es
// vorher auf 256 px, damit die Nutzlast klein bleibt; die Grenze hier ist das
// Sicherheitsnetz gegen ein Bild, das diesen Weg umgeht.
const MAX_AVATAR_CHARS = 400_000;
export function normalizeAvatar(input) {
if (input === null || input === undefined || input === '') return { value: null };
const value = String(input).trim();
const isDataUrl = /^data:image\/(png|jpe?g|webp|gif|avif);base64,[A-Za-z0-9+/=]+$/.test(value);
const isHttpUrl = /^https?:\/\/\S+$/i.test(value);
if (!isDataUrl && !isHttpUrl) {
return { error: 'Foto muss ein Bild oder eine http(s)-Adresse sein.' };
}
if (value.length > MAX_AVATAR_CHARS) {
return { error: 'Das Foto ist zu gross. Bitte ein kleineres Bild verwenden.' };
}
return { value };
}
/*
* Verfügbare Farbwelten. Muss mit THEME_PALETTES in src/types.ts
* übereinstimmen. Ein unbekannter Wert wird abgewiesen, damit ein Tippfehler
* nicht still zum Standarddesign führt.
*/
const THEMES = new Set(['icon', 'halflife', 'pixel']);
export function normalizeTheme(input) {
if (input === null || input === undefined || input === '') return { value: null };
const value = String(input).trim().toLowerCase();
if (!THEMES.has(value)) return { error: 'Unbekanntes Design.' };
return { value };
}
export function normalizeThemeMode(input) {
if (input === null || input === undefined || input === '') return { value: null };
const value = String(input).trim().toLowerCase();
if (value !== 'light' && value !== 'dark') return { error: 'Modus muss light oder dark sein.' };
return { value };
}