feat(akquise): E-Mail-Adresse je Berater in den Einstellungen

Neue Spalte crm_acquisition_options.email, pflegbar unter
Einstellungen -> Akquise direkt in der Berater-Zeile. Beim ersten Start
werden vorhandene Berater einmalig mit vorname.nachname@perinco.de
vorbelegt (Merker consultant_email_seeded); Namen ohne Nachnamen bleiben leer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-10-21 09:05:59 +02:00
co-authored by Claude Opus 5.5
parent b9d6544b30
commit 907b010f3c
9 changed files with 223 additions and 19 deletions
+66
View File
@@ -55,6 +55,7 @@ export async function initDatabase() {
await migrateOpportunityConsultant();
await seedAcquisitionOptions();
await dropProviderOptions();
await addConsultantEmail();
await migrateIconFlag();
await ensureAdminUser();
}
@@ -274,6 +275,71 @@ async function dropProviderOptions() {
);
}
/*
* E-Mail-Adresse je Berater in der Auswahlliste.
*
* Die Spalte gilt fuer alle Listen, gepflegt wird sie nur bei "Berater" - eine
* eigene Tabelle fuer eine Spalte waere mehr Aufwand als Nutzen.
*
* Beim ersten Anlegen werden die vorhandenen Berater einmalig mit
* vorname.nachname@perinco.de vorbelegt (Umlaute ausgeschrieben). Ein Merker in
* crm_settings haelt das fest - eine bewusst geleerte Adresse soll nach einem
* Neustart nicht wiederkommen. Namen ohne Nachnamen bleiben leer, da laesst
* sich keine Adresse raten.
*/
async function addConsultantEmail() {
const [columns] = await pool.query(
`SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_acquisition_options'
AND COLUMN_NAME = 'email' LIMIT 1`
);
if (!columns[0]) {
await pool.query(
'ALTER TABLE crm_acquisition_options ADD COLUMN email VARCHAR(190) NULL AFTER value'
);
}
const flag = 'consultant_email_seeded';
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]);
if (rows[0]?.value === 'true') return;
const [consultants] = await pool.query(
"SELECT id, value FROM crm_acquisition_options WHERE field = 'consultant' AND email IS NULL"
);
let filled = 0;
for (const row of consultants) {
const email = guessPerincoEmail(row.value);
if (!email) continue;
await pool.query('UPDATE crm_acquisition_options SET email = ? WHERE id = ?', [email, row.id]);
filled += 1;
}
await pool.query(
`INSERT INTO crm_settings (key_name, value) VALUES (?, 'true')
ON DUPLICATE KEY UPDATE value = 'true'`,
[flag]
);
console.log(`[Akquise] Berater-Adressen vorbelegt - ${filled} von ${consultants.length} Berater(n).`);
}
/** "Jörg Müller-Lüdenscheidt" -> "joerg.mueller-luedenscheidt@perinco.de"; ohne Nachname null. */
function guessPerincoEmail(name) {
const parts = String(name)
.trim()
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.split(/\s+/)
.map((part) => part.replace(/[^a-z0-9-]/g, ''))
.filter(Boolean);
if (parts.length < 2) return null;
return `${parts[0]}.${parts[parts.length - 1]}@perinco.de`;
}
/*
* Das Feld "Berater" nimmt mehrere Namen auf, kommagetrennt (siehe
* acquisitionRules.js). VARCHAR(190) reichte fuer einen Namen; drei bis vier
+52 -11
View File
@@ -295,13 +295,29 @@ async function purgeOldEntries() {
/** Alle Auswahllisten als { sender: [...], channel: [...], consultant: [...] }. */
async function loadOptions() {
const [rows] = await pool.query(
'SELECT id, field, value FROM crm_acquisition_options ORDER BY field, value'
'SELECT id, field, value, email FROM crm_acquisition_options ORDER BY field, value'
);
const options = { sender: [], channel: [], consultant: [] };
for (const row of rows) options[row.field].push({ id: row.id, value: row.value });
for (const row of rows) {
options[row.field].push({ id: row.id, value: row.value, email: row.email ?? null });
}
return options;
}
/**
* E-Mail-Adresse eines Beraters pruefen. Leer heisst "keine Adresse" (null);
* geprueft wird nur grob die Form - ob das Postfach existiert, weiss hier keiner.
*/
function normalizeOptionEmail(raw) {
const email = emptyToNull(raw);
if (email === null) return { value: null };
if (email.length > 190) return { error: 'Die E-Mail-Adresse ist zu lang (max. 190 Zeichen).' };
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { error: 'Das ist keine gültige E-Mail-Adresse.' };
}
return { value: email.toLowerCase() };
}
/**
* Bisher benutzte Anbieter, der zuletzt verwendete zuerst. Das Feld ist Freitext -
* die Vorschlaege ersparen das Ausschreiben, ohne einen neuen Vermittler zu
@@ -452,13 +468,16 @@ router.post('/options', async (req, res, next) => {
if (value.length > limit) {
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
}
// Eine Adresse gibt es nur bei Beratern.
const email = field === 'consultant' ? normalizeOptionEmail(req.body?.email) : { value: null };
if (email.error) return res.status(400).json({ error: email.error });
try {
const [result] = await pool.query(
'INSERT INTO crm_acquisition_options (field, value, created_by) VALUES (?, ?, ?)',
[field, value, req.user.id]
'INSERT INTO crm_acquisition_options (field, value, email, created_by) VALUES (?, ?, ?, ?)',
[field, value, email.value, req.user.id]
);
res.status(201).json({ id: result.insertId, field, value });
res.status(201).json({ id: result.insertId, field, value, email: email.value });
} catch (err) {
if (err?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' });
@@ -478,26 +497,48 @@ router.patch('/options/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' });
const value = emptyToNull(req.body?.value);
if (!value) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
/*
* `value` und `email` sind beide optional: die Einstellungen aendern mal den
* Namen, mal nur die Adresse. Fehlt `value`, bleibt der Name, wie er ist.
*/
const emailGiven = req.body?.email !== undefined;
const email = emailGiven ? normalizeOptionEmail(req.body.email) : null;
if (email?.error) return res.status(400).json({ error: email.error });
const valueGiven = req.body?.value !== undefined;
const requested = valueGiven ? emptyToNull(req.body.value) : null;
if (valueGiven && !requested) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
if (!valueGiven && !emailGiven) return res.status(400).json({ error: 'Nichts zu ändern.' });
const conn = await pool.getConnection();
try {
const [rows] = await conn.query(
'SELECT field, value FROM crm_acquisition_options WHERE id = ? LIMIT 1',
'SELECT field, value, email FROM crm_acquisition_options WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
if (emailGiven && existing.field !== 'consultant') {
return res.status(400).json({ error: 'Eine E-Mail-Adresse gibt es nur bei Beratern.' });
}
const value = requested ?? existing.value;
const newEmail = emailGiven ? email.value : existing.email ?? null;
const limit = existing.field === 'channel' ? 60 : 190;
if (value.length > limit) {
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
}
if (value === existing.value) return res.json({ id, field: existing.field, value, updated: 0 });
if (value === existing.value) {
await conn.query('UPDATE crm_acquisition_options SET email = ? WHERE id = ?', [newEmail, id]);
return res.json({ id, field: existing.field, value, email: newEmail, updated: 0 });
}
await conn.beginTransaction();
await conn.query('UPDATE crm_acquisition_options SET value = ? WHERE id = ?', [value, id]);
await conn.query('UPDATE crm_acquisition_options SET value = ?, email = ? WHERE id = ?', [
value,
newEmail,
id,
]);
let touched;
if (existing.field === 'consultant') {
@@ -528,7 +569,7 @@ router.patch('/options/:id', async (req, res, next) => {
}
await conn.commit();
res.json({ id, field: existing.field, value, updated: touched });
res.json({ id, field: existing.field, value, email: newEmail, updated: touched });
} catch (err) {
await conn.rollback().catch(() => {});
if (err?.code === 'ER_DUP_ENTRY') {
+1
View File
@@ -575,6 +575,7 @@ CREATE TABLE IF NOT EXISTS crm_acquisition_options (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
field ENUM('sender','channel','consultant') NOT NULL,
value VARCHAR(190) NOT NULL,
email VARCHAR(190) NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),