- Eingabezeile über der Liste, mit Abstand und Knopf Hinzufügen; Liste neueste zuerst, neue Einträge erscheinen direkt darunter - Letzter Kontakt neuer Zeilen = heute (Ortszeit), auch serverseitig, wenn das Feld beim Anlegen fehlt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
6.9 KiB
JavaScript
184 lines
6.9 KiB
JavaScript
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, email: 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]}`;
|
|
}
|
|
// "mailto:" aus kopierten Links entfernen - gespeichert wird die blanke Adresse.
|
|
if (out.email) out.email = out.email.replace(/^mailto:/i, '');
|
|
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;
|
|
}
|
|
|
|
/** Heute in deutscher Zeit als YYYY-MM-DD (sv-SE formatiert genau so). */
|
|
const localDate = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' });
|
|
|
|
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 oben - direkt unter der Eingabezeile, die oben steht.
|
|
const [rows] = await pool.query(`${SELECT} WHERE s.archived = ? ORDER BY ${archived ? 's.updated_at DESC' : 's.id DESC'}`, [
|
|
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.' });
|
|
// Ohne Angabe gilt heute als letzter Kontakt; ein bewusst leeres Feld (null) bleibt leer.
|
|
if (req.body?.last_contact_on === undefined) data.last_contact_on = localDate();
|
|
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 - aus der Tabelle wie aus dem Archiv (die Oberflaeche fragt nach). */
|
|
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 FROM icon_suspects WHERE id = ?', [id]);
|
|
if (!row) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
|
|
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;
|