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:
Sascha Wolf
2026-08-18 11:12:49 +02:00
co-authored by Claude Opus 5
parent 025390e18a
commit 8741a9ae0c
39 changed files with 719 additions and 199 deletions
+46
View File
@@ -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).
+3 -1
View File
@@ -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: '[]',
+11 -2
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -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) => {
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+1 -1
View File
@@ -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
View File
@@ -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