import express from 'express'; import { pool } from '../db.js'; import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); /* * Akquise: gefundene Projekte, aus denen eine Opportunity werden kann. * * Drei Zustaende, alle aus `found_at` und `converted_at` abgeleitet - es gibt * keine Spalte, die ein Job pflegen muesste: * * Eingang ("Akquise") found_at >= heute - 14 Tage, nicht uebernommen * Archiv aelter als 14 Tage, nicht uebernommen * weg aelter als 90 Tage (wird geloescht) oder uebernommen * * Uebernommene Eintraege bleiben in der Tabelle stehen: sie sind der Nachweis, * woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht. */ /* * Ist die Opportunity zu einem Eintrag verschwunden - im Papierkorb oder * endgueltig geloescht -, faellt die Bewerbung ins Archiv zurueck. Sonst wuerde * sie einfach nicht mehr existieren: aus der Akquise ist sie mit der Uebernahme * heraus, und die Opportunity gibt es auch nicht mehr. * * Abgeleitet statt gespeichert: `converted_at` bleibt stehen. Wird die * Opportunity aus dem Papierkorb zurueckgeholt, verschwindet der Eintrag von * selbst wieder aus dem Archiv - es gibt keinen Zustand, der nachgezogen werden * muesste. * * `opp.id IS NULL` deckt zwei Faelle ab: nie uebernommen (opportunity_id ist * NULL) und endgueltig geloescht (der Fremdschluessel wurde dabei auf NULL * gesetzt). Zusammen mit converted_at ergibt das den Unterschied. */ const OPPORTUNITY_GONE = '(opp.id IS NULL OR opp.deleted_at IS NOT NULL)'; /** Bewerbung, die (wieder) in Eingang oder Archiv gehoert. */ const IS_OPEN = `(a.converted_at IS NULL OR ${OPPORTUNITY_GONE})`; /** Grenze zwischen Eingang und Archiv, in Tagen. */ const ARCHIVE_AFTER_DAYS = 14; /** Ab hier loescht das Archiv selbst. */ const DELETE_AFTER_DAYS = 90; /* * Felder, deren Inhalte als Auswahlliste in den Einstellungen gepflegt werden. * Die Werte selbst stehen in crm_acquisition_options, nicht hier - deshalb * prueft das Backend beim Speichern eines Eintrags nur die Laenge und nicht die * Zugehoerigkeit zur Liste: wer einen Wert aus der Liste entfernt, soll damit * nicht jeden alten Eintrag unaenderbar machen. * * "provider" (Anbieter) fehlt hier absichtlich: das Feld ist Freitext mit * Vorschlaegen aus den bisherigen Eintraegen. */ const OPTION_FIELDS = ['sender', 'channel', 'consultant']; // `link` fehlt hier absichtlich: der Wert wird eigens geprueft (normalizeLink). const TEXT_FIELDS = [ 'sender', 'provider', 'title', 'consultant', 'company', 'description', 'next_step', 'notes', ]; const DATE_FIELDS = ['found_at', 'start_date', 'end_date']; const NUMBER_FIELDS = [ ['daily_rate', 'Der Tagessatz', 1_000_000], ['duration_days', 'Die Laufzeit', 100_000], ['fixed_price', 'Der Projektpreis', 1_000_000_000], ]; /** Laengengrenzen der Spalten - abgewiesen statt stillschweigend abgeschnitten. */ const MAX_LENGTH = { sender: 190, provider: 190, title: 190, consultant: 190, link: 1024, company: 190, next_step: 500, }; 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: 'Ungültiges 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 }; } /** * Nur http und https. Ohne diese Pruefung liesse sich ein `javascript:`-Verweis * hinterlegen, den der Titel in der Liste anklickbar macht. */ function normalizeLink(value) { const text = emptyToNull(value); if (text === null) return { value: null }; // Ohne Schema ist "hays.de/projekt/123" gemeint - das ergaenzen wir. const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text) ? text : `https://${text}`; let url; try { url = new URL(candidate); } catch { return { error: 'Der Link ist keine gültige Adresse.' }; } if (url.protocol !== 'http:' && url.protocol !== 'https:') { return { error: 'Der Link muss mit http:// oder https:// beginnen.' }; } if (candidate.length > MAX_LENGTH.link) return { error: 'Der Link ist zu lang (max. 1024 Zeichen).' }; return { value: candidate }; } 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))]); } /** DECIMAL kommt als String aus mysql2 - im Frontend sollen es Zahlen sein. */ function mapAcquisition(row) { return { ...row, // Aus der Abfrage kommt 0/1 - im Frontend soll es ein Schalter sein. opportunity_gone: Boolean(Number(row.opportunity_gone)), daily_rate: row.daily_rate === null ? null : Number(row.daily_rate), duration_days: row.duration_days === null ? null : Number(row.duration_days), fixed_price: row.fixed_price === null ? null : Number(row.fixed_price), tags: parseTags(row.tags), }; } const SELECT_ACQUISITIONS = ` SELECT a.*, creator.name AS created_by_name, opp.title AS opportunity_title, opp.stage AS opportunity_stage, opp.deleted_at AS opportunity_deleted_at, ${OPPORTUNITY_GONE} AS opportunity_gone FROM crm_acquisitions a LEFT JOIN crm_users creator ON creator.id = a.created_by LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id `; async function fetchAcquisition(id) { const [rows] = await pool.query(`${SELECT_ACQUISITIONS} WHERE a.id = ? LIMIT 1`, [id]); return rows[0] ? mapAcquisition(rows[0]) : null; } /** * Alles aelter als 90 Tage entfernen. Uebernommene Eintraege bleiben - sie * gehoeren zur Opportunity, nicht zum Archiv. * * Das laeuft beim Abruf der Liste, hoechstens aber einmal pro Stunde je Prozess. * Ein eigener Zeitplan waere eine weitere Stelle, die ausfallen kann; und die * Abfragen filtern die Altlasten ohnehin heraus, der Loeschlauf raeumt nur * hinterher. */ let lastPurgeAt = 0; async function purgeOldEntries() { if (Date.now() - lastPurgeAt < 3_600_000) return; lastPurgeAt = Date.now(); /* * Vor dem Loeschen die Zahlen sichern: jeder Eintrag ist eine versendete * Bewerbung, und das Dashboard rechnet ueber Jahre. Gesichert wird nur Tag, * Berater und Anzahl - die Projektdaten sind danach wirklich weg. * * Sichern und Loeschen gehoeren in eine Transaktion: die Anzahl wird * aufaddiert (ein Tag kann aus einem frueheren Lauf schon in der Historie * stehen), und ein Abbruch zwischen beiden Schritten wuerde denselben Tag beim * naechsten Lauf ein zweites Mal dazurechnen. */ const conn = await pool.getConnection(); try { await conn.beginTransaction(); await conn.query( `INSERT INTO crm_acquisition_history (found_on, consultant, applications) SELECT a.found_at, COALESCE(a.consultant, ''), COUNT(*) FROM crm_acquisitions a LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id WHERE ${IS_OPEN} AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY) GROUP BY a.found_at, COALESCE(a.consultant, '') ON DUPLICATE KEY UPDATE applications = crm_acquisition_history.applications + VALUES(applications)`, [DELETE_AFTER_DAYS] ); /* * Geloescht wird ueber dieselbe Regel: was in Eingang oder Archiv steht, * altert nach 90 Tagen heraus - auch ein Rueckfaeller. Eintraege mit * lebender Opportunity bleiben unberuehrt, sie gehoeren zu ihr. */ const [result] = await conn.query( `DELETE a FROM crm_acquisitions a LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id WHERE ${IS_OPEN} AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`, [DELETE_AFTER_DAYS] ); await conn.commit(); if (result.affectedRows > 0) { console.log( `[Akquise] ${result.affectedRows} Eintrag/Eintraege aelter als ${DELETE_AFTER_DAYS} Tage geloescht (Anzahl im Verlauf gesichert).` ); } } catch (err) { await conn.rollback().catch(() => {}); // Nicht weiterwerfen: das Aufraeumen darf die Liste nicht blockieren. console.warn(`[Akquise] Aufraeumen fehlgeschlagen: ${err.message}`); } finally { conn.release(); } } /** 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' ); const options = { sender: [], channel: [], consultant: [] }; for (const row of rows) options[row.field].push({ id: row.id, value: row.value }); return options; } /** * Bisher benutzte Anbieter, der zuletzt verwendete zuerst. Das Feld ist Freitext - * die Vorschlaege ersparen das Ausschreiben, ohne einen neuen Vermittler zu * verbieten. Bewusst aus dem gesamten Bestand, nicht nur aus der gezeigten Liste. */ async function loadProviderSuggestions() { const [rows] = await pool.query( `SELECT provider FROM crm_acquisitions WHERE provider IS NOT NULL AND provider <> '' GROUP BY provider ORDER BY MAX(found_at) DESC, provider ASC LIMIT 100` ); return rows.map((row) => row.provider); } /** * Ist die Opportunity zu einem Eintrag verschwunden? Dieselbe Frage wie * OPPORTUNITY_GONE, nur fuer eine einzelne Zeile - beim Bearbeiten und beim * erneuten Uebernehmen. */ async function opportunityGone(opportunityId) { if (opportunityId === null || opportunityId === undefined) return true; const [rows] = await pool.query( 'SELECT deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1', [opportunityId] ); return !rows[0] || rows[0].deleted_at !== null; } /** Aendern und loeschen darf der Ersteller und jeder Admin. */ function mayModify(user, row) { return user.role === 'admin' || row.created_by === user.id; } /** * Felder aus dem Anfragekoerper pruefen. `partial` laesst fehlende Felder weg * (PATCH), sonst werden alle gesetzt (POST). */ function readFields(body, { partial }) { const columns = {}; for (const field of TEXT_FIELDS) { if (partial && body?.[field] === undefined) continue; const value = emptyToNull(body?.[field]); if (MAX_LENGTH[field] && value !== null && value.length > MAX_LENGTH[field]) { return { error: `Der Wert für "${field}" ist zu lang (max. ${MAX_LENGTH[field]} Zeichen).` }; } columns[field] = value; } if (!partial || body?.link !== undefined) { const link = normalizeLink(body?.link); if (link.error) return { error: link.error }; columns.link = link.value; } if (!partial || body?.channel !== undefined) { const channel = emptyToNull(body?.channel); if (channel !== null && channel.length > 60) { return { error: 'Der Kanal ist zu lang (max. 60 Zeichen).' }; } columns.channel = channel; } for (const field of DATE_FIELDS) { if (partial && body?.[field] === undefined) continue; const result = normalizeDate(body?.[field]); if (result.error) return { error: result.error }; columns[field] = result.value; } for (const [field, label, max] of NUMBER_FIELDS) { if (partial && body?.[field] === undefined) continue; const result = normalizeNumber(body?.[field], label, max); if (result.error) return { error: result.error }; columns[field] = result.value; } if (!partial || body?.tags !== undefined) { columns.tags = serializeTags(body?.tags) ?? '[]'; } if (columns.title !== undefined && !columns.title) { return { error: 'Ein Projekttitel ist erforderlich.' }; } if (columns.start_date && columns.end_date && columns.end_date < columns.start_date) { return { error: 'Das Projektende darf nicht vor dem Start liegen.' }; } return { columns }; } // --------------------------------------------------------------------------- // Auswahllisten (Einstellungen -> Akquise) // // Pflegen darf jeder Angemeldete. Wer ein Projekt eintraegt und dabei auf einen // neuen Anbieter stoesst, muss weiterarbeiten koennen - eine Liste, die nur ein // Administrator ergaenzen kann, blockiert genau dann die Eingabe. // --------------------------------------------------------------------------- // GET /api/acquisitions/options router.get('/options', async (req, res, next) => { try { res.json(await loadOptions()); } catch (err) { next(err); } }); // POST /api/acquisitions/options router.post('/options', async (req, res, next) => { const field = String(req.body?.field ?? ''); if (!OPTION_FIELDS.includes(field)) return res.status(400).json({ error: 'Unbekanntes Feld.' }); const value = emptyToNull(req.body?.value); if (!value) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' }); const limit = field === 'channel' ? 60 : 190; if (value.length > limit) { return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` }); } try { const [result] = await pool.query( 'INSERT INTO crm_acquisition_options (field, value, created_by) VALUES (?, ?, ?)', [field, value, req.user.id] ); res.status(201).json({ id: result.insertId, field, value }); } catch (err) { if (err?.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' }); } next(err); } }); /* * PATCH /api/acquisitions/options/:id * * Umbenennen zieht die vorhandenen Eintraege mit: wird "Hays" zu "Hays AG", soll * die Liste nicht auf einmal zwei Anbieter zeigen, von denen einer in keinem * Dropdown mehr steht. */ 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.' }); const conn = await pool.getConnection(); try { const [rows] = await conn.query( 'SELECT field, value 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.' }); 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 }); await conn.beginTransaction(); await conn.query('UPDATE crm_acquisition_options SET value = ? WHERE id = ?', [value, id]); const [updated] = await conn.query( `UPDATE crm_acquisitions SET ${existing.field} = ? WHERE ${existing.field} = ?`, [value, existing.value] ); await conn.commit(); res.json({ id, field: existing.field, value, updated: updated.affectedRows }); } catch (err) { await conn.rollback().catch(() => {}); if (err?.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' }); } next(err); } finally { conn.release(); } }); /* * DELETE /api/acquisitions/options/:id * * Nur die Auswahl verschwindet. Die Eintraege behalten ihren Wert - sonst wuerde * das Aufraeumen einer Liste stillschweigend Daten aendern. Wie viele Eintraege * betroffen sind, steht in der Antwort, damit die Oberflaeche es sagen kann. */ router.delete('/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.' }); try { const [rows] = await pool.query( 'SELECT field, value 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.' }); const [[used]] = await pool.query( `SELECT COUNT(*) AS count FROM crm_acquisitions WHERE converted_at IS NULL AND ${existing.field} = ?`, [existing.value] ); await pool.query('DELETE FROM crm_acquisition_options WHERE id = ?', [id]); res.json({ ok: true, still_in_use: Number(used.count) }); } catch (err) { next(err); } }); // GET /api/acquisitions?view=eingang|archiv|alle router.get('/', async (req, res, next) => { try { await purgeOldEntries(); const view = String(req.query.view ?? 'eingang'); const where = [IS_OPEN]; const params = []; /* * Der Eingang zeigt nur, was noch nie uebernommen wurde. Eine Bewerbung, * deren Opportunity geloescht wurde, gehoert ins Archiv - unabhaengig davon, * wie alt sie ist: sie ist abgearbeitet, aber nicht verschwunden. */ if (view === 'eingang') { where.push('a.converted_at IS NULL'); where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)'); params.push(ARCHIVE_AFTER_DAYS); } else if (view === 'archiv') { // Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur // stuendlich, angezeigt werden soll nichts, was schon faellig ist. where.push( `(a.converted_at IS NOT NULL OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))` ); where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)'); params.push(ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS); } else if (view !== 'alle') { return res.status(400).json({ error: 'Unbekannte Ansicht.' }); } const [rows] = await pool.query( `${SELECT_ACQUISITIONS} WHERE ${where.join(' AND ')} ORDER BY a.found_at DESC, a.id DESC`, params ); // Beide Zaehler in einem Rutsch: die Oberflaeche zeigt sie am Menue und // muesste sonst zwei Listen laden, um eine Zahl zu kennen. const [[counts]] = await pool.query( `SELECT SUM(a.converted_at IS NULL AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS eingang, SUM((a.converted_at IS NOT NULL OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)) AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS archiv FROM crm_acquisitions a LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id WHERE ${IS_OPEN}`, [ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS] ); // Auswahllisten und Anbieter-Vorschlaege kommen mit: die Eingabe braucht sie // sofort, und ein zweiter Aufruf nur fuer ein paar kurze Listen waere // verschenkt. res.json({ archive_after_days: ARCHIVE_AFTER_DAYS, delete_after_days: DELETE_AFTER_DAYS, options: await loadOptions(), provider_suggestions: await loadProviderSuggestions(), counts: { eingang: Number(counts.eingang ?? 0), archiv: Number(counts.archiv ?? 0), }, entries: rows.map(mapAcquisition), }); } catch (err) { next(err); } }); // POST /api/acquisitions router.post('/', async (req, res, next) => { const parsed = readFields(req.body, { partial: false }); if (parsed.error) return res.status(400).json({ error: parsed.error }); const columns = parsed.columns; // Ohne Angabe gilt heute: gefunden wurde es dann, wenn es eingetragen wird. if (columns.found_at === null) columns.found_at = new Date().toISOString().slice(0, 10); columns.created_by = req.user.id; try { const keys = Object.keys(columns); const [result] = await pool.query( `INSERT INTO crm_acquisitions (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, keys.map((key) => columns[key]) ); res.status(201).json(await fetchAcquisition(result.insertId)); } catch (err) { next(err); } }); // PATCH /api/acquisitions/:id 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 Akquise-ID.' }); try { const [rows] = await pool.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]); const existing = rows[0]; if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); if (!mayModify(req.user, existing)) { return res.status(403).json({ error: 'Diesen Eintrag darf nur ändern, wer ihn angelegt hat, oder ein Administrator.', }); } /* * Gesperrt ist der Eintrag nur, solange seine Opportunity lebt - dort wird * dann gepflegt. Ist sie im Papierkorb oder geloescht, ist der Eintrag * wieder das Original und darf geaendert werden. */ if (existing.converted_at !== null && !(await opportunityGone(existing.opportunity_id))) { return res .status(409) .json({ error: 'Der Eintrag wurde schon in eine Opportunity übernommen.' }); } const parsed = readFields(req.body, { partial: true }); if (parsed.error) return res.status(400).json({ error: parsed.error }); const columns = parsed.columns; if ('title' in columns && !columns.title) { return res.status(400).json({ error: 'Der Projekttitel darf nicht leer sein.' }); } // Datumsgrenzen gegen den gespeicherten Stand pruefen, nicht nur gegen das, // was gerade mitgeschickt wurde. const start = 'start_date' in columns ? columns.start_date : existing.start_date; const end = 'end_date' in columns ? columns.end_date : existing.end_date; if (start && end && end < start) { return res.status(400).json({ error: 'Das Projektende darf nicht vor dem Start liegen.' }); } const keys = Object.keys(columns); if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); await pool.query( `UPDATE crm_acquisitions SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`, [...keys.map((key) => columns[key]), id] ); res.json(await fetchAcquisition(id)); } catch (err) { next(err); } }); // DELETE /api/acquisitions/:id 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 Akquise-ID.' }); try { const [rows] = await pool.query( 'SELECT created_by FROM crm_acquisitions WHERE id = ? LIMIT 1', [id] ); const existing = rows[0]; if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); if (!mayModify(req.user, existing)) { return res.status(403).json({ error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.', }); } await pool.query('DELETE FROM crm_acquisitions WHERE id = ?', [id]); res.json({ ok: true }); } catch (err) { next(err); } }); /* * POST /api/acquisitions/:id/opportunity * * Aus dem Eintrag wird eine Opportunity in der Phase "Neu". Beides in einer * Transaktion: entweder die Opportunity steht und der Eintrag ist als * uebernommen vermerkt, oder es bleibt alles wie es war. Sonst koennte eine * Opportunity entstehen, die der Eingang weiterhin anbietet. */ router.post('/:id/opportunity', async (req, res, next) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' }); const conn = await pool.getConnection(); try { const [rows] = await conn.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]); const entry = rows[0]; if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); // Noch einmal uebernehmen darf man, wenn die erste Opportunity weg ist. if (entry.converted_at !== null && !(await opportunityGone(entry.opportunity_id))) { return res.status(409).json({ error: 'Aus diesem Eintrag wurde schon eine Opportunity.', opportunity_id: entry.opportunity_id, }); } /* * Was in der Liste steht, aber in der Opportunity kein Feld hat (Anbieter, * Kanal, Absender, Link, Berater), wandert in die Notizen. Sonst wuesste * hinterher niemand mehr, woher das Projekt kam. */ const origin = [ ['Anbieter', entry.provider], ['Von', entry.sender], ['Kanal', entry.channel], ['Berater', entry.consultant], ['Link', entry.link], ] .filter(([, value]) => value) .map(([label, value]) => `${label}: ${value}`); const notes = [ entry.notes, origin.length > 0 ? `Aus der Akquise vom ${entry.found_at}\n${origin.join('\n')}` : null, ] .filter(Boolean) .join('\n\n'); await conn.beginTransaction(); const [created] = await conn.query( `INSERT INTO crm_opportunities (title, company, description, stage, probability, daily_rate, duration_days, fixed_price, start_date, end_date, next_step, tags, notes, owner_id, created_by) VALUES (?, ?, ?, 'Neu', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ entry.title, entry.company, entry.description, entry.daily_rate, entry.duration_days, entry.fixed_price, entry.start_date, entry.end_date, entry.next_step, entry.tags ?? '[]', notes || null, req.user.id, req.user.id, ] ); await conn.query( 'UPDATE crm_acquisitions SET opportunity_id = ?, converted_at = UTC_TIMESTAMP() WHERE id = ?', [created.insertId, id] ); await conn.commit(); res.status(201).json({ ok: true, opportunity_id: created.insertId }); } catch (err) { await conn.rollback().catch(() => {}); next(err); } finally { conn.release(); } }); export default router;