Files
perinco-CRM/server/src/routes/acquisitions.js
T
Sascha WolfandClaude Opus 5 c380d05b42 feat(akquise): geloeschte Opportunity gibt die Bewerbung ans Archiv zurueck
Wer eine Opportunity loescht, hat bisher auch die Bewerbung dahinter verloren:
aus der Akquise war sie mit der Uebernahme heraus, und die Opportunity gab es
nicht mehr. Der Eintrag stand nirgends.

Jetzt taucht er im Archiv auf - abgeleitet, nicht gespeichert: `converted_at`
bleibt stehen, sichtbar wird der Eintrag, solange seine Opportunity im
Papierkorb liegt oder endgueltig weg ist. Wird sie zurueckgeholt, verschwindet
der Eintrag von selbst wieder. Es gibt damit keinen Zustand, der nachgezogen
werden muesste und auseinanderlaufen kann.

Die Regel steht als IS_OPEN an einer Stelle und gilt fuer Liste, Zaehler und den
Aufraeumlauf: ein Rueckfaeller altert wie jeder andere Eintrag nach 90 Tagen
heraus, und seine Anzahl wird vorher in den Verlauf gesichert.

Ausserdem:
- Bearbeiten und erneutes Uebernehmen sind wieder erlaubt, sobald die
  Opportunity weg ist. Gesperrt bleibt der Eintrag nur, solange sie lebt - dort
  wird dann gepflegt.
- Der Eingang zeigt weiter nur, was nie uebernommen wurde.
- Die Zeile traegt ein Kennzeichen ("Opportunity im Papierkorb" bzw.
  "Opportunity geloescht"), sonst stuende sie unerklaerlich im Archiv.
- Im Dashboard zaehlt eine Wandlung nur mit lebender Opportunity. Wird sie
  geloescht, faellt die Bewerbung zurueck - dann soll die Quote nicht behaupten,
  aus ihr sei eine Opportunity geworden.

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

742 lines
26 KiB
JavaScript

import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
/*
* Akquise: gefundene Projekte, aus denen eine Opportunity werden kann.
*
* Drei Zustaende, alle aus `found_at` und `converted_at` abgeleitet - es gibt
* keine Spalte, die ein Job pflegen muesste:
*
* Eingang ("Akquise") found_at >= heute - 14 Tage, nicht uebernommen
* Archiv aelter als 14 Tage, nicht uebernommen
* weg aelter als 90 Tage (wird geloescht) oder uebernommen
*
* Uebernommene Eintraege bleiben in der Tabelle stehen: sie sind der Nachweis,
* woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht.
*/
/*
* Ist die Opportunity zu einem Eintrag verschwunden - im Papierkorb oder
* endgueltig geloescht -, faellt die Bewerbung ins Archiv zurueck. Sonst wuerde
* sie einfach nicht mehr existieren: aus der Akquise ist sie mit der Uebernahme
* heraus, und die Opportunity gibt es auch nicht mehr.
*
* Abgeleitet statt gespeichert: `converted_at` bleibt stehen. Wird die
* Opportunity aus dem Papierkorb zurueckgeholt, verschwindet der Eintrag von
* selbst wieder aus dem Archiv - es gibt keinen Zustand, der nachgezogen werden
* muesste.
*
* `opp.id IS NULL` deckt zwei Faelle ab: nie uebernommen (opportunity_id ist
* NULL) und endgueltig geloescht (der Fremdschluessel wurde dabei auf NULL
* gesetzt). Zusammen mit converted_at ergibt das den Unterschied.
*/
const OPPORTUNITY_GONE = '(opp.id IS NULL OR opp.deleted_at IS NOT NULL)';
/** Bewerbung, die (wieder) in Eingang oder Archiv gehoert. */
const IS_OPEN = `(a.converted_at IS NULL OR ${OPPORTUNITY_GONE})`;
/** Grenze zwischen Eingang und Archiv, in Tagen. */
const ARCHIVE_AFTER_DAYS = 14;
/** Ab hier loescht das Archiv selbst. */
const DELETE_AFTER_DAYS = 90;
/*
* Felder, deren Inhalte als Auswahlliste in den Einstellungen gepflegt werden.
* Die Werte selbst stehen in crm_acquisition_options, nicht hier - deshalb
* prueft das Backend beim Speichern eines Eintrags nur die Laenge und nicht die
* Zugehoerigkeit zur Liste: wer einen Wert aus der Liste entfernt, soll damit
* nicht jeden alten Eintrag unaenderbar machen.
*
* "provider" (Anbieter) fehlt hier absichtlich: das Feld ist Freitext mit
* Vorschlaegen aus den bisherigen Eintraegen.
*/
const OPTION_FIELDS = ['sender', 'channel', 'consultant'];
// `link` fehlt hier absichtlich: der Wert wird eigens geprueft (normalizeLink).
const TEXT_FIELDS = [
'sender',
'provider',
'title',
'consultant',
'company',
'description',
'next_step',
'notes',
];
const DATE_FIELDS = ['found_at', 'start_date', 'end_date'];
const NUMBER_FIELDS = [
['daily_rate', 'Der Tagessatz', 1_000_000],
['duration_days', 'Die Laufzeit', 100_000],
['fixed_price', 'Der Projektpreis', 1_000_000_000],
];
/** Laengengrenzen der Spalten - abgewiesen statt stillschweigend abgeschnitten. */
const MAX_LENGTH = {
sender: 190,
provider: 190,
title: 190,
consultant: 190,
link: 1024,
company: 190,
next_step: 500,
};
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 };
}
/**
* Nur http und https. Ohne diese Pruefung liesse sich ein `javascript:`-Verweis
* hinterlegen, den der Titel in der Liste anklickbar macht.
*/
function normalizeLink(value) {
const text = emptyToNull(value);
if (text === null) return { value: null };
// Ohne Schema ist "hays.de/projekt/123" gemeint - das ergaenzen wir.
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text) ? text : `https://${text}`;
let url;
try {
url = new URL(candidate);
} catch {
return { error: 'Der Link ist keine gültige Adresse.' };
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { error: 'Der Link muss mit http:// oder https:// beginnen.' };
}
if (candidate.length > MAX_LENGTH.link) return { error: 'Der Link ist zu lang (max. 1024 Zeichen).' };
return { value: candidate };
}
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))]);
}
/** DECIMAL kommt als String aus mysql2 - im Frontend sollen es Zahlen sein. */
function mapAcquisition(row) {
return {
...row,
// Aus der Abfrage kommt 0/1 - im Frontend soll es ein Schalter sein.
opportunity_gone: Boolean(Number(row.opportunity_gone)),
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),
tags: parseTags(row.tags),
};
}
const SELECT_ACQUISITIONS = `
SELECT a.*,
creator.name AS created_by_name,
opp.title AS opportunity_title,
opp.stage AS opportunity_stage,
opp.deleted_at AS opportunity_deleted_at,
${OPPORTUNITY_GONE} AS opportunity_gone
FROM crm_acquisitions a
LEFT JOIN crm_users creator ON creator.id = a.created_by
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
`;
async function fetchAcquisition(id) {
const [rows] = await pool.query(`${SELECT_ACQUISITIONS} WHERE a.id = ? LIMIT 1`, [id]);
return rows[0] ? mapAcquisition(rows[0]) : null;
}
/**
* Alles aelter als 90 Tage entfernen. Uebernommene Eintraege bleiben - sie
* gehoeren zur Opportunity, nicht zum Archiv.
*
* Das 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 Altlasten ohnehin heraus, der Loeschlauf raeumt nur
* hinterher.
*/
let lastPurgeAt = 0;
async function purgeOldEntries() {
if (Date.now() - lastPurgeAt < 3_600_000) return;
lastPurgeAt = Date.now();
/*
* Vor dem Loeschen die Zahlen sichern: jeder Eintrag ist eine versendete
* Bewerbung, und das Dashboard rechnet ueber Jahre. Gesichert wird nur Tag,
* Berater und Anzahl - die Projektdaten sind danach wirklich weg.
*
* Sichern und Loeschen gehoeren in eine Transaktion: die Anzahl wird
* aufaddiert (ein Tag kann aus einem frueheren Lauf schon in der Historie
* stehen), und ein Abbruch zwischen beiden Schritten wuerde denselben Tag beim
* naechsten Lauf ein zweites Mal dazurechnen.
*/
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO crm_acquisition_history (found_on, consultant, applications)
SELECT a.found_at, COALESCE(a.consultant, ''), COUNT(*)
FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
WHERE ${IS_OPEN}
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY a.found_at, COALESCE(a.consultant, '')
ON DUPLICATE KEY UPDATE
applications = crm_acquisition_history.applications + VALUES(applications)`,
[DELETE_AFTER_DAYS]
);
/*
* Geloescht wird ueber dieselbe Regel: was in Eingang oder Archiv steht,
* altert nach 90 Tagen heraus - auch ein Rueckfaeller. Eintraege mit
* lebender Opportunity bleiben unberuehrt, sie gehoeren zu ihr.
*/
const [result] = await conn.query(
`DELETE a FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
WHERE ${IS_OPEN}
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`,
[DELETE_AFTER_DAYS]
);
await conn.commit();
if (result.affectedRows > 0) {
console.log(
`[Akquise] ${result.affectedRows} Eintrag/Eintraege aelter als ${DELETE_AFTER_DAYS} Tage geloescht (Anzahl im Verlauf gesichert).`
);
}
} catch (err) {
await conn.rollback().catch(() => {});
// Nicht weiterwerfen: das Aufraeumen darf die Liste nicht blockieren.
console.warn(`[Akquise] Aufraeumen fehlgeschlagen: ${err.message}`);
} finally {
conn.release();
}
}
/** Alle Auswahllisten als { sender: [...], channel: [...], consultant: [...] }. */
async function loadOptions() {
const [rows] = await pool.query(
'SELECT id, field, value FROM crm_acquisition_options ORDER BY field, value'
);
const options = { sender: [], channel: [], consultant: [] };
for (const row of rows) options[row.field].push({ id: row.id, value: row.value });
return options;
}
/**
* Bisher benutzte Anbieter, der zuletzt verwendete zuerst. Das Feld ist Freitext -
* die Vorschlaege ersparen das Ausschreiben, ohne einen neuen Vermittler zu
* verbieten. Bewusst aus dem gesamten Bestand, nicht nur aus der gezeigten Liste.
*/
async function loadProviderSuggestions() {
const [rows] = await pool.query(
`SELECT provider FROM crm_acquisitions
WHERE provider IS NOT NULL AND provider <> ''
GROUP BY provider
ORDER BY MAX(found_at) DESC, provider ASC
LIMIT 100`
);
return rows.map((row) => row.provider);
}
/**
* Ist die Opportunity zu einem Eintrag verschwunden? Dieselbe Frage wie
* OPPORTUNITY_GONE, nur fuer eine einzelne Zeile - beim Bearbeiten und beim
* erneuten Uebernehmen.
*/
async function opportunityGone(opportunityId) {
if (opportunityId === null || opportunityId === undefined) return true;
const [rows] = await pool.query(
'SELECT deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1',
[opportunityId]
);
return !rows[0] || rows[0].deleted_at !== null;
}
/** Aendern und loeschen darf der Ersteller und jeder Admin. */
function mayModify(user, row) {
return user.role === 'admin' || row.created_by === user.id;
}
/**
* Felder aus dem Anfragekoerper pruefen. `partial` laesst fehlende Felder weg
* (PATCH), sonst werden alle gesetzt (POST).
*/
function readFields(body, { partial }) {
const columns = {};
for (const field of TEXT_FIELDS) {
if (partial && body?.[field] === undefined) continue;
const value = emptyToNull(body?.[field]);
if (MAX_LENGTH[field] && value !== null && value.length > MAX_LENGTH[field]) {
return { error: `Der Wert für "${field}" ist zu lang (max. ${MAX_LENGTH[field]} Zeichen).` };
}
columns[field] = value;
}
if (!partial || body?.link !== undefined) {
const link = normalizeLink(body?.link);
if (link.error) return { error: link.error };
columns.link = link.value;
}
if (!partial || body?.channel !== undefined) {
const channel = emptyToNull(body?.channel);
if (channel !== null && channel.length > 60) {
return { error: 'Der Kanal ist zu lang (max. 60 Zeichen).' };
}
columns.channel = channel;
}
for (const field of DATE_FIELDS) {
if (partial && body?.[field] === undefined) continue;
const result = normalizeDate(body?.[field]);
if (result.error) return { error: result.error };
columns[field] = result.value;
}
for (const [field, label, max] of NUMBER_FIELDS) {
if (partial && body?.[field] === undefined) continue;
const result = normalizeNumber(body?.[field], label, max);
if (result.error) return { error: result.error };
columns[field] = result.value;
}
if (!partial || body?.tags !== undefined) {
columns.tags = serializeTags(body?.tags) ?? '[]';
}
if (columns.title !== undefined && !columns.title) {
return { error: 'Ein Projekttitel ist erforderlich.' };
}
if (columns.start_date && columns.end_date && columns.end_date < columns.start_date) {
return { error: 'Das Projektende darf nicht vor dem Start liegen.' };
}
return { columns };
}
// ---------------------------------------------------------------------------
// Auswahllisten (Einstellungen -> Akquise)
//
// Pflegen darf jeder Angemeldete. Wer ein Projekt eintraegt und dabei auf einen
// neuen Anbieter stoesst, muss weiterarbeiten koennen - eine Liste, die nur ein
// Administrator ergaenzen kann, blockiert genau dann die Eingabe.
// ---------------------------------------------------------------------------
// GET /api/acquisitions/options
router.get('/options', async (req, res, next) => {
try {
res.json(await loadOptions());
} catch (err) {
next(err);
}
});
// POST /api/acquisitions/options
router.post('/options', async (req, res, next) => {
const field = String(req.body?.field ?? '');
if (!OPTION_FIELDS.includes(field)) return res.status(400).json({ error: 'Unbekanntes Feld.' });
const value = emptyToNull(req.body?.value);
if (!value) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
const limit = field === 'channel' ? 60 : 190;
if (value.length > limit) {
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
}
try {
const [result] = await pool.query(
'INSERT INTO crm_acquisition_options (field, value, created_by) VALUES (?, ?, ?)',
[field, value, req.user.id]
);
res.status(201).json({ id: result.insertId, field, value });
} catch (err) {
if (err?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' });
}
next(err);
}
});
/*
* PATCH /api/acquisitions/options/:id
*
* Umbenennen zieht die vorhandenen Eintraege mit: wird "Hays" zu "Hays AG", soll
* die Liste nicht auf einmal zwei Anbieter zeigen, von denen einer in keinem
* Dropdown mehr steht.
*/
router.patch('/options/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' });
const value = emptyToNull(req.body?.value);
if (!value) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
const conn = await pool.getConnection();
try {
const [rows] = await conn.query(
'SELECT field, value FROM crm_acquisition_options WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
const limit = existing.field === 'channel' ? 60 : 190;
if (value.length > limit) {
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
}
if (value === existing.value) return res.json({ id, field: existing.field, value, updated: 0 });
await conn.beginTransaction();
await conn.query('UPDATE crm_acquisition_options SET value = ? WHERE id = ?', [value, id]);
const [updated] = await conn.query(
`UPDATE crm_acquisitions SET ${existing.field} = ? WHERE ${existing.field} = ?`,
[value, existing.value]
);
await conn.commit();
res.json({ id, field: existing.field, value, updated: updated.affectedRows });
} catch (err) {
await conn.rollback().catch(() => {});
if (err?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' });
}
next(err);
} finally {
conn.release();
}
});
/*
* DELETE /api/acquisitions/options/:id
*
* Nur die Auswahl verschwindet. Die Eintraege behalten ihren Wert - sonst wuerde
* das Aufraeumen einer Liste stillschweigend Daten aendern. Wie viele Eintraege
* betroffen sind, steht in der Antwort, damit die Oberflaeche es sagen kann.
*/
router.delete('/options/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' });
try {
const [rows] = await pool.query(
'SELECT field, value FROM crm_acquisition_options WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
const [[used]] = await pool.query(
`SELECT COUNT(*) AS count FROM crm_acquisitions
WHERE converted_at IS NULL AND ${existing.field} = ?`,
[existing.value]
);
await pool.query('DELETE FROM crm_acquisition_options WHERE id = ?', [id]);
res.json({ ok: true, still_in_use: Number(used.count) });
} catch (err) {
next(err);
}
});
// GET /api/acquisitions?view=eingang|archiv|alle
router.get('/', async (req, res, next) => {
try {
await purgeOldEntries();
const view = String(req.query.view ?? 'eingang');
const where = [IS_OPEN];
const params = [];
/*
* Der Eingang zeigt nur, was noch nie uebernommen wurde. Eine Bewerbung,
* deren Opportunity geloescht wurde, gehoert ins Archiv - unabhaengig davon,
* wie alt sie ist: sie ist abgearbeitet, aber nicht verschwunden.
*/
if (view === 'eingang') {
where.push('a.converted_at IS NULL');
where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
params.push(ARCHIVE_AFTER_DAYS);
} else if (view === 'archiv') {
// Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur
// stuendlich, angezeigt werden soll nichts, was schon faellig ist.
where.push(
`(a.converted_at IS NOT NULL
OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))`
);
where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
params.push(ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS);
} else if (view !== 'alle') {
return res.status(400).json({ error: 'Unbekannte Ansicht.' });
}
const [rows] = await pool.query(
`${SELECT_ACQUISITIONS} WHERE ${where.join(' AND ')}
ORDER BY a.found_at DESC, a.id DESC`,
params
);
// Beide Zaehler in einem Rutsch: die Oberflaeche zeigt sie am Menue und
// muesste sonst zwei Listen laden, um eine Zahl zu kennen.
const [[counts]] = await pool.query(
`SELECT
SUM(a.converted_at IS NULL
AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS eingang,
SUM((a.converted_at IS NOT NULL
OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))
AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS archiv
FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
WHERE ${IS_OPEN}`,
[ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS]
);
// Auswahllisten und Anbieter-Vorschlaege kommen mit: die Eingabe braucht sie
// sofort, und ein zweiter Aufruf nur fuer ein paar kurze Listen waere
// verschenkt.
res.json({
archive_after_days: ARCHIVE_AFTER_DAYS,
delete_after_days: DELETE_AFTER_DAYS,
options: await loadOptions(),
provider_suggestions: await loadProviderSuggestions(),
counts: {
eingang: Number(counts.eingang ?? 0),
archiv: Number(counts.archiv ?? 0),
},
entries: rows.map(mapAcquisition),
});
} catch (err) {
next(err);
}
});
// POST /api/acquisitions
router.post('/', async (req, res, next) => {
const parsed = readFields(req.body, { partial: false });
if (parsed.error) return res.status(400).json({ error: parsed.error });
const columns = parsed.columns;
// Ohne Angabe gilt heute: gefunden wurde es dann, wenn es eingetragen wird.
if (columns.found_at === null) columns.found_at = new Date().toISOString().slice(0, 10);
columns.created_by = req.user.id;
try {
const keys = Object.keys(columns);
const [result] = await pool.query(
`INSERT INTO crm_acquisitions (${keys.join(', ')})
VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((key) => columns[key])
);
res.status(201).json(await fetchAcquisition(result.insertId));
} catch (err) {
next(err);
}
});
// PATCH /api/acquisitions/: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 Akquise-ID.' });
try {
const [rows] = await pool.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error: 'Diesen Eintrag darf nur ändern, wer ihn angelegt hat, oder ein Administrator.',
});
}
/*
* Gesperrt ist der Eintrag nur, solange seine Opportunity lebt - dort wird
* dann gepflegt. Ist sie im Papierkorb oder geloescht, ist der Eintrag
* wieder das Original und darf geaendert werden.
*/
if (existing.converted_at !== null && !(await opportunityGone(existing.opportunity_id))) {
return res
.status(409)
.json({ error: 'Der Eintrag wurde schon in eine Opportunity übernommen.' });
}
const parsed = readFields(req.body, { partial: true });
if (parsed.error) return res.status(400).json({ error: parsed.error });
const columns = parsed.columns;
if ('title' in columns && !columns.title) {
return res.status(400).json({ error: 'Der Projekttitel darf nicht leer sein.' });
}
// Datumsgrenzen gegen den gespeicherten Stand pruefen, nicht nur gegen das,
// was gerade mitgeschickt wurde.
const start = 'start_date' in columns ? columns.start_date : existing.start_date;
const end = 'end_date' in columns ? columns.end_date : existing.end_date;
if (start && end && end < start) {
return res.status(400).json({ error: 'Das Projektende darf nicht vor dem Start liegen.' });
}
const keys = Object.keys(columns);
if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
await pool.query(
`UPDATE crm_acquisitions SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
[...keys.map((key) => columns[key]), id]
);
res.json(await fetchAcquisition(id));
} catch (err) {
next(err);
}
});
// DELETE /api/acquisitions/:id
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 Akquise-ID.' });
try {
const [rows] = await pool.query(
'SELECT created_by FROM crm_acquisitions WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.',
});
}
await pool.query('DELETE FROM crm_acquisitions WHERE id = ?', [id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/*
* POST /api/acquisitions/:id/opportunity
*
* Aus dem Eintrag wird eine Opportunity in der Phase "Neu". Beides in einer
* Transaktion: entweder die Opportunity steht und der Eintrag ist als
* uebernommen vermerkt, oder es bleibt alles wie es war. Sonst koennte eine
* Opportunity entstehen, die der Eingang weiterhin anbietet.
*/
router.post('/:id/opportunity', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
const conn = await pool.getConnection();
try {
const [rows] = await conn.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]);
const entry = rows[0];
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
// Noch einmal uebernehmen darf man, wenn die erste Opportunity weg ist.
if (entry.converted_at !== null && !(await opportunityGone(entry.opportunity_id))) {
return res.status(409).json({
error: 'Aus diesem Eintrag wurde schon eine Opportunity.',
opportunity_id: entry.opportunity_id,
});
}
/*
* Was in der Liste steht, aber in der Opportunity kein Feld hat (Anbieter,
* Kanal, Absender, Link, Berater), wandert in die Notizen. Sonst wuesste
* hinterher niemand mehr, woher das Projekt kam.
*/
const origin = [
['Anbieter', entry.provider],
['Von', entry.sender],
['Kanal', entry.channel],
['Berater', entry.consultant],
['Link', entry.link],
]
.filter(([, value]) => value)
.map(([label, value]) => `${label}: ${value}`);
const notes = [
entry.notes,
origin.length > 0 ? `Aus der Akquise vom ${entry.found_at}\n${origin.join('\n')}` : null,
]
.filter(Boolean)
.join('\n\n');
await conn.beginTransaction();
const [created] = await conn.query(
`INSERT INTO crm_opportunities
(title, company, description, stage, probability, daily_rate, duration_days,
fixed_price, start_date, end_date, next_step, tags, notes, owner_id, created_by)
VALUES (?, ?, ?, 'Neu', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
entry.title,
entry.company,
entry.description,
entry.daily_rate,
entry.duration_days,
entry.fixed_price,
entry.start_date,
entry.end_date,
entry.next_step,
entry.tags ?? '[]',
notes || null,
req.user.id,
req.user.id,
]
);
await conn.query(
'UPDATE crm_acquisitions SET opportunity_id = ?, converted_at = UTC_TIMESTAMP() WHERE id = ?',
[created.insertId, id]
);
await conn.commit();
res.status(201).json({ ok: true, opportunity_id: created.insertId });
} catch (err) {
await conn.rollback().catch(() => {});
next(err);
} finally {
conn.release();
}
});
export default router;