Files
perinco-CRM/server/src/routes/opportunities.js
T
Sascha WolfandClaude Opus 5 025390e18a feat(opportunities): Festpreis, Klick daneben, Kontakt von hier
Drei Dinge, die im Alltag gefehlt haben:

- Projektdaten kennen jetzt einen Projektpreis. Ist er gesetzt, gilt er als
  Volumen statt Tagessatz x Laufzeit - Festpreisprojekte mussten bisher
  ausgerechnet und in den Tagessatz gefaelscht werden.
- Dialoge schliessen auch per Klick in den Hintergrund, nicht nur ueber das X.
  Escape trifft dabei nur den obersten Dialog; eine Textmarkierung, die im
  Dialog beginnt und daneben endet, schliesst nichts.
- Fehlt ein Ansprechpartner noch im CRM, oeffnet "Neuer Kontakt..." in der
  Opportunity denselben Dialog wie unter Kontakte. Der frische Kontakt haengt
  danach sofort als Ansprechpartner dran, ohne die Opportunity zu verlassen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:41:11 +02:00

432 lines
15 KiB
JavaScript

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;