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', '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 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), fixed_price: row.fixed_price === null ? null : Number(row.fixed_price), 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 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 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: '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 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, fixed_price, 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, 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 (!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.' }); } fields.push(`${field} = ?`); values.push(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); } 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 - 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: 'Ungültige 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 Zuständige oder ein Administrator löschen.', }); } await pool.query('DELETE FROM crm_opportunities WHERE id = ?', [id]); res.json({ ok: true }); } catch (err) { next(err); } }); export default router;