feat(opportunities): Papierkorb statt endgueltigem Loeschen
Das Loeschsymbol gab es in der Kachelansicht schon, es war aber bis zum
Ueberfahren durchsichtig - ein Knopf, den man nur findet, wenn man ihn kennt.
Jetzt ist er sichtbar, in der Liste ebenso, und die Pipeline-Karte hat ihn
ueberhaupt zum ersten Mal.
Geloescht wird nicht mehr endgueltig: DELETE setzt deleted_at, die Zeile bleibt.
An einer Opportunity haengen Ansprechpartner, Verlauf und Mailzuordnungen, und
ein Fehlklick soll das nicht kosten. Der Papierkorb ist ein Unterpunkt unter
Opportunities und zeigt, wann und von wem geloescht wurde und wie lange der
Eintrag noch liegt. Zurueckholen darf, wer auch loeschen darf; das Leeren jeder
Angemeldete - was dort liegt, hat schon jemand mit den noetigen Rechten
weggeworfen. Beides fragt vorher nach.
Nach 90 Tagen raeumt das Backend selbst auf, beim Abruf der Liste und hoechstens
einmal pro Stunde je Prozess - dasselbe Muster wie in der Akquise. Ein eigener
Zeitplan waere eine weitere Stelle, die ausfallen kann, und die Abfragen filtern
Faelliges ohnehin heraus.
Im Papierkorb liegende Opportunities sind aus allen Listen, Summen und
Zuordnungsdialogen heraus und lassen sich nicht bearbeiten (409) - erst
zurueckholen, dann aendern.
Nebenbei: der Header bekommt seinen Titel jetzt ueber viewTitle(). Der
Ansichtsname musste eindeutig sein ("Opportunity-Papierkorb", weil die Mails
schon einen Papierkorb haben), angezeigt wird beides als "Papierkorb".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2717462c3a
commit
1ac0e2d2c7
@@ -70,12 +70,43 @@ const SELECT_OPPORTUNITIES = `
|
||||
creator.name AS created_by_name,
|
||||
owner.name AS owner_name,
|
||||
owner.color AS owner_color,
|
||||
owner.avatar AS owner_avatar
|
||||
owner.avatar AS owner_avatar,
|
||||
deleter.name AS deleted_by_name
|
||||
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
|
||||
`;
|
||||
|
||||
/** 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.
|
||||
@@ -187,11 +218,17 @@ function mayModify(user, opportunity) {
|
||||
);
|
||||
}
|
||||
|
||||
// GET /api/opportunities
|
||||
// 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} ORDER BY o.updated_at DESC, o.id DESC`
|
||||
`${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))));
|
||||
@@ -200,6 +237,22 @@ router.get('/', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 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);
|
||||
@@ -302,6 +355,11 @@ router.patch('/:id', async (req, res, next) => {
|
||||
);
|
||||
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.',
|
||||
@@ -403,29 +461,75 @@ router.patch('/:id', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/opportunities/:id - Ansprechpartner raeumt die DB per CASCADE
|
||||
/*
|
||||
* 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 FROM crm_opportunities WHERE id = ? LIMIT 1',
|
||||
'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('DELETE FROM crm_opportunities WHERE id = ?', [id]);
|
||||
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, 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.',
|
||||
});
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE crm_opportunities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
res.json(await fetchOpportunity(id));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user