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>
828 lines
30 KiB
JavaScript
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;
|