Files
ICON-CRM/server/src/routes/suspects.js
T
schakal00783andClaude Opus 5.5 33c9dbdb2b Suspects: Eingabezeile oben, Letzter Kontakt standardmäßig heute
- 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>
2026-09-29 12:37:56 +02:00

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;