import express from 'express'; import { pool } from '../db.js'; import { requireAuth } from '../middleware/auth.js'; import { normalizeLink } from '../link.js'; const router = express.Router(); router.use(requireAuth); // Phasen des Vermittlungsprozesses - dieselbe Reihenfolge wie im Frontend // (OPPORTUNITY_STAGES in src/types.ts) und in der Spalte crm_opportunities.stage. const STAGES = new Set([ 'Neu', 'Beworben', 'Erstgespraech Berater', 'Kundenvorstellung', 'Interview Kunde', 'Zusage', 'Beauftragt', 'Absage', ]); const TEXT_FIELDS = [ 'title', 'company', // Fuer welchen Berater das Projekt laeuft. Gepflegt wird die Auswahl unter // Einstellungen -> Akquise; geprueft wird hier nur die Laenge, damit ein aus // der Liste entfernter Name eine alte Opportunity nicht unaenderbar macht. 'consultant', 'description', 'last_interaction_note', 'next_step', 'notes', ]; /** Laengengrenze der Spalte - abgewiesen statt stillschweigend abgeschnitten. */ const CONSULTANT_MAX_LENGTH = 190; const DATE_FIELDS = ['start_date', 'end_date', 'last_interaction_at']; const NUMBER_FIELDS = ['daily_rate', 'duration_days', 'fixed_price']; 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 }; } 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, deleter.name AS deleted_by_name, mover.name AS stage_changed_by_name, (src.id IS NOT NULL AND src.deleted_at IS NULL) AS acquisition_available 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 LEFT JOIN crm_users deleter ON deleter.id = o.deleted_by LEFT JOIN crm_users mover ON mover.id = o.stage_changed_by LEFT JOIN crm_acquisitions src ON src.id = o.acquisition_id `; /** Ab hier raeumt der Papierkorb selbst auf. */ const DELETE_AFTER_DAYS = 90; /** * Endgueltig entfernen, was lange genug im Papierkorb liegt. * * 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 Faelligen ohnehin heraus, der Loeschlauf raeumt nur * hinterher. Dasselbe Muster wie in der Akquise. */ let lastPurgeAt = 0; async function purgeOldTrash() { if (Date.now() - lastPurgeAt < 3_600_000) return; lastPurgeAt = Date.now(); const [result] = await pool.query( `DELETE FROM crm_opportunities WHERE deleted_at IS NOT NULL AND deleted_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)`, [DELETE_AFTER_DAYS] ); if (result.affectedRows > 0) { console.log( `[Opportunities] ${result.affectedRows} Eintrag/Eintraege nach ${DELETE_AFTER_DAYS} Tagen aus dem Papierkorb geloescht.` ); } } /** * 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), fixed_price: row.fixed_price === null ? null : Number(row.fixed_price), probability: Number(row.probability), // Aus der Abfrage kommt 0/1 - im Frontend soll es ein Schalter sein. acquisition_available: Boolean(Number(row.acquisition_available)), 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 müssen als Liste übergeben 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: 'Ungültige 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?trash=true router.get('/', async (req, res, next) => { try { await purgeOldTrash(); // Der Papierkorb ist ausgeblendet, solange er nicht ausdruecklich gefragt ist. const trash = req.query.trash === 'true'; const [rows] = await pool.query( `${SELECT_OPPORTUNITIES} WHERE o.deleted_at IS ${trash ? 'NOT NULL' : 'NULL'} ORDER BY ${trash ? 'o.deleted_at DESC' : '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/trash/empty - Papierkorb endgueltig leeren * * Vor den Routen mit :id registriert, damit "trash" nicht als Id gelesen wird. * Erlaubt ist es jedem Angemeldeten: was hier liegt, hat schon jemand mit den * noetigen Rechten geloescht. Vor dem endgueltigen Schritt warnt die Oberflaeche. */ router.post('/trash/empty', async (req, res, next) => { try { const [result] = await pool.query('DELETE FROM crm_opportunities WHERE deleted_at IS NOT NULL'); res.json({ ok: true, purged: result.affectedRows }); } 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: 'Ungültige 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], ['fixed_price', 'Der Projektpreis', 1_000_000_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: 'Zuständiger Benutzer existiert nicht.' }); } const consultant = emptyToNull(req.body?.consultant); if (consultant !== null && consultant.length > CONSULTANT_MAX_LENGTH) { return res .status(400) .json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` }); } // Link zur Ausschreibung - dieselbe Pruefung wie in der Akquise. const link = normalizeLink(req.body?.link); if (link.error) return res.status(400).json({ error: link.error }); 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( // Wer direkt in 'Beauftragt' anlegt, hat gerade gewonnen - dann steht das // Datum sofort. `INSERT INTO crm_opportunities (title, company, consultant, link, description, stage, won_at, probability, daily_rate, duration_days, fixed_price, start_date, end_date, last_interaction_at, last_interaction_note, next_step, tags, notes, owner_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ title, emptyToNull(req.body?.company), consultant, link.value, emptyToNull(req.body?.description), stage, Math.round(probability.value ?? 10), numbers.daily_rate, numbers.duration_days, numbers.fixed_price, 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: 'Ungültige 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 (existing.deleted_at !== null) { return res .status(409) .json({ error: 'Diese Opportunity liegt im Papierkorb - erst wiederherstellen.' }); } if (!mayModify(req.user, existing)) { return res.status(403).json({ error: 'Diese Opportunity darf nur der Ersteller, der Zuständige oder ein Administrator ändern.', }); } 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.' }); } if (field === 'consultant' && value !== null && value.length > CONSULTANT_MAX_LENGTH) { return res .status(400) .json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` }); } fields.push(`${field} = ?`); values.push(value); } if (req.body?.link !== undefined) { const link = normalizeLink(req.body.link); if (link.error) return res.status(400).json({ error: link.error }); fields.push('link = ?'); values.push(link.value); } if (req.body?.stage !== undefined) { if (!STAGES.has(req.body.stage)) return res.status(400).json({ error: 'Ungültige Phase.' }); fields.push('stage = ?'); values.push(req.body.stage); // Wer hat wann verschoben - nur bei echtem Wechsel, sonst wuerde jedes // Speichern im Dialog den Zeitpunkt nach vorn schieben. if (req.body.stage !== existing.stage) { fields.push('stage_changed_at = UTC_TIMESTAMP()', 'stage_changed_by = ?'); values.push(req.user.id); } /* * Zeitpunkt des Gewinns mitschreiben. Der erste Wechsel nach 'Beauftragt' * zaehlt; ein zweites Speichern derselben Phase darf das Datum nicht nach * vorn schieben. Geht die Opportunity wieder heraus, wird es geleert - * sonst zaehlt das Dashboard einen Gewinn, den es nicht gibt. */ if (req.body.stage === 'Beauftragt' && existing.stage !== 'Beauftragt') { fields.push('won_at = UTC_TIMESTAMP()'); } else if (req.body.stage !== 'Beauftragt' && existing.stage === 'Beauftragt') { fields.push('won_at = NULL'); } } 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], ['fixed_price', 'Der Projektpreis', 1_000_000_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: 'Zuständiger 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 Änderungen übergeben.' }); } 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 - in den Papierkorb legen * * Bewusst kein echtes Loeschen: an einer Opportunity haengen Verlauf, * Ansprechpartner und Mailzuordnungen, die ein Fehlklick nicht kosten soll. * Endgueltig entfernt sie erst das Leeren des Papierkorbs oder der Ablauf der * 90 Tage - dann raeumt die Datenbank Ansprechpartner und Mailbezuege per * CASCADE mit auf. */ 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 Opportunity-ID.' }); try { const [rows] = await pool.query( 'SELECT created_by, owner_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1', [id] ); const existing = rows[0]; if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' }); if (existing.deleted_at !== null) { return res.status(409).json({ error: 'Diese Opportunity liegt schon im Papierkorb.' }); } if (!mayModify(req.user, existing)) { return res.status(403).json({ error: 'Diese Opportunity darf nur der Ersteller, der Zuständige oder ein Administrator löschen.', }); } await pool.query( 'UPDATE crm_opportunities SET deleted_at = UTC_TIMESTAMP(), deleted_by = ? WHERE id = ?', [req.user.id, id] ); res.json({ ok: true }); } catch (err) { next(err); } }); // POST /api/opportunities/:id/restore - aus dem Papierkorb zurueckholen router.post('/:id/restore', async (req, res, next) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Opportunity-ID.' }); try { const [rows] = await pool.query( 'SELECT created_by, owner_id, acquisition_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1', [id] ); const existing = rows[0]; if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' }); if (existing.deleted_at === null) { return res.status(409).json({ error: 'Diese Opportunity liegt nicht im Papierkorb.' }); } if (!mayModify(req.user, existing)) { return res.status(403).json({ error: 'Diese Opportunity darf nur der Ersteller, der Zuständige oder ein Administrator wiederherstellen.', }); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); await conn.query( 'UPDATE crm_opportunities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id] ); /* * Kommt die Opportunity aus der Akquise, haengt der Eintrag wieder an ihr. * Sonst stuende dasselbe Projekt nach einem "Zurueck in die Akquise" und * dem Wiederherstellen doppelt da: als Opportunity und im Eingang. * * Nur wenn der Eintrag frei ist - nie uebernommen oder seine Opportunity * weg. Haengt er an einer anderen, lebenden Opportunity, bleibt er dort. * `converted_at` bleibt, wenn es schon gesetzt ist, sonst gilt jetzt. */ if (existing.acquisition_id !== null) { await conn.query( `UPDATE crm_acquisitions a LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id SET a.opportunity_id = ?, a.converted_at = COALESCE(a.converted_at, UTC_TIMESTAMP()) WHERE a.id = ? AND a.deleted_at IS NULL AND (a.converted_at IS NULL OR opp.id IS NULL OR opp.id = ? OR opp.deleted_at IS NOT NULL)`, [id, existing.acquisition_id, id] ); } await conn.commit(); } catch (err) { await conn.rollback().catch(() => {}); throw err; } finally { conn.release(); } res.json(await fetchOpportunity(id)); } catch (err) { next(err); } }); /* * POST /api/opportunities/:id/acquisition - zurueck in die Akquise * * Nimmt eine Uebernahme zurueck, die aus Versehen passiert ist. Der Eintrag der * Akquise wird wieder frei (`converted_at` und `opportunity_id` leer) und steht * dort, wo sein Funddatum ihn hinstellt: jung im Eingang, sonst im Archiv. Das * Datum bleibt, wie es war - es ist der Tag der Bewerbung und zaehlt so in der * Auswertung. * * Die Opportunities wandern in den Papierkorb, und zwar alle aus demselben * Eintrag: bei drei Beratern sind bei der Uebernahme drei entstanden, und wer * sie zuruecknimmt, meint alle. Papierkorb statt echtem Loeschen, weil an einer * Opportunity inzwischen Ansprechpartner oder Mails haengen koennen. Wird eine * davon wiederhergestellt, haengt der Eintrag wieder an ihr (siehe /restore). * * Alles in einer Transaktion - ein Eintrag im Eingang neben einer noch lebenden * Opportunity waere genau die Dublette, die hier vermieden werden soll. */ router.post('/:id/acquisition', async (req, res, next) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Opportunity-ID.' }); const conn = await pool.getConnection(); try { const [rows] = await conn.query( 'SELECT id, created_by, owner_id, acquisition_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1', [id] ); const existing = rows[0]; if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' }); if (existing.deleted_at !== null) { return res.status(409).json({ error: 'Diese Opportunity liegt im Papierkorb.' }); } if (existing.acquisition_id === null) { return res .status(409) .json({ error: 'Diese Opportunity wurde nicht aus der Akquise übernommen.' }); } const [entries] = await conn.query( 'SELECT id, title, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1', [existing.acquisition_id] ); const entry = entries[0]; if (!entry) { return res .status(409) .json({ error: 'Der Eintrag in der Akquise existiert nicht mehr.' }); } if (entry.deleted_at !== null) { return res.status(409).json({ error: 'Der Eintrag liegt im Papierkorb der Akquise. Dort erst wiederherstellen.', }); } const [siblings] = await conn.query( `SELECT id, created_by, owner_id FROM crm_opportunities WHERE acquisition_id = ? AND deleted_at IS NULL`, [entry.id] ); // Wer eine davon nicht loeschen darf, darf die Uebernahme nicht zuruecknehmen. if (!siblings.every((row) => mayModify(req.user, row))) { return res.status(403).json({ error: 'Zurücknehmen darf nur, wer alle Opportunities aus diesem Eintrag löschen darf (Ersteller, Zuständiger oder Administrator).', }); } await conn.beginTransaction(); await conn.query( `UPDATE crm_opportunities SET deleted_at = UTC_TIMESTAMP(), deleted_by = ? WHERE acquisition_id = ? AND deleted_at IS NULL`, [req.user.id, entry.id] ); await conn.query( 'UPDATE crm_acquisitions SET converted_at = NULL, opportunity_id = NULL WHERE id = ?', [entry.id] ); await conn.commit(); res.json({ ok: true, acquisition_id: entry.id, trashed: siblings.map((row) => row.id) }); } catch (err) { await conn.rollback().catch(() => {}); next(err); } finally { conn.release(); } }); /* * POST /api/opportunities/:id/copy - fuer andere Berater kopieren * * Eine Opportunity laeuft fuer genau einen Berater. Passt dasselbe Projekt auch * fuer einen Kollegen, entsteht je Berater eine Kopie mit eigener Phase - wie * bei der Uebernahme aus der Akquise, nur nachtraeglich. * * Kopiert wird, was das Projekt beschreibt: Titel, Firma, Link, Beschreibung, Zahlen, * Zeitraum, Tags, Notizen, Ansprechpartner, Zustaendiger und die Herkunft aus * der Akquise (damit "Zurueck in die Akquise" auch die Kopien findet). Nicht * kopiert wird, was zum Verlauf des Originals gehoert: letzte Interaktion, * naechster Schritt, Gewinndatum. Die Phase waehlt der Aufrufer, sonst "Neu". * * Body: { consultants: string[], stage?: string } */ router.post('/:id/copy', async (req, res, next) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Opportunity-ID.' }); const raw = Array.isArray(req.body?.consultants) ? req.body.consultants : []; // Getrimmt und ohne Dubletten - beim Vergleich ohne Gross-/Kleinschreibung. const seen = new Set(); const consultants = []; for (const entry of raw) { const name = emptyToNull(entry); if (name === null || seen.has(name.toLowerCase())) continue; if (name.length > CONSULTANT_MAX_LENGTH) { return res .status(400) .json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` }); } seen.add(name.toLowerCase()); consultants.push(name); } if (consultants.length === 0) { return res.status(400).json({ error: 'Mindestens ein Berater ist erforderlich.' }); } if (consultants.length > 20) { return res.status(400).json({ error: 'Höchstens 20 Berater auf einmal.' }); } const stage = emptyToNull(req.body?.stage) ?? 'Neu'; if (!STAGES.has(stage)) return res.status(400).json({ error: 'Ungültige Phase.' }); const conn = await pool.getConnection(); try { const [rows] = await conn.query('SELECT * FROM crm_opportunities WHERE id = ? LIMIT 1', [id]); const source = rows[0]; if (!source) return res.status(404).json({ error: 'Opportunity nicht gefunden.' }); if (source.deleted_at !== null) { return res.status(409).json({ error: 'Diese Opportunity liegt im Papierkorb.' }); } const [contactRows] = await conn.query( 'SELECT lead_id, role FROM crm_opportunity_contacts WHERE opportunity_id = ?', [id] ); await conn.beginTransaction(); const createdIds = []; for (const consultant of consultants) { const [created] = await conn.query( `INSERT INTO crm_opportunities (title, company, consultant, acquisition_id, link, description, stage, won_at, probability, daily_rate, duration_days, fixed_price, start_date, end_date, tags, notes, owner_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ source.title, source.company, consultant, source.acquisition_id, source.link, source.description, stage, source.probability, source.daily_rate, source.duration_days, source.fixed_price, source.start_date, source.end_date, source.tags ?? '[]', source.notes, source.owner_id, req.user.id, ] ); await replaceContacts(conn, created.insertId, contactRows); createdIds.push(created.insertId); } await conn.commit(); res.status(201).json({ ok: true, opportunity_ids: createdIds }); } catch (err) { await conn.rollback().catch(() => {}); next(err); } finally { conn.release(); } }); export default router;