feat: Interviews, Mail-Papierkorb, Kontaktart
Drei Aenderungen aus dem Alltagsgebrauch:
- Der Kalender heisst jetzt "Interviews" - dafuer wird er benutzt. Der Wert in
ViewState wandert mit, damit Menue und Kopfzeile nicht auseinanderlaufen.
Sichtbare Reste ("Kalenderfarbe") heissen jetzt Terminfarbe.
- Mails haben einen Papierkorb, der unter dem Menuepunkt aufklappt. Loeschen
heisst dabei ausschliesslich: im CRM. Im Postfach crm@perinco.de bleibt die
Mail unberuehrt - Schreibrechte darauf hat die Anwendung nicht. Damit der
Delta-Abgleich geloeschte Mails nicht wieder einliest, bleibt nach dem Leeren
eine leere Zeile als Merker stehen (crm_purged); runMailSync() ueberspringt
alles, was im CRM geloescht ist. Zurueckholen und Leeren darf jeder
Angemeldete, Leeren nach einer Warnung.
- Der Kontaktstatus (Neu .. Verloren) wird zur Kontaktart: Recruiter, Partner,
Endkunde. Die Spalte heisst jetzt kind, weil "Status" das Feld falsch
benannte. migrateLeadKind() stellt bestehende Datenbanken um und setzt alle
vorhandenen Kontakte auf Recruiter - welche Art jemand ist, sagt der alte
Status nicht (so mit dem Nutzer entschieden).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
025390e18a
commit
8741a9ae0c
@@ -48,10 +48,56 @@ export async function initDatabase() {
|
||||
conn.release();
|
||||
}
|
||||
|
||||
await migrateLeadKind();
|
||||
await migrateMailPool();
|
||||
await ensureAdminUser();
|
||||
}
|
||||
|
||||
/*
|
||||
* Einmalige Umstellung: aus dem Vertriebsstatus eines Kontakts
|
||||
* (Neu, Kontaktiert, Qualifiziert, Angebot, Gewonnen, Verloren) wird seine Art
|
||||
* (Recruiter, Partner, Endkunde). Erkannt wird der Altstand an der Spalte
|
||||
* selbst - dafuer braucht es keinen Merker in crm_settings.
|
||||
*
|
||||
* Die Reihenfolge ist wichtig: erst die Auswahl auf alte plus neue Werte
|
||||
* erweitern, dann die Altwerte auf 'Recruiter' setzen, dann umbenennen und auf
|
||||
* die drei neuen Werte verengen. Direkt zu verengen wuerde jeden Altwert zu ''
|
||||
* machen - welcher Kontakt welche Art ist, sagt der alte Status nicht, deshalb
|
||||
* die einheitliche Vorgabe (so entschieden am 2026-08-18).
|
||||
*/
|
||||
async function migrateLeadKind() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_leads'
|
||||
AND COLUMN_NAME IN ('status', 'kind')`
|
||||
);
|
||||
const columns = new Set(rows.map((row) => row.COLUMN_NAME));
|
||||
if (columns.has('kind') || !columns.has('status')) return;
|
||||
|
||||
await pool.query(
|
||||
`ALTER TABLE crm_leads MODIFY status
|
||||
ENUM('Neu','Kontaktiert','Qualifiziert','Angebot','Gewonnen','Verloren',
|
||||
'Recruiter','Partner','Endkunde') NOT NULL DEFAULT 'Recruiter'`
|
||||
);
|
||||
const [result] = await pool.query(
|
||||
`UPDATE crm_leads SET status = 'Recruiter'
|
||||
WHERE status NOT IN ('Recruiter', 'Partner', 'Endkunde')`
|
||||
);
|
||||
await pool.query(
|
||||
`ALTER TABLE crm_leads CHANGE COLUMN status kind
|
||||
ENUM('Recruiter','Partner','Endkunde') NOT NULL DEFAULT 'Recruiter'`
|
||||
);
|
||||
|
||||
// Der Index zeigt nach dem Umbenennen weiter auf dieselbe Spalte, traegt aber
|
||||
// noch den alten Namen. Neu anlegen, alten entfernen.
|
||||
await pool.query('ALTER TABLE crm_leads ADD INDEX IF NOT EXISTS idx_crm_leads_kind (kind)');
|
||||
await pool.query('ALTER TABLE crm_leads DROP INDEX IF EXISTS idx_crm_leads_status');
|
||||
|
||||
console.log(
|
||||
`[perinco CRM] Kontaktstatus auf Kontaktart umgestellt - ${result.affectedRows} Kontakt(e) auf "Recruiter" gesetzt.`
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte
|
||||
* (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1).
|
||||
|
||||
@@ -113,7 +113,9 @@ export async function adoptCandidate(
|
||||
const fields = candidateToLeadFields(candidate);
|
||||
const columns = {
|
||||
...fields,
|
||||
status: 'Neu',
|
||||
// Art des Kontakts bleibt auf der Vorgabe der Spalte ('Recruiter'): welche
|
||||
// Art jemand ist, sagt eine Mail nicht - das entscheidet der Mensch beim
|
||||
// Uebernehmen aus "Aus Mails".
|
||||
source: MAIL_LEAD_SOURCE,
|
||||
mail_pool: mailPool,
|
||||
tags: '[]',
|
||||
|
||||
@@ -48,15 +48,24 @@ export async function resetDelta() {
|
||||
* Ist die Mail schon bekannt, und liegt ihr Text vor? Der Delta-Abgleich holt
|
||||
* absichtlich nur Metadaten; der Mailtext wird einzeln nachgeladen und nur dann,
|
||||
* wenn er wirklich fehlt.
|
||||
*
|
||||
* `inTrash` und `purged` sagen dem Abgleich, dass er die Mail in Ruhe lassen
|
||||
* soll: im Postfach steht sie weiter, im CRM ist sie geloescht.
|
||||
*/
|
||||
export async function getMessageStateByHash(graphIdHash) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, (body_text IS NOT NULL AND body_text <> '') AS has_body
|
||||
`SELECT id, crm_deleted_at, crm_purged,
|
||||
(body_text IS NOT NULL AND body_text <> '') AS has_body
|
||||
FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1`,
|
||||
[graphIdHash]
|
||||
);
|
||||
if (!rows[0]) return null;
|
||||
return { id: rows[0].id, hasBody: Boolean(Number(rows[0].has_body)) };
|
||||
return {
|
||||
id: rows[0].id,
|
||||
hasBody: Boolean(Number(rows[0].has_body)),
|
||||
inTrash: rows[0].crm_deleted_at !== null,
|
||||
purged: Boolean(Number(rows[0].crm_purged)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-1
@@ -166,6 +166,7 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
|
||||
let newMessages = 0;
|
||||
let updatedMessages = 0;
|
||||
let deletedMessages = 0;
|
||||
let skippedTrashed = 0;
|
||||
let newCandidates = 0;
|
||||
let adoptedContacts = 0;
|
||||
let pages = 0;
|
||||
@@ -201,6 +202,17 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
|
||||
const message = normalizeMessage(raw);
|
||||
const existing = await getMessageStateByHash(message.graphIdHash);
|
||||
|
||||
/*
|
||||
* Im CRM geloescht - Papierkorb oder endgueltig. Im Postfach steht die
|
||||
* Mail weiter, der Delta-Abgleich liefert sie also wieder aus. Sie hier
|
||||
* zu ueberspringen ist der ganze Grund, warum die Zeile nach dem Leeren
|
||||
* des Papierkorbs als Merker stehen bleibt.
|
||||
*/
|
||||
if (existing && (existing.inTrash || existing.purged)) {
|
||||
skippedTrashed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* Zeitgrenze - immer geprueft, nicht nur wenn der Serverfilter versagt
|
||||
* hat. Ein Erstabgleich ohne Filter zieht sich ueber mehrere Laeufe; in
|
||||
@@ -298,13 +310,15 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
|
||||
deletedMessages,
|
||||
newCandidates,
|
||||
adoptedContacts,
|
||||
skippedTrashed,
|
||||
hasMore: hasMore || truncated,
|
||||
};
|
||||
|
||||
if (newMessages || newCandidates || deletedMessages) {
|
||||
console.log(
|
||||
`[Mail] Abgleich (${trigger}): ${newMessages} neu, ${updatedMessages} aktualisiert, ` +
|
||||
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Eintraege in "Aus Mails".`
|
||||
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Eintraege in "Aus Mails"` +
|
||||
`${skippedTrashed ? `, ${skippedTrashed} im CRM geloescht und daher uebersprungen` : ''}.`
|
||||
);
|
||||
}
|
||||
if (hasMore || truncated) {
|
||||
|
||||
@@ -60,7 +60,7 @@ router.get('/me', requireAuth, (req, res) => {
|
||||
});
|
||||
|
||||
// PATCH /api/auth/me { color?, avatar? }
|
||||
// Jeder darf seine eigene Kalenderfarbe und sein Foto setzen - dafuer braucht
|
||||
// Jeder darf seine eigene Terminfarbe und sein Foto setzen - dafuer braucht
|
||||
// es keine Adminrechte. null bzw. "" setzt das Feld zurueck (Farbe: automatisch
|
||||
// aus der Palette, Foto: Initiale).
|
||||
router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
|
||||
@@ -123,7 +123,7 @@ async function replaceParticipants(conn, eventId, ids) {
|
||||
);
|
||||
}
|
||||
|
||||
// GET /api/events - der Kalender ist geteilt, jeder sieht alle Termine
|
||||
// GET /api/events - die Termine sind geteilt, jeder sieht alle
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(`${SELECT_EVENTS} ORDER BY e.starts_at ASC, e.id ASC`);
|
||||
|
||||
+13
-17
@@ -5,14 +5,10 @@ import { requireAuth } from '../middleware/auth.js';
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const STATUSES = new Set([
|
||||
'Neu',
|
||||
'Kontaktiert',
|
||||
'Qualifiziert',
|
||||
'Angebot',
|
||||
'Gewonnen',
|
||||
'Verloren',
|
||||
]);
|
||||
// Art des Kontakts. Vorgabe ist 'Recruiter' - auch fuer die Kontakte, die vor
|
||||
// der Umstellung noch einen Vertriebsstatus trugen (siehe migrateLeadKind()).
|
||||
const KINDS = new Set(['Recruiter', 'Partner', 'Endkunde']);
|
||||
const DEFAULT_KIND = 'Recruiter';
|
||||
|
||||
// Felder, die per API gesetzt werden duerfen. owner_id/created_by werden
|
||||
// separat behandelt, alles andere ist nicht beschreibbar.
|
||||
@@ -122,7 +118,7 @@ router.post('/', async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Vorname und Nachname sind erforderlich.' });
|
||||
}
|
||||
|
||||
const status = STATUSES.has(req.body?.status) ? req.body.status : 'Neu';
|
||||
const kind = KINDS.has(req.body?.kind) ? req.body.kind : DEFAULT_KIND;
|
||||
const ownerId =
|
||||
req.body?.owner_id === undefined || req.body.owner_id === null || req.body.owner_id === ''
|
||||
? req.user.id
|
||||
@@ -134,12 +130,12 @@ router.post('/', async (req, res, next) => {
|
||||
|
||||
const consent = consentUpdate(req.body?.mail_consent, null);
|
||||
|
||||
const columns = [...TEXT_FIELDS, 'status', 'tags', 'owner_id', 'created_by', ...Object.keys(consent)];
|
||||
const columns = [...TEXT_FIELDS, 'kind', 'tags', 'owner_id', 'created_by', ...Object.keys(consent)];
|
||||
const values = [
|
||||
...TEXT_FIELDS.map((field) =>
|
||||
field === 'first_name' ? firstName : field === 'last_name' ? lastName : emptyToNull(req.body?.[field])
|
||||
),
|
||||
status,
|
||||
kind,
|
||||
serializeTags(req.body?.tags) ?? '[]',
|
||||
ownerId,
|
||||
req.user.id,
|
||||
@@ -169,7 +165,7 @@ router.post('/', async (req, res, next) => {
|
||||
/*
|
||||
* Felder, die beim Uebernehmen verglichen und uebertragen werden koennen.
|
||||
* `source` fehlt absichtlich - die Herkunft 'Mail' wandert unveraendert mit und
|
||||
* ist keine Entscheidung des Benutzers. `notes`, `avatar`, `tags` und `status`
|
||||
* ist keine Entscheidung des Benutzers. `notes`, `avatar`, `tags` und `kind`
|
||||
* bleiben ebenfalls aussen vor: sie sind gepflegte Angaben des Kontakts und
|
||||
* haben in einem Mailfund kein Gegenstueck.
|
||||
*/
|
||||
@@ -217,7 +213,7 @@ async function moveMailRelations(fromLeadId, toLeadId) {
|
||||
async function copyPoolLeadToContacts(poolLead, { userId, ownerId }) {
|
||||
const columns = {};
|
||||
for (const field of TEXT_FIELDS) columns[field] = poolLead[field] ?? null;
|
||||
columns.status = poolLead.status;
|
||||
columns.kind = poolLead.kind;
|
||||
columns.tags = poolLead.tags ?? '[]';
|
||||
columns.mail_pool = 0;
|
||||
columns.owner_id = ownerId;
|
||||
@@ -435,10 +431,10 @@ router.patch('/:id', async (req, res, next) => {
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
if (req.body?.status !== undefined) {
|
||||
if (!STATUSES.has(req.body.status)) return res.status(400).json({ error: 'Ungültiger Status.' });
|
||||
fields.push('status = ?');
|
||||
values.push(req.body.status);
|
||||
if (req.body?.kind !== undefined) {
|
||||
if (!KINDS.has(req.body.kind)) return res.status(400).json({ error: 'Ungültige Kontaktart.' });
|
||||
fields.push('kind = ?');
|
||||
values.push(req.body.kind);
|
||||
}
|
||||
|
||||
if (req.body?.tags !== undefined) {
|
||||
|
||||
+136
-6
@@ -53,6 +53,8 @@ function mapMessageRow(row) {
|
||||
body_preview: row.body_preview,
|
||||
has_attachments: Boolean(row.has_attachments),
|
||||
is_deleted: Boolean(row.is_deleted),
|
||||
crm_deleted_at: row.crm_deleted_at ?? null,
|
||||
crm_deleted_by_name: row.crm_deleted_by_name ?? null,
|
||||
web_link: row.web_link,
|
||||
link_count: Number(row.link_count ?? 0),
|
||||
open_candidates: Number(row.open_candidates ?? 0),
|
||||
@@ -71,8 +73,13 @@ router.get('/status', async (req, res, next) => {
|
||||
|
||||
const [[counts]] = await pool.query(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM crm_mail_messages WHERE is_deleted = 0) AS messages,
|
||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'offen') AS open_candidates,
|
||||
(SELECT COUNT(*) FROM crm_mail_messages
|
||||
WHERE is_deleted = 0 AND crm_deleted_at IS NULL) AS messages,
|
||||
(SELECT COUNT(*) FROM crm_mail_messages
|
||||
WHERE crm_deleted_at IS NOT NULL AND crm_purged = 0) AS trash,
|
||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates c
|
||||
JOIN crm_mail_messages m ON m.id = c.message_id
|
||||
WHERE c.status = 'offen' AND m.crm_deleted_at IS NULL) AS open_candidates,
|
||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'uebernommen') AS adopted_candidates,
|
||||
(SELECT COUNT(*) FROM crm_leads WHERE mail_pool = 1) AS pool_leads,
|
||||
(SELECT COUNT(*) FROM crm_mail_links) AS links`
|
||||
@@ -98,6 +105,7 @@ router.get('/status', async (req, res, next) => {
|
||||
last_candidates: state.last_candidates,
|
||||
counts: {
|
||||
messages: Number(counts.messages),
|
||||
trash: Number(counts.trash),
|
||||
open_candidates: Number(counts.open_candidates),
|
||||
adopted_candidates: Number(counts.adopted_candidates),
|
||||
pool_leads: Number(counts.pool_leads),
|
||||
@@ -200,7 +208,7 @@ router.post('/sync', requireAdmin, async (req, res, next) => {
|
||||
// Nachrichten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/mail/messages?search=&limit=&offset=&filter=
|
||||
// GET /api/mail/messages?search=&limit=&offset=&filter=&trash=
|
||||
router.get('/messages', async (req, res, next) => {
|
||||
try {
|
||||
const limit = clampInt(req.query.limit, 50, 1, 200);
|
||||
@@ -209,6 +217,17 @@ router.get('/messages', async (req, res, next) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
/*
|
||||
* `trash=true` zeigt genau den Papierkorb, sonst ist er ausgeblendet.
|
||||
* Endgueltig geloeschte Mails (crm_purged) tauchen nirgends auf - ihre Zeile
|
||||
* ist nur noch ein Merker fuer den Abgleich.
|
||||
*/
|
||||
if (req.query.trash === 'true') {
|
||||
where.push('m.crm_deleted_at IS NOT NULL AND m.crm_purged = 0');
|
||||
} else {
|
||||
where.push('m.crm_deleted_at IS NULL');
|
||||
}
|
||||
|
||||
if (req.query.include_deleted !== 'true') where.push('m.is_deleted = 0');
|
||||
|
||||
const search = String(req.query.search ?? '').trim();
|
||||
@@ -235,10 +254,12 @@ router.get('/messages', async (req, res, next) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT m.id, m.subject, m.from_name, m.from_email, m.to_json, m.cc_json, m.received_at,
|
||||
m.body_preview, m.has_attachments, m.is_deleted, m.web_link,
|
||||
m.crm_deleted_at, du.name AS crm_deleted_by_name,
|
||||
(SELECT COUNT(*) FROM crm_mail_links l WHERE l.message_id = m.id) AS link_count,
|
||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates c
|
||||
WHERE c.message_id = m.id AND c.status = 'offen') AS open_candidates
|
||||
FROM crm_mail_messages m
|
||||
LEFT JOIN crm_users du ON du.id = m.crm_deleted_by
|
||||
${whereSql}
|
||||
ORDER BY m.received_at DESC, m.id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -262,9 +283,16 @@ router.get('/messages/:id', async (req, res, next) => {
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Mail-ID.' });
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM crm_mail_messages WHERE id = ? LIMIT 1', [id]);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT m.*, du.name AS crm_deleted_by_name
|
||||
FROM crm_mail_messages m
|
||||
LEFT JOIN crm_users du ON du.id = m.crm_deleted_by
|
||||
WHERE m.id = ? LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return res.status(404).json({ error: 'Mail nicht gefunden.' });
|
||||
if (Number(row.crm_purged) === 1) return res.status(404).json({ error: 'Mail nicht gefunden.' });
|
||||
|
||||
const [links] = await pool.query(
|
||||
`SELECT l.id, l.lead_id, l.opportunity_id, l.origin, l.note, l.created_at,
|
||||
@@ -320,6 +348,105 @@ router.get('/messages/:id', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Papierkorb
|
||||
//
|
||||
// Loeschen heisst hier: aus dem CRM verschwinden lassen. Im Postfach
|
||||
// crm@perinco.de bleibt die Mail unberuehrt - Schreibzugriff darauf hat das CRM
|
||||
// bewusst nicht. Der Abgleich ueberspringt geloeschte Mails, sie kommen also
|
||||
// nicht wieder (siehe sync.js).
|
||||
//
|
||||
// In den Papierkorb darf jeder Angemeldete: der Schritt ist umkehrbar. Das
|
||||
// Leeren ist es nicht - davor warnt die Oberflaeche.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// POST /api/mail/trash/empty - Papierkorb endgueltig leeren
|
||||
router.post('/trash/empty', async (req, res, next) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// Offene Vorschlaege verschwinden mit der Mail. Entschiedene bleiben: sie
|
||||
// sind der Nachweis, woher ein Kontakt stammt.
|
||||
await conn.query(
|
||||
`DELETE c FROM crm_mail_contact_candidates c
|
||||
JOIN crm_mail_messages m ON m.id = c.message_id
|
||||
WHERE m.crm_deleted_at IS NOT NULL AND m.crm_purged = 0 AND c.status = 'offen'`
|
||||
);
|
||||
|
||||
// Eine Mail, die es nicht mehr gibt, kann an nichts mehr haengen.
|
||||
await conn.query(
|
||||
`DELETE l FROM crm_mail_links l
|
||||
JOIN crm_mail_messages m ON m.id = l.message_id
|
||||
WHERE m.crm_deleted_at IS NOT NULL AND m.crm_purged = 0`
|
||||
);
|
||||
|
||||
/*
|
||||
* Die Zeile bleibt stehen, ihr Inhalt geht. Nur so weiss der naechste
|
||||
* Abgleich, dass diese Mail im CRM nicht mehr erwuenscht ist - im Postfach
|
||||
* liegt sie ja weiter und wuerde sonst erneut eingelesen.
|
||||
*/
|
||||
const [result] = await conn.query(
|
||||
`UPDATE crm_mail_messages
|
||||
SET crm_purged = 1,
|
||||
subject = NULL, body_preview = NULL, body_text = NULL, body_html = NULL,
|
||||
to_json = NULL, cc_json = NULL, bcc_json = NULL,
|
||||
from_name = NULL, from_email = NULL, web_link = NULL
|
||||
WHERE crm_deleted_at IS NOT NULL AND crm_purged = 0`
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
res.json({ ok: true, purged: result.affectedRows });
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {});
|
||||
next(err);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/mail/messages/:id - Mail in den Papierkorb legen
|
||||
router.delete('/messages/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Mail-ID.' });
|
||||
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
`UPDATE crm_mail_messages
|
||||
SET crm_deleted_at = UTC_TIMESTAMP(), crm_deleted_by = ?
|
||||
WHERE id = ? AND crm_deleted_at IS NULL`,
|
||||
[req.user.id, id]
|
||||
);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ error: 'Mail nicht gefunden oder schon im Papierkorb.' });
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mail/messages/:id/restore - Mail aus dem Papierkorb zurueckholen
|
||||
router.post('/messages/:id/restore', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Mail-ID.' });
|
||||
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
`UPDATE crm_mail_messages
|
||||
SET crm_deleted_at = NULL, crm_deleted_by = NULL
|
||||
WHERE id = ? AND crm_deleted_at IS NOT NULL AND crm_purged = 0`,
|
||||
[id]
|
||||
);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ error: 'Mail liegt nicht im Papierkorb.' });
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mail/messages/:id/links - Mail an Kontakt und/oder Opportunity haengen
|
||||
router.post('/messages/:id/links', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
@@ -413,7 +540,10 @@ router.get('/candidates', async (req, res, next) => {
|
||||
const limit = clampInt(req.query.limit, 100, 1, 500);
|
||||
const status = String(req.query.status ?? 'offen');
|
||||
|
||||
const where = [];
|
||||
// Vorschlaege aus Mails im Papierkorb bleiben aussen vor: eine geloeschte
|
||||
// Mail soll keine Entscheidung mehr verlangen. Beim Zuruecknehmen der
|
||||
// Loeschung sind sie wieder da.
|
||||
const where = ['m.crm_deleted_at IS NULL'];
|
||||
const params = [];
|
||||
if (status !== 'alle') {
|
||||
if (!CANDIDATE_STATUSES.has(status)) return res.status(400).json({ error: 'Ungültiger Status.' });
|
||||
@@ -439,7 +569,7 @@ router.get('/candidates', async (req, res, next) => {
|
||||
JOIN crm_mail_messages m ON m.id = c.message_id
|
||||
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
|
||||
LEFT JOIN crm_users u ON u.id = c.decided_by
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY c.confidence DESC, m.received_at DESC, c.id DESC
|
||||
LIMIT ?`,
|
||||
[...params, limit]
|
||||
|
||||
@@ -24,7 +24,7 @@ function normalizeEmail(value) {
|
||||
// GET /api/users/directory
|
||||
// Benutzerliste ohne Adminrechte - jeder angemeldete Benutzer braucht sie fuer
|
||||
// das Netzwerkdiagramm, Zustaendigkeiten, @-Mentions, Teilnehmerauswahl und
|
||||
// Farben im Kalender. Bewusst vor dem Admin-Guard registriert; Passwort-Hashes
|
||||
// Terminfarben. Bewusst vor dem Admin-Guard registriert; Passwort-Hashes
|
||||
// tauchen hier nie auf. Deaktivierte Konten bleiben enthalten, damit alte
|
||||
// Zuordnungen und Mentions weiter aufloesbar sind - die Auswahlfelder im
|
||||
// Frontend filtern sie heraus.
|
||||
|
||||
+28
-4
@@ -40,8 +40,9 @@ CREATE TABLE IF NOT EXISTS crm_leads (
|
||||
zip VARCHAR(24) NULL,
|
||||
city VARCHAR(120) NULL,
|
||||
country VARCHAR(120) NULL,
|
||||
status ENUM('Neu','Kontaktiert','Qualifiziert','Angebot','Gewonnen','Verloren')
|
||||
NOT NULL DEFAULT 'Neu',
|
||||
-- Art des Kontakts. Frueher ein Vertriebsstatus (Neu .. Verloren); die
|
||||
-- Umstellung bestehender Datenbanken macht migrateLeadKind() in db.js.
|
||||
kind ENUM('Recruiter','Partner','Endkunde') NOT NULL DEFAULT 'Recruiter',
|
||||
source VARCHAR(120) NULL,
|
||||
tags LONGTEXT NULL, -- JSON-Array als Text (MariaDB-kompatibel)
|
||||
notes TEXT NULL,
|
||||
@@ -53,7 +54,7 @@ CREATE TABLE IF NOT EXISTS crm_leads (
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_crm_leads_name (last_name, first_name),
|
||||
KEY idx_crm_leads_company (company),
|
||||
KEY idx_crm_leads_status (status),
|
||||
KEY idx_crm_leads_kind (kind),
|
||||
KEY idx_crm_leads_owner (owner_id),
|
||||
KEY idx_crm_leads_creator (created_by),
|
||||
CONSTRAINT fk_crm_leads_owner FOREIGN KEY (owner_id) REFERENCES crm_users (id) ON DELETE SET NULL,
|
||||
@@ -231,15 +232,38 @@ CREATE TABLE IF NOT EXISTS crm_mail_messages (
|
||||
web_link VARCHAR(1024) NULL, -- Outlook-Web-Link
|
||||
is_read_in_mailbox TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0, -- im Postfach geloescht
|
||||
crm_deleted_at DATETIME NULL, -- im CRM in den Papierkorb gelegt (UTC)
|
||||
crm_deleted_by INT UNSIGNED NULL, -- wer; bewusst ohne Fremdschluessel, s.u.
|
||||
crm_purged TINYINT(1) NOT NULL DEFAULT 0, -- Papierkorb geleert
|
||||
first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_crm_mail_graph (graph_id_hash),
|
||||
KEY idx_crm_mail_received (received_at),
|
||||
KEY idx_crm_mail_from (from_email),
|
||||
KEY idx_crm_mail_conversation (conversation_id(191))
|
||||
KEY idx_crm_mail_conversation (conversation_id(191)),
|
||||
KEY idx_crm_mail_crm_deleted (crm_deleted_at)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
/*
|
||||
* Papierkorb des CRM. Zwei Stufen, weil sie zwei verschiedene Dinge bedeuten:
|
||||
*
|
||||
* crm_deleted_at - liegt im Papierkorb, laesst sich zurueckholen.
|
||||
* crm_purged - Papierkorb geleert. Die Zeile bleibt als Merker stehen,
|
||||
* ihr Inhalt wird geleert. Ohne diesen Merker wuerde der
|
||||
* naechste Abgleich die Mail aus dem Postfach - dort ist sie
|
||||
* ja weiterhin vorhanden - sofort wieder einlesen.
|
||||
*
|
||||
* crm_deleted_by traegt bewusst keinen Fremdschluessel: Spalten lassen sich per
|
||||
* ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht.
|
||||
* Verschwindet der Benutzer, bleibt hier eine Id ohne Namen stehen - der Merker
|
||||
* ist die Loeschung selbst, nicht die Person.
|
||||
*/
|
||||
ALTER TABLE crm_mail_messages ADD COLUMN IF NOT EXISTS crm_deleted_at DATETIME NULL AFTER is_deleted;
|
||||
ALTER TABLE crm_mail_messages ADD COLUMN IF NOT EXISTS crm_deleted_by INT UNSIGNED NULL AFTER crm_deleted_at;
|
||||
ALTER TABLE crm_mail_messages ADD COLUMN IF NOT EXISTS crm_purged TINYINT(1) NOT NULL DEFAULT 0 AFTER crm_deleted_by;
|
||||
ALTER TABLE crm_mail_messages ADD INDEX IF NOT EXISTS idx_crm_mail_crm_deleted (crm_deleted_at);
|
||||
|
||||
/*
|
||||
* Verknuepfung einer Mail mit Kontakt und/oder Opportunity. Absichtlich eine
|
||||
* eigene Tabelle statt eines Textanhangs im Notizfeld: so laesst sich eine
|
||||
|
||||
Reference in New Issue
Block a user