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;