feat: Projekt Scanner integrieren, Whisper in die Einstellungen

Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft
parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt
statt geteilt, damit die alte Installation unberuehrt bleibt:

- eigene Datenbank perinco_scanner, legt sich beim Start selbst an
- eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis
- eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig
  mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM)
- Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie,
  ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten

Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des
Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem
statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der
alten Datenbank, ohne sie zu veraendern.

Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit
aufklappbaren Unterpunkten, Einstellungen als eigener Reiter.

Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den
Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die
Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet
die Rolle.

Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf
1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische
Dokumentation mit Funktionszeichnungen unter docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-06 15:08:59 +02:00
co-authored by Claude Opus 5
commit 82d1b7eab4
106 changed files with 24133 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
const STAGES = new Set([
'Neu',
'Qualifizierung',
'Angebot',
'Verhandlung',
'Gewonnen',
'Verloren',
]);
const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes'];
const DATE_FIELDS = ['start_date', 'end_date', 'last_interaction_at'];
const NUMBER_FIELDS = ['daily_rate', 'duration_days'];
function emptyToNull(value) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text === '' ? null : text;
}
/** Akzeptiert `YYYY-MM-DD` (auch mit angehaengter Uhrzeit) oder null. */
function normalizeDate(value) {
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value).trim());
if (!match) return { error: 'Ungueltiges Datum (erwartet JJJJ-MM-TT).' };
const [, year, month, day] = match;
const probe = new Date(Number(year), Number(month) - 1, Number(day));
if (
probe.getFullYear() !== Number(year) ||
probe.getMonth() !== Number(month) - 1 ||
probe.getDate() !== Number(day)
) {
return { error: 'Dieses Datum gibt es nicht.' };
}
return { value: `${year}-${month}-${day}` };
}
function normalizeNumber(value, label, max) {
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
const number = Number(String(value).replace(',', '.'));
if (!Number.isFinite(number) || number < 0) return { error: `${label} muss eine Zahl ab 0 sein.` };
if (number > max) return { error: `${label} ist unrealistisch hoch.` };
return { value: number };
}
function parseTags(raw) {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === 'string') : [];
} catch {
return [];
}
}
function serializeTags(value) {
if (!Array.isArray(value)) return null;
return JSON.stringify([...new Set(value.map((tag) => String(tag).trim()).filter(Boolean))]);
}
const SELECT_OPPORTUNITIES = `
SELECT o.*,
creator.name AS created_by_name,
owner.name AS owner_name,
owner.color AS owner_color,
owner.avatar AS owner_avatar
FROM crm_opportunities o
LEFT JOIN crm_users creator ON creator.id = o.created_by
LEFT JOIN crm_users owner ON owner.id = o.owner_id
`;
/**
* DECIMAL kommt aus mysql2 als String zurueck - fuer das Frontend werden daraus
* Zahlen, damit dort nicht ueberall geparst werden muss.
*/
function mapOpportunity(row, contacts) {
return {
...row,
daily_rate: row.daily_rate === null ? null : Number(row.daily_rate),
duration_days: row.duration_days === null ? null : Number(row.duration_days),
probability: Number(row.probability),
tags: parseTags(row.tags),
contacts: contacts ?? [],
};
}
/** Ansprechpartner der uebergebenen Opportunities als Map id -> Liste. */
async function loadContacts(opportunityIds) {
if (opportunityIds.length === 0) return new Map();
const [rows] = await pool.query(
`SELECT c.opportunity_id, c.role, c.lead_id,
l.first_name, l.last_name, l.company, l.position, l.email, l.phone, l.avatar
FROM crm_opportunity_contacts c
JOIN crm_leads l ON l.id = c.lead_id
WHERE c.opportunity_id IN (${opportunityIds.map(() => '?').join(', ')})
ORDER BY l.last_name ASC, l.first_name ASC`,
opportunityIds
);
const map = new Map();
for (const row of rows) {
const list = map.get(row.opportunity_id) ?? [];
list.push({
lead_id: row.lead_id,
role: row.role,
first_name: row.first_name,
last_name: row.last_name,
company: row.company,
position: row.position,
email: row.email,
phone: row.phone,
avatar: row.avatar,
});
map.set(row.opportunity_id, list);
}
return map;
}
async function fetchOpportunity(id) {
const [rows] = await pool.query(`${SELECT_OPPORTUNITIES} WHERE o.id = ? LIMIT 1`, [id]);
if (!rows[0]) return null;
const contacts = await loadContacts([id]);
return mapOpportunity(rows[0], contacts.get(id));
}
async function userExists(id) {
if (id === null) return true;
const [rows] = await pool.query('SELECT id FROM crm_users WHERE id = ? LIMIT 1', [id]);
return Boolean(rows[0]);
}
/** Prueft die Ansprechpartner-Liste `[{ lead_id, role }]`. */
async function normalizeContacts(raw) {
if (raw === undefined) return undefined;
if (!Array.isArray(raw)) return { error: 'Ansprechpartner muessen als Liste uebergeben werden.' };
const seen = new Map();
for (const entry of raw) {
const leadId = Number(entry?.lead_id ?? entry);
if (!Number.isInteger(leadId) || leadId <= 0) {
return { error: 'Ungueltige Kontakt-ID bei den Ansprechpartnern.' };
}
const role = emptyToNull(entry?.role);
if (role !== null && role.length > 120) {
return { error: 'Die Rolle eines Ansprechpartners ist zu lang (max. 120 Zeichen).' };
}
seen.set(leadId, role);
}
const ids = [...seen.keys()];
if (ids.length === 0) return { contacts: [] };
const [rows] = await pool.query(
`SELECT id FROM crm_leads WHERE id IN (${ids.map(() => '?').join(', ')})`,
ids
);
if (rows.length !== ids.length) return { error: 'Mindestens ein Ansprechpartner existiert nicht.' };
return { contacts: ids.map((leadId) => ({ lead_id: leadId, role: seen.get(leadId) })) };
}
async function replaceContacts(conn, opportunityId, contacts) {
await conn.query('DELETE FROM crm_opportunity_contacts WHERE opportunity_id = ?', [opportunityId]);
if (contacts.length === 0) return;
await conn.query(
`INSERT INTO crm_opportunity_contacts (opportunity_id, lead_id, role)
VALUES ${contacts.map(() => '(?, ?, ?)').join(', ')}`,
contacts.flatMap((contact) => [opportunityId, contact.lead_id, contact.role])
);
}
/** Bearbeiten und Loeschen darf der Ersteller, der Zustaendige und jeder Admin. */
function mayModify(user, opportunity) {
return (
user.role === 'admin' ||
opportunity.created_by === user.id ||
opportunity.owner_id === user.id
);
}
// GET /api/opportunities
router.get('/', async (req, res, next) => {
try {
const [rows] = await pool.query(
`${SELECT_OPPORTUNITIES} ORDER BY o.updated_at DESC, o.id DESC`
);
const contacts = await loadContacts(rows.map((row) => row.id));
res.json(rows.map((row) => mapOpportunity(row, contacts.get(row.id))));
} catch (err) {
next(err);
}
});
// POST /api/opportunities
router.post('/', async (req, res, next) => {
const title = emptyToNull(req.body?.title);
if (!title) return res.status(400).json({ error: 'Ein Titel ist erforderlich.' });
// Fehlt die Phase, gilt "Neu" - eine falsch geschriebene Phase wird aber
// abgewiesen statt still ueberschrieben, sonst glaubt der Client, er haette
// etwas anderes gesetzt.
const stageGiven = req.body?.stage !== undefined && req.body.stage !== null && req.body.stage !== '';
if (stageGiven && !STAGES.has(req.body.stage)) {
return res.status(400).json({ error: 'Ungueltige Phase.' });
}
const stage = stageGiven ? req.body.stage : 'Neu';
const probability = normalizeNumber(req.body?.probability ?? 10, 'Die Wahrscheinlichkeit', 100);
if (probability.error) return res.status(400).json({ error: probability.error });
const numbers = {};
for (const [field, label, max] of [
['daily_rate', 'Der Tagessatz', 1_000_000],
['duration_days', 'Die Laufzeit', 100_000],
]) {
const result = normalizeNumber(req.body?.[field], label, max);
if (result.error) return res.status(400).json({ error: result.error });
numbers[field] = result.value;
}
const dates = {};
for (const field of DATE_FIELDS) {
const result = normalizeDate(req.body?.[field]);
if (result.error) return res.status(400).json({ error: result.error });
dates[field] = result.value;
}
if (dates.start_date && dates.end_date && dates.end_date < dates.start_date) {
return res.status(400).json({ error: 'Das Projektende darf nicht vor dem Start liegen.' });
}
const ownerId =
req.body?.owner_id === undefined || req.body.owner_id === null || req.body.owner_id === ''
? req.user.id
: Number(req.body.owner_id);
if (Number.isNaN(ownerId) || !(await userExists(ownerId))) {
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' });
}
const contacts = await normalizeContacts(req.body?.contacts ?? []);
if (contacts.error) return res.status(400).json({ error: contacts.error });
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [result] = await conn.query(
`INSERT INTO crm_opportunities
(title, company, description, stage, probability, daily_rate, duration_days,
start_date, end_date, last_interaction_at, last_interaction_note, next_step,
tags, notes, owner_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
title,
emptyToNull(req.body?.company),
emptyToNull(req.body?.description),
stage,
Math.round(probability.value ?? 10),
numbers.daily_rate,
numbers.duration_days,
dates.start_date,
dates.end_date,
dates.last_interaction_at,
emptyToNull(req.body?.last_interaction_note),
emptyToNull(req.body?.next_step),
serializeTags(req.body?.tags) ?? '[]',
emptyToNull(req.body?.notes),
ownerId,
req.user.id,
]
);
await replaceContacts(conn, result.insertId, contacts.contacts);
await conn.commit();
res.status(201).json(await fetchOpportunity(result.insertId));
} catch (err) {
await conn.rollback().catch(() => {});
next(err);
} finally {
conn.release();
}
});
// PATCH /api/opportunities/:id
router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' });
const conn = await pool.getConnection();
try {
const [existingRows] = await conn.query(
'SELECT * FROM crm_opportunities WHERE id = ? LIMIT 1',
[id]
);
const existing = existingRows[0];
if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error: 'Diese Opportunity darf nur der Ersteller, der Zustaendige oder ein Administrator aendern.',
});
}
const fields = [];
const values = [];
for (const field of TEXT_FIELDS) {
if (req.body?.[field] === undefined) continue;
const value = emptyToNull(req.body[field]);
if (field === 'title' && !value) {
return res.status(400).json({ error: 'Der Titel darf nicht leer sein.' });
}
fields.push(`${field} = ?`);
values.push(value);
}
if (req.body?.stage !== undefined) {
if (!STAGES.has(req.body.stage)) return res.status(400).json({ error: 'Ungueltige Phase.' });
fields.push('stage = ?');
values.push(req.body.stage);
}
if (req.body?.probability !== undefined) {
const result = normalizeNumber(req.body.probability, 'Die Wahrscheinlichkeit', 100);
if (result.error) return res.status(400).json({ error: result.error });
fields.push('probability = ?');
values.push(Math.round(result.value ?? 0));
}
for (const [field, label, max] of [
['daily_rate', 'Der Tagessatz', 1_000_000],
['duration_days', 'Die Laufzeit', 100_000],
]) {
if (req.body?.[field] === undefined) continue;
const result = normalizeNumber(req.body[field], label, max);
if (result.error) return res.status(400).json({ error: result.error });
fields.push(`${field} = ?`);
values.push(result.value);
}
const nextDates = {
start_date: existing.start_date,
end_date: existing.end_date,
};
for (const field of DATE_FIELDS) {
if (req.body?.[field] === undefined) continue;
const result = normalizeDate(req.body[field]);
if (result.error) return res.status(400).json({ error: result.error });
fields.push(`${field} = ?`);
values.push(result.value);
if (field in nextDates) nextDates[field] = result.value;
}
if (nextDates.start_date && nextDates.end_date && nextDates.end_date < nextDates.start_date) {
return res.status(400).json({ error: 'Das Projektende darf nicht vor dem Start liegen.' });
}
if (req.body?.tags !== undefined) {
fields.push('tags = ?');
values.push(serializeTags(req.body.tags) ?? '[]');
}
if (req.body?.owner_id !== undefined) {
const ownerId =
req.body.owner_id === null || req.body.owner_id === '' ? null : Number(req.body.owner_id);
if (ownerId !== null && (Number.isNaN(ownerId) || !(await userExists(ownerId)))) {
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' });
}
fields.push('owner_id = ?');
values.push(ownerId);
}
const contacts = await normalizeContacts(req.body?.contacts);
if (contacts?.error) return res.status(400).json({ error: contacts.error });
if (fields.length === 0 && !contacts) {
return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' });
}
await conn.beginTransaction();
if (fields.length > 0) {
await conn.query(`UPDATE crm_opportunities SET ${fields.join(', ')} WHERE id = ?`, [
...values,
id,
]);
}
if (contacts) await replaceContacts(conn, id, contacts.contacts);
await conn.commit();
res.json(await fetchOpportunity(id));
} catch (err) {
await conn.rollback().catch(() => {});
next(err);
} finally {
conn.release();
}
});
// DELETE /api/opportunities/:id - Ansprechpartner raeumt die DB per CASCADE
router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' });
try {
const [rows] = await pool.query(
'SELECT created_by, owner_id FROM crm_opportunities WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error: 'Diese Opportunity darf nur der Ersteller, der Zustaendige oder ein Administrator loeschen.',
});
}
await pool.query('DELETE FROM crm_opportunities WHERE id = ?', [id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;