Suspects: Vorstufe der Leads als Tabelle
Neuer Menüpunkt über Leads mit Unterpunkt Archiv. - Tabelle Name, Funktion, Unternehmen, LinkedIn, Website, Notiz, Letzter Kontakt, Nächster Impuls; leere Zeile unten, speichert beim Verlassen. Links als Symbol mit URL im Tooltip. - Nächster Impuls: Tage eingeben, Erinnerung über den ReminderWatcher - Archivieren, Zurückholen, endgültig löschen (nur aus dem Archiv) - Lead erstellen öffnet die Lead-Karte vorbelegt (LeadModal.initial), danach wandert der Suspect mit lead_id ins Archiv Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e0f6ef06cd
commit
6a452f1510
@@ -0,0 +1,177 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { logHistory } from '../history.js';
|
||||
|
||||
/*
|
||||
* Suspects - die Stufe vor dem Lead (siehe docs/13-suspects.md).
|
||||
*
|
||||
* Eine schlichte Tabelle, die man wie eine Liste fuellt. Aus einer Zeile wird
|
||||
* spaeter ein Lead; dann traegt sie `lead_id` und liegt im Archiv.
|
||||
*/
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const TEXT_FIELDS = { name: 190, role: 190, company: 190, linkedin: 500, website: 500, notes: 5000 };
|
||||
|
||||
function readBody(body) {
|
||||
const out = {};
|
||||
for (const [field, max] of Object.entries(TEXT_FIELDS)) {
|
||||
if (body[field] === undefined) continue;
|
||||
const value = String(body[field] ?? '').trim().slice(0, max);
|
||||
out[field] = value || null;
|
||||
}
|
||||
// Links ohne Protokoll ("www.firma.de") sonst als relative Adresse geoeffnet.
|
||||
for (const field of ['linkedin', 'website']) {
|
||||
if (out[field] && !/^https?:\/\//i.test(out[field])) out[field] = `https://${out[field]}`;
|
||||
}
|
||||
if (body.last_contact_on !== undefined) {
|
||||
out.last_contact_on = /^\d{4}-\d{2}-\d{2}$/.test(String(body.last_contact_on ?? ''))
|
||||
? body.last_contact_on
|
||||
: null;
|
||||
}
|
||||
// Wie bei den Leads: Tage statt Datum, das Datum rechnet der Server.
|
||||
if (body.remind_in_days !== undefined) {
|
||||
const n = Number(body.remind_in_days);
|
||||
out.remind_at = Number.isFinite(n) && n > 0 ? inDays(n) : null;
|
||||
}
|
||||
if (body.archived !== undefined) out.archived = body.archived ? 1 : 0;
|
||||
if (body.lead_id !== undefined) {
|
||||
const n = Number(body.lead_id);
|
||||
out.lead_id = Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
const SELECT = `
|
||||
SELECT s.*, l.title AS lead_title
|
||||
FROM icon_suspects s
|
||||
LEFT JOIN icon_leads l ON l.id = s.lead_id`;
|
||||
|
||||
const normalize = (row) => ({ ...row, archived: Boolean(row.archived) });
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
const archived = String(req.query.archived ?? '0') === '1';
|
||||
try {
|
||||
// Neueste unten: die Tabelle waechst wie eine Liste nach unten.
|
||||
const [rows] = await pool.query(`${SELECT} WHERE s.archived = ? ORDER BY ${archived ? 's.updated_at DESC' : 's.id'}`, [
|
||||
archived ? 1 : 0,
|
||||
]);
|
||||
res.json({ suspects: rows.map(normalize) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Faellige Impulse - fragt der Erinnerungswaechter zusammen mit den Leads ab. */
|
||||
router.get('/reminders', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`${SELECT} WHERE s.archived = 0 AND s.remind_at IS NOT NULL AND s.remind_at <= NOW() ORDER BY s.remind_at`
|
||||
);
|
||||
res.json({ due: rows.map(normalize) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
const data = readBody(req.body || {});
|
||||
if (!data.name) return res.status(400).json({ error: 'Der Name ist erforderlich.' });
|
||||
try {
|
||||
const columns = Object.keys(data);
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO icon_suspects (${columns.join(', ')}, created_by) VALUES (${columns.map(() => '?').join(', ')}, ?)`,
|
||||
[...columns.map((c) => data[c]), req.user.id]
|
||||
);
|
||||
const [[row]] = await pool.query(`${SELECT} WHERE s.id = ?`, [result.insertId]);
|
||||
res.status(201).json({ suspect: normalize(row) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
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 Kennung.' });
|
||||
const data = readBody(req.body || {});
|
||||
if (data.name === null) return res.status(400).json({ error: 'Der Name darf nicht leer sein.' });
|
||||
const columns = Object.keys(data);
|
||||
if (columns.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||
|
||||
try {
|
||||
const [[before]] = await pool.query('SELECT name, archived FROM icon_suspects WHERE id = ?', [id]);
|
||||
if (!before) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
|
||||
|
||||
await pool.query(`UPDATE icon_suspects SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`, [
|
||||
...columns.map((c) => data[c]),
|
||||
id,
|
||||
]);
|
||||
|
||||
if (data.archived !== undefined && data.archived !== before.archived) {
|
||||
await logHistory({
|
||||
entityType: 'system',
|
||||
entityLabel: 'Suspects',
|
||||
action: data.archived ? 'archived' : 'updated',
|
||||
text: data.lead_id
|
||||
? `Suspect „${before.name}" als Lead übernommen`
|
||||
: data.archived
|
||||
? `Suspect „${before.name}" archiviert`
|
||||
: `Suspect „${before.name}" aus dem Archiv zurückgeholt`,
|
||||
userId: req.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const [[row]] = await pool.query(`${SELECT} WHERE s.id = ?`, [id]);
|
||||
res.json({ suspect: normalize(row) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Impuls quittieren: `snooze_days` verschiebt ihn, sonst gilt die Person als
|
||||
* kontaktiert - Letzter Kontakt wird heute, die Erinnerung verschwindet.
|
||||
*/
|
||||
router.post('/:id/acknowledge', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
|
||||
const days = Number(req.body?.snooze_days);
|
||||
try {
|
||||
const [result] =
|
||||
Number.isFinite(days) && days > 0
|
||||
? await pool.query('UPDATE icon_suspects SET remind_at = ? WHERE id = ?', [inDays(days), id])
|
||||
: await pool.query('UPDATE icon_suspects SET remind_at = NULL, last_contact_on = CURDATE() WHERE id = ?', [id]);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Endgueltig loeschen - nur aus dem Archiv, damit nichts aus Versehen verschwindet. */
|
||||
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 Kennung.' });
|
||||
try {
|
||||
const [[row]] = await pool.query('SELECT name, archived FROM icon_suspects WHERE id = ?', [id]);
|
||||
if (!row) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
|
||||
if (!row.archived) return res.status(409).json({ error: 'Bitte zuerst archivieren.' });
|
||||
await pool.query('DELETE FROM icon_suspects WHERE id = ?', [id]);
|
||||
await logHistory({
|
||||
entityType: 'system',
|
||||
entityLabel: 'Suspects',
|
||||
action: 'deleted',
|
||||
text: `Suspect „${row.name}" endgültig gelöscht`,
|
||||
userId: req.user.id,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user