Files
perinco-CRM/server/src/routes/opportunities.js
T
Sascha WolfandClaude Opus 5.5 b9d6544b30 feat(opportunities): letzten Phasenwechsel mit Benutzer und Zeit anzeigen
Neue Spalten stage_changed_at/stage_changed_by, gesetzt nur bei echtem
Phasenwechsel per PATCH. Der Dialog zeigt unter "Angelegt von" die Zeile
"Verschoben von ... am ... um ... in ...".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:29:00 +02:00

828 lines
30 KiB
JavaScript

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;