-- 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;