OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.
Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.
Bewusst so entschieden:
- Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
- Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
Notzugang, falls Entra oder das Secret ausfaellt.
- Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
Apache-Log.
- Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.
Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
562 lines
30 KiB
SQL
562 lines
30 KiB
SQL
-- perinco CRM - Schema fuer MariaDB-Datenbank perinco_CRM
|
|
-- Idempotent: kann jederzeit erneut ausgefuehrt werden.
|
|
|
|
CREATE TABLE IF NOT EXISTS crm_users (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
username VARCHAR(64) NOT NULL,
|
|
email VARCHAR(190) NULL,
|
|
name VARCHAR(190) NOT NULL,
|
|
role ENUM('admin','user') NOT NULL DEFAULT 'user',
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
created_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_users_username (username),
|
|
UNIQUE KEY uq_crm_users_email (email)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Persoenliche Farbe fuer die Kalenderansicht. NULL = automatisch aus der
|
|
-- Palette (siehe userColor() im Frontend). ADD COLUMN IF NOT EXISTS haelt das
|
|
-- Statement idempotent (MariaDB-Erweiterung).
|
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS color VARCHAR(9) NULL AFTER role;
|
|
|
|
-- Benutzerfoto als Data-URL oder externe URL - wie bei crm_leads.avatar.
|
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color;
|
|
|
|
-- Persoenliche Darstellung. Sie haengt am Benutzer und nicht am Browser: wer
|
|
-- sich an einem anderen Rechner anmeldet, findet sein Design wieder, und zwei
|
|
-- Benutzer am selben Rechner kommen sich nicht in die Quere.
|
|
-- theme = Farbwelt ('perinco', 'pixel', 'outdoor', 'cyberpunk'); NULL = 'perinco'
|
|
-- theme_mode = 'light' oder 'dark'; NULL = der Systemeinstellung folgen
|
|
-- Bewusst VARCHAR und kein ENUM: ein weiteres Theme soll eine Zeile im
|
|
-- Frontend sein und keine Datenbankmigration. Geprueft wird in userFields.js.
|
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme VARCHAR(32) NULL AFTER avatar;
|
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme_mode VARCHAR(16) NULL AFTER theme;
|
|
|
|
-- Objekt-ID des Microsoft-Kontos (Entra), gesetzt bei der ersten Anmeldung per
|
|
-- SSO. Sie ist die stabile Zuordnung: die E-Mail-Adresse eines Menschen kann
|
|
-- sich aendern, die Objekt-ID nicht. NULL = noch nie per Microsoft angemeldet;
|
|
-- dann greift der Abgleich ueber die Adresse. Unique, damit nicht zwei
|
|
-- CRM-Konten an demselben Microsoft-Konto haengen.
|
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS entra_oid VARCHAR(64) NULL AFTER email;
|
|
ALTER TABLE crm_users ADD UNIQUE INDEX IF NOT EXISTS uq_crm_users_entra_oid (entra_oid);
|
|
|
|
CREATE TABLE IF NOT EXISTS crm_leads (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
salutation VARCHAR(32) NULL,
|
|
first_name VARCHAR(120) NOT NULL,
|
|
last_name VARCHAR(120) NOT NULL,
|
|
company VARCHAR(190) NULL,
|
|
position VARCHAR(190) NULL,
|
|
email VARCHAR(190) NULL,
|
|
phone VARCHAR(64) NULL,
|
|
mobile VARCHAR(64) NULL,
|
|
linkedin VARCHAR(255) NULL,
|
|
website VARCHAR(255) NULL,
|
|
street VARCHAR(190) NULL,
|
|
zip VARCHAR(24) NULL,
|
|
city VARCHAR(120) NULL,
|
|
country VARCHAR(120) NULL,
|
|
-- 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,
|
|
avatar LONGTEXT NULL, -- Data-URL oder externe URL
|
|
owner_id INT UNSIGNED NULL, -- zustaendiger Benutzer
|
|
created_by INT UNSIGNED NULL, -- wer den Kontakt angelegt hat
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_crm_leads_name (last_name, first_name),
|
|
KEY idx_crm_leads_company (company),
|
|
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,
|
|
CONSTRAINT fk_crm_leads_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Kontakte aus dem Postfach landen nicht direkt im Kontaktbestand, sondern in
|
|
* einem Vorbereich: mail_pool = 1. Die Kontaktansicht zeigt nur mail_pool = 0,
|
|
* der Vorbereich steht als eigener Menuepunkt "Aus Mails" daneben. Erst die
|
|
* Uebernahme dort erzeugt einen richtigen Kontakt (mail_pool = 0).
|
|
*
|
|
* Bewusst eine Spalte statt einer zweiten Tabelle: Mailverknuepfungen
|
|
* (crm_mail_links) und Vorschlaege (crm_mail_contact_candidates) zeigen per
|
|
* Fremdschluessel auf crm_leads und bleiben damit auch fuer den Vorbereich
|
|
* gueltig. Die Einmal-Umstellung bestehender Zeilen macht db.js.
|
|
*/
|
|
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_pool TINYINT(1) NOT NULL DEFAULT 0 AFTER source;
|
|
ALTER TABLE crm_leads ADD INDEX IF NOT EXISTS idx_crm_leads_mail_pool (mail_pool);
|
|
|
|
/*
|
|
* Einverstaendnis in Werbemails. Ohne Haken kommt die Adresse in keine
|
|
* Mailingliste - Vorgabe ist deshalb 0, auch bei Kontakten, die aus dem
|
|
* Vorbereich uebernommen werden: eine Mail von jemandem ist keine Einwilligung.
|
|
*
|
|
* `mail_consent_at` haelt fest, wann der Haken gesetzt wurde. Bei einer
|
|
* Beschwerde ist die Frage nicht "gibt es ein Einverstaendnis", sondern "seit
|
|
* wann" - ohne Zeitpunkt ist das Kennzeichen als Nachweis wertlos.
|
|
*/
|
|
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_consent TINYINT(1) NOT NULL DEFAULT 0 AFTER mail_pool;
|
|
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_consent_at DATETIME NULL AFTER mail_consent;
|
|
|
|
-- Wer steht mit wem in Kontakt. Ungerichtet gedacht; Duplikate werden ueber
|
|
-- den Unique-Key auf dem sortierten Paar verhindert (siehe Backend).
|
|
CREATE TABLE IF NOT EXISTS crm_lead_connections (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
from_lead_id INT UNSIGNED NOT NULL,
|
|
to_lead_id INT UNSIGNED NOT NULL,
|
|
type ENUM('Kollege','Vorgesetzter','Kunde','Partner','Empfehlung','Netzwerk','Sonstige')
|
|
NOT NULL DEFAULT 'Netzwerk',
|
|
label VARCHAR(190) NULL,
|
|
strength TINYINT UNSIGNED NOT NULL DEFAULT 2, -- 1 = lose, 2 = normal, 3 = eng
|
|
created_by INT UNSIGNED NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_conn_pair (from_lead_id, to_lead_id),
|
|
KEY idx_crm_conn_to (to_lead_id),
|
|
CONSTRAINT fk_crm_conn_from FOREIGN KEY (from_lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_conn_to FOREIGN KEY (to_lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_conn_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Vertriebschancen. Ansprechpartner haengen als n:m an den Kontakten, damit ein
|
|
-- Kontakt in mehreren Opportunities auftauchen kann.
|
|
CREATE TABLE IF NOT EXISTS crm_opportunities (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
title VARCHAR(190) NOT NULL,
|
|
company VARCHAR(190) NULL,
|
|
description TEXT NULL,
|
|
-- Phasen des Vermittlungsprozesses. Bis zum 18.08.2026 stand hier ein
|
|
-- allgemeiner Vertriebstrichter (Neu .. Verloren); die Umstellung bestehender
|
|
-- Datenbanken macht migrateOpportunityStages() in db.js.
|
|
stage ENUM('Neu','Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde','Zusage','Beauftragt','Absage')
|
|
NOT NULL DEFAULT 'Neu',
|
|
probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 %
|
|
daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro
|
|
duration_days DECIMAL(6,1) NULL, -- Personentage, halbe moeglich
|
|
fixed_price DECIMAL(12,2) NULL, -- Festpreis; Alternative zu Tagessatz x Laufzeit
|
|
start_date DATE NULL,
|
|
end_date DATE NULL,
|
|
last_interaction_at DATE NULL,
|
|
last_interaction_note VARCHAR(500) NULL,
|
|
next_step VARCHAR(500) NULL,
|
|
tags LONGTEXT NULL, -- JSON-Array als Text, wie bei crm_leads
|
|
notes TEXT NULL,
|
|
owner_id INT UNSIGNED NULL,
|
|
created_by INT UNSIGNED NULL,
|
|
deleted_at DATETIME NULL, -- im Papierkorb (UTC)
|
|
deleted_by INT UNSIGNED NULL, -- wer; bewusst ohne Fremdschluessel, s.u.
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_crm_opp_deleted (deleted_at),
|
|
KEY idx_crm_opp_stage (stage),
|
|
KEY idx_crm_opp_owner (owner_id),
|
|
KEY idx_crm_opp_interaction (last_interaction_at),
|
|
CONSTRAINT fk_crm_opp_owner FOREIGN KEY (owner_id) REFERENCES crm_users (id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_crm_opp_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Festpreisprojekte rechnen nicht ueber Tagessatz x Personentage. Ist der
|
|
-- Projektpreis gesetzt, gilt er als Volumen (siehe opportunityVolume()).
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS fixed_price DECIMAL(12,2) NULL AFTER duration_days;
|
|
|
|
/*
|
|
* Papierkorb der Opportunities. Loeschen setzt `deleted_at`, die Zeile bleibt -
|
|
* eine Opportunity traegt Verlauf, Ansprechpartner und Mailbezuege, und ein
|
|
* Fehlklick soll das nicht kosten. Nach 90 Tagen raeumt das Backend selbst auf,
|
|
* vorher laesst sich der Papierkorb von Hand leeren.
|
|
*
|
|
* `deleted_by` traegt bewusst keinen Fremdschluessel: Spalten lassen sich per
|
|
* ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht.
|
|
*/
|
|
/*
|
|
* Zeitpunkt des Gewinns. Ohne ihn liesse sich "wie viele Projekte haben wir im
|
|
* Mai gewonnen" nicht beantworten - die Phase allein sagt nur, wie es jetzt
|
|
* steht. Gesetzt beim Wechsel nach 'Beauftragt', geleert beim Wechsel heraus.
|
|
*/
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS won_at DATETIME NULL AFTER stage;
|
|
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at);
|
|
|
|
/*
|
|
* Fuer welchen Berater das Projekt laeuft.
|
|
*
|
|
* Bis zum 09.09.2026 stand der Berater nur in den Notizen: die Uebernahme aus
|
|
* der Akquise schrieb ihn dorthin, weil die Opportunity kein Feld dafuer hatte.
|
|
* In Pipeline, Liste und Kacheln war damit nicht zu sehen, fuer wen ein Projekt
|
|
* laeuft - neben dem Titel die wichtigste Angabe. Bestehende Opportunities aus
|
|
* der Akquise holt migrateOpportunityConsultant() in db.js aus ihren Notizen
|
|
* nach.
|
|
*
|
|
* Genau ein Name je Opportunity: passt ein Fund zu drei Beratern, entstehen bei
|
|
* der Uebernahme drei Opportunities. Deshalb 190 Zeichen wie die uebrigen
|
|
* Namensfelder und nicht 500 wie in der Akquise, wo eine Liste darin steht.
|
|
*/
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS consultant VARCHAR(190) NULL AFTER company;
|
|
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER created_by;
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at;
|
|
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_deleted (deleted_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS crm_opportunity_contacts (
|
|
opportunity_id INT UNSIGNED NOT NULL,
|
|
lead_id INT UNSIGNED NOT NULL,
|
|
role VARCHAR(120) NULL, -- z. B. "Entscheider", "Fachbereich"
|
|
PRIMARY KEY (opportunity_id, lead_id),
|
|
KEY idx_crm_opp_contacts_lead (lead_id),
|
|
CONSTRAINT fk_crm_oc_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_oc_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Gemeinsamer Teamkalender: alle Benutzer sehen alle Termine.
|
|
-- description darf Mention-Tokens der Form @[Anzeigename](lead:12) bzw.
|
|
-- @[Anzeigename](user:3) enthalten - das Frontend rendert sie klickbar.
|
|
CREATE TABLE IF NOT EXISTS crm_events (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
title VARCHAR(190) NOT NULL,
|
|
description TEXT NULL,
|
|
location VARCHAR(190) NULL,
|
|
starts_at DATETIME NOT NULL,
|
|
ends_at DATETIME NOT NULL,
|
|
all_day TINYINT(1) NOT NULL DEFAULT 0,
|
|
created_by INT UNSIGNED NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_crm_events_start (starts_at),
|
|
KEY idx_crm_events_creator (created_by),
|
|
CONSTRAINT fk_crm_events_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Ein Termin kann mehrere Teilnehmer haben; der Primaerschluessel verhindert
|
|
-- Doppeleintraege.
|
|
CREATE TABLE IF NOT EXISTS crm_event_participants (
|
|
event_id INT UNSIGNED NOT NULL,
|
|
user_id INT UNSIGNED NOT NULL,
|
|
PRIMARY KEY (event_id, user_id),
|
|
KEY idx_crm_event_participants_user (user_id),
|
|
CONSTRAINT fk_crm_ep_event FOREIGN KEY (event_id) REFERENCES crm_events (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_ep_user FOREIGN KEY (user_id) REFERENCES crm_users (id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ===========================================================================
|
|
-- Allgemeine Einstellungen des CRM als Schluessel/Wert. Bewusst ohne
|
|
-- Geheimnisse: Zugangsdaten stehen ausschliesslich in der .env auf dem Server,
|
|
-- damit ein Datenbank-Dump keine Secrets enthaelt.
|
|
-- ===========================================================================
|
|
CREATE TABLE IF NOT EXISTS crm_settings (
|
|
key_name VARCHAR(100) NOT NULL,
|
|
value TEXT NULL,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (key_name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ===========================================================================
|
|
-- M365-Postfachanbindung (crm@perinco.de)
|
|
-- ===========================================================================
|
|
|
|
/*
|
|
* Gespiegelte Nachrichten aus dem Postfach. Die Graph-ID ist zu lang fuer einen
|
|
* Unique-Index (utf8mb4), deshalb liegt daneben ihr SHA-256 als Hex - der traegt
|
|
* die Eindeutigkeit. Zeiten stehen in UTC, so wie Graph sie liefert. Die
|
|
* Umrechnung macht das Frontend.
|
|
*
|
|
* ROW_FORMAT=DYNAMIC steht ausdruecklich da: bei COMPACT legt InnoDB von jeder
|
|
* langen Spalte 768 Bytes in die Zeile, und die Summe aus mehreren VARCHAR(512+)
|
|
* und fuenf LONGTEXT reisst dann die Zeilengrenze.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_mail_messages (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
graph_id VARCHAR(1024) NOT NULL,
|
|
graph_id_hash CHAR(64) NOT NULL,
|
|
internet_message_id VARCHAR(512) NULL,
|
|
conversation_id VARCHAR(512) NULL,
|
|
subject VARCHAR(512) NULL,
|
|
from_name VARCHAR(190) NULL,
|
|
from_email VARCHAR(190) NULL,
|
|
to_json LONGTEXT NULL, -- [{name,email}] als JSON-Text
|
|
cc_json LONGTEXT NULL,
|
|
bcc_json LONGTEXT NULL,
|
|
received_at DATETIME NOT NULL, -- UTC
|
|
sent_at DATETIME NULL, -- UTC
|
|
body_preview VARCHAR(512) NULL,
|
|
body_text LONGTEXT NULL, -- aus HTML gewandelt, Zeilen erhalten
|
|
body_html LONGTEXT NULL, -- entschaerftes HTML fuer die Anzeige
|
|
has_attachments TINYINT(1) NOT NULL DEFAULT 0,
|
|
importance VARCHAR(16) NULL,
|
|
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_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
|
|
* Verknuepfung wieder loesen, und dieselbe Mail kann an mehreren Stellen haengen.
|
|
* Mehrere NULL-Werte sind in einem Unique-Index erlaubt - damit sperren die
|
|
* beiden Schluessel genau die Doppelung desselben Paares.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_mail_links (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
message_id INT UNSIGNED NOT NULL,
|
|
lead_id INT UNSIGNED NULL,
|
|
opportunity_id INT UNSIGNED NULL,
|
|
origin ENUM('manuell','automatisch') NOT NULL DEFAULT 'manuell',
|
|
note VARCHAR(500) NULL,
|
|
created_by INT UNSIGNED NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_mail_link_lead (message_id, lead_id),
|
|
UNIQUE KEY uq_crm_mail_link_opp (message_id, opportunity_id),
|
|
KEY idx_crm_mail_link_lead (lead_id),
|
|
KEY idx_crm_mail_link_opp (opportunity_id),
|
|
CONSTRAINT fk_crm_ml_message FOREIGN KEY (message_id) REFERENCES crm_mail_messages (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_ml_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_ml_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_ml_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Kontakt-Vorschlaege aus Mails. Bewusst NICHT direkt als Kontakt angelegt:
|
|
* das Altsystem hat dafuer Platzhalter-Adressen wie name@kontakt-aus-email.local
|
|
* erfunden und damit die Kontaktliste vermuellt. Hier entsteht ein Kontakt erst
|
|
* bei der Uebernahme - automatisch nur, wenn eine echte E-Mail-Adresse vorliegt.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_mail_contact_candidates (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
message_id INT UNSIGNED NOT NULL,
|
|
dedupe_hash CHAR(64) NOT NULL, -- aus E-Mail bzw. Name+Telefon
|
|
first_name VARCHAR(120) NULL,
|
|
last_name VARCHAR(120) NULL,
|
|
salutation VARCHAR(32) NULL,
|
|
email VARCHAR(190) NULL,
|
|
phone VARCHAR(64) NULL,
|
|
mobile VARCHAR(64) NULL,
|
|
company VARCHAR(190) NULL,
|
|
position VARCHAR(190) NULL,
|
|
street VARCHAR(190) NULL,
|
|
zip VARCHAR(24) NULL,
|
|
city VARCHAR(120) NULL,
|
|
website VARCHAR(255) NULL,
|
|
linkedin VARCHAR(255) NULL,
|
|
origin ENUM('absender','empfaenger','signatur','text') NOT NULL DEFAULT 'text',
|
|
confidence TINYINT UNSIGNED NOT NULL DEFAULT 50, -- 0-100
|
|
status ENUM('offen','uebernommen','verworfen','dublette') NOT NULL DEFAULT 'offen',
|
|
lead_id INT UNSIGNED NULL, -- gesetzt bei uebernommen und dublette
|
|
decided_by INT UNSIGNED NULL,
|
|
decided_at DATETIME NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_mail_cand (message_id, dedupe_hash),
|
|
KEY idx_crm_mail_cand_status (status),
|
|
KEY idx_crm_mail_cand_email (email),
|
|
CONSTRAINT fk_crm_mc_message FOREIGN KEY (message_id) REFERENCES crm_mail_messages (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_mc_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_crm_mc_user FOREIGN KEY (decided_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Zustand der Delta-Synchronisation - genau eine Zeile. `delta_link` ist die
|
|
* URL, die Graph fuer den naechsten Aufruf zurueckgegeben hat. `has_more`
|
|
* unterscheidet nextLink (Seite folgt noch) von deltaLink (aktueller Stand).
|
|
* Damit ueberlebt der Abgleich Neustarts und braucht keinen Schreibzugriff auf
|
|
* das Postfach - anders als der isRead-Ansatz des Altsystems.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_mail_sync_state (
|
|
id TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
|
mailbox VARCHAR(190) NULL,
|
|
delta_link TEXT NULL,
|
|
has_more TINYINT(1) NOT NULL DEFAULT 0,
|
|
last_run_at DATETIME NULL,
|
|
last_success_at DATETIME NULL,
|
|
last_error TEXT NULL,
|
|
last_new_messages INT UNSIGNED NOT NULL DEFAULT 0,
|
|
last_candidates INT UNSIGNED NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1);
|
|
|
|
-- ===========================================================================
|
|
-- Akquise: gefundene Projekte, bevor daraus eine Opportunity wird
|
|
--
|
|
-- Der Eingang ist eine Arbeitsliste, kein Archiv: was aelter als 14 Tage ist,
|
|
-- erscheint in der Unteransicht "Archiv", was aelter als 90 Tage ist, wird
|
|
-- geloescht. Beides ergibt sich aus `found_at` - es gibt bewusst keine
|
|
-- Zustandsspalte, die von einem Job gepflegt werden muesste und auseinander
|
|
-- laufen kann.
|
|
--
|
|
-- `converted_at` ist der Merker fuer "in eine Opportunity uebernommen". Er ist
|
|
-- die Wahrheit, nicht `opportunity_id`: wird die Opportunity spaeter geloescht,
|
|
-- faellt der Verweis auf NULL, der Eintrag soll aber nicht wieder im Eingang
|
|
-- auftauchen.
|
|
-- ===========================================================================
|
|
CREATE TABLE IF NOT EXISTS crm_acquisitions (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
found_at DATE NOT NULL, -- Spalte "Datum": wann gefunden
|
|
sender VARCHAR(190) NULL, -- "Von": wer es geschickt hat
|
|
provider VARCHAR(190) NULL, -- "Anbieter": Vermittler, Portal
|
|
channel VARCHAR(60) NULL, -- "Kanal": Mail, Telefon, Portal, ...
|
|
title VARCHAR(190) NOT NULL, -- Projekttitel
|
|
consultant VARCHAR(500) NULL, -- "Berater": ein Name oder mehrere, kommagetrennt
|
|
link VARCHAR(1024) NULL, -- Ausschreibung; macht den Titel klickbar
|
|
-- Ab hier: steht nicht in der Liste, wandert aber in die Opportunity.
|
|
company VARCHAR(190) NULL, -- Endkunde
|
|
description TEXT NULL,
|
|
daily_rate DECIMAL(10,2) NULL,
|
|
duration_days DECIMAL(6,1) NULL,
|
|
fixed_price DECIMAL(12,2) NULL,
|
|
start_date DATE NULL,
|
|
end_date DATE NULL,
|
|
next_step VARCHAR(500) NULL,
|
|
notes TEXT NULL,
|
|
tags LONGTEXT NULL, -- JSON-Array als Text, wie bei crm_leads
|
|
opportunity_id INT UNSIGNED NULL,
|
|
converted_at DATETIME NULL,
|
|
created_by INT UNSIGNED NULL,
|
|
deleted_at DATETIME NULL, -- im Papierkorb (UTC)
|
|
deleted_by INT UNSIGNED NULL, -- wer; bewusst ohne Fremdschluessel, s.u.
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_crm_acq_found (found_at),
|
|
KEY idx_crm_acq_converted (converted_at),
|
|
KEY idx_crm_acq_deleted (deleted_at),
|
|
CONSTRAINT fk_crm_acq_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_crm_acq_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Papierkorb der Akquise. Loeschen setzt `deleted_at`, die Zeile bleibt stehen -
|
|
* vorher war es ein echtes DELETE, und ein Fehlklick war damit endgueltig: der
|
|
* Eintrag tauchte in keiner Liste mehr auf, auch nicht im Archiv.
|
|
*
|
|
* Der Papierkorb hat seine eigene Uhr (`deleted_at`), nicht die des Funddatums:
|
|
* ein von Hand geloeschter Eintrag liegt 90 Tage dort, gleich wie alt der Fund
|
|
* ist. Der Aufraeumlauf ueber `found_at` laesst geloeschte Zeilen deshalb
|
|
* ausdruecklich liegen - sonst wuerde ein alter Fund im Papierkorb sofort
|
|
* verschwinden.
|
|
*
|
|
* `deleted_by` traegt bewusst keinen Fremdschluessel: Spalten lassen sich per
|
|
* ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht.
|
|
*/
|
|
ALTER TABLE crm_acquisitions ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER created_by;
|
|
ALTER TABLE crm_acquisitions ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at;
|
|
ALTER TABLE crm_acquisitions ADD INDEX IF NOT EXISTS idx_crm_acq_deleted (deleted_at);
|
|
|
|
/*
|
|
* Rueckweg von der Opportunity in die Akquise.
|
|
*
|
|
* `crm_acquisitions.opportunity_id` merkt sich nur die erste Opportunity einer
|
|
* Uebernahme - bei drei Beratern entstehen aber drei. Wer eine Uebernahme
|
|
* zuruecknimmt, muss alle finden, deshalb zeigt jede Opportunity selbst auf
|
|
* ihren Eintrag. Ohne Fremdschluessel, wie `deleted_by`: die Akquise loescht
|
|
* nach 90 Tagen, und ein ADD COLUMN IF NOT EXISTS laesst sich wiederholen.
|
|
*
|
|
* Das UPDATE traegt den Verweis fuer Uebernahmen von vor dieser Spalte nach -
|
|
* aber nur fuer die erste Opportunity, von den weiteren weiss die Datenbank
|
|
* nichts. Es fasst nur leere Felder an und ist damit bei jedem Start harmlos.
|
|
*/
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS acquisition_id INT UNSIGNED NULL AFTER consultant;
|
|
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_acquisition (acquisition_id);
|
|
UPDATE crm_opportunities o
|
|
JOIN crm_acquisitions a ON a.opportunity_id = o.id
|
|
SET o.acquisition_id = a.id
|
|
WHERE o.acquisition_id IS NULL;
|
|
|
|
/*
|
|
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
|
|
* Inhalte in den Einstellungen gepflegt werden.
|
|
*
|
|
* "Anbieter" gehoert ausdruecklich nicht dazu: dort wird frei getippt, mit
|
|
* Vorschlaegen aus den bisherigen Eintraegen. Neue Vermittler tauchen zu oft auf,
|
|
* als dass sich das Eintragen an einer gepflegten Liste aufhalten sollte.
|
|
*
|
|
* Bewusst eine Tabelle mit Feldkennzeichen statt drei Tabellen: die Felder
|
|
* verhalten sich gleich, und jede weitere Liste ist damit ein Wert im ENUM statt
|
|
* einer neuen Tabelle samt Route.
|
|
*
|
|
* Der Unique-Schluessel greift ueber die Kollation utf8mb4_unicode_ci auch bei
|
|
* abweichender Gross-/Kleinschreibung: "mail" ist neben "Mail" kein neuer Wert.
|
|
*/
|
|
/*
|
|
* Verdichteter Verlauf der Akquise. Jeder Eintrag in crm_acquisitions ist eine
|
|
* versendete Bewerbung; nach 90 Tagen wird er geloescht (siehe dort). Damit das
|
|
* Dashboard trotzdem ueber Jahre rechnen kann, wird vor dem Loeschen genau das
|
|
* gesichert, was die Auswertung braucht: Tag, Berater, Anzahl.
|
|
*
|
|
* Bewusst nur Zahlen, keine Projektdaten - "geloescht" bleibt damit auch
|
|
* geloescht. Der Primaerschluessel erlaubt kein NULL, deshalb steht fuer "ohne
|
|
* Berater" der leere Text.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_acquisition_history (
|
|
found_on DATE NOT NULL,
|
|
consultant VARCHAR(190) NOT NULL DEFAULT '',
|
|
applications INT UNSIGNED NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (found_on, consultant)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Verdichteter Verlauf der Akquise. Jeder Eintrag in crm_acquisitions ist eine
|
|
* versendete Bewerbung; nach 90 Tagen wird er geloescht (siehe dort). Damit das
|
|
* Dashboard trotzdem ueber Jahre rechnen kann, wird vor dem Loeschen genau das
|
|
* gesichert, was die Auswertung braucht: Tag, Berater, Anzahl.
|
|
*
|
|
* Bewusst nur Zahlen, keine Projektdaten - "geloescht" bleibt damit auch
|
|
* geloescht. Der Primaerschluessel erlaubt kein NULL, deshalb steht fuer "ohne
|
|
* Berater" der leere Text.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_acquisition_history (
|
|
found_on DATE NOT NULL,
|
|
consultant VARCHAR(190) NOT NULL DEFAULT '',
|
|
applications INT UNSIGNED NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (found_on, consultant)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
CREATE TABLE IF NOT EXISTS crm_acquisition_options (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
field ENUM('sender','channel','consultant') NOT NULL,
|
|
value VARCHAR(190) NOT NULL,
|
|
created_by INT UNSIGNED NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_acq_opt (field, value),
|
|
CONSTRAINT fk_crm_acq_opt_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|