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