bel-crm-db
Uses the mcp postgresql to read and write crm relevant data to the crm database: Its about: sales_opportunity (Verkaufschancen) person (Kontaktperson im Unternehmen) company_site (Ein Standort eines Unternehmens) event (Aktivität, TODO, ... insgesamt bilden die Aktivitäten die Historie und die Zukunft von company_site, person und sales_opportunity ab) data_files (Dateien: PDFs, Bilder, E-Mail-Anhänge, Office-Dokumente - verknüpft mit CRM-Entitäten)
$ Instalar
git clone https://github.com/bennoloeffler/bassi /tmp/bassi && cp -r /tmp/bassi/.claude/skills/bel-crm-db ~/.claude/skills/bassi// tip: Run this command in your terminal to install the skill
name: bel-crm-db description: " Uses the mcp postgresql to read and write crm relevant data to the crm database: Its about: sales_opportunity (Verkaufschancen) person (Kontaktperson im Unternehmen) company_site (Ein Standort eines Unternehmens) event (Aktivität, TODO, ... insgesamt bilden die Aktivitäten die Historie und die Zukunft von company_site, person und sales_opportunity ab) data_files (Dateien: PDFs, Bilder, E-Mail-Anhänge, Office-Dokumente - verknüpft mit CRM-Entitäten)"
CRITICAL: SQL Limitations - READ FIRST!
Before writing ANY SQL, know these PostgreSQL MCP server limitations:
| FORBIDDEN | WHY | USE INSTEAD |
|---|---|---|
RETURNING | Syntax error | Query ID separately with read_query |
ON CONFLICT | Not supported | Check existence first, then INSERT or UPDATE |
| Multiple statements | Not allowed | One statement per tool call |
Example - CORRECT pattern for INSERT:
-- Step 1: write_query (NO RETURNING!)
INSERT INTO company_site (name, created_at, updated_at)
VALUES ('Acme GmbH', now(), now());
-- Step 2: read_query (get ID if needed)
SELECT id FROM company_site WHERE name = 'Acme GmbH' ORDER BY created_at DESC LIMIT 1;
For complete SQL rules, see the bel-crm-sql-rules skill.
You will probably only use those tools from postgresql mcp server:
- mcp__postgresql__read_query,
- mcp__postgresql__write_query
This is the SCHEMA you will work on:
Hier ist das vollständige Datenbankschema:
📊 Datenbank-Schema Übersicht
Die Datenbank enthält 6 Tabellen für ein CRM-System:
- adressen (Legacy-Adresstabelle)
Persönliche Kontaktdaten (deutschsprachig):
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | INTEGER | 🔑 Primary Key |
| vorname | VARCHAR | Vorname |
| nachname | VARCHAR | Nachname |
| strasse_hausnummer | VARCHAR | Straße & Hausnummer |
| plz | VARCHAR | Postleitzahl |
| ort | VARCHAR | Ort |
| VARCHAR | ||
| mobil | VARCHAR | Mobilnummer |
| tel | VARCHAR | Telefon |
| erstellt_am | TIMESTAMP | Erstellungsdatum |
| aktualisiert_am | TIMESTAMP | Aktualisierungsdatum |
- company_site (Unternehmensstandorte)
Firmeninformationen und Standorte:
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | INTEGER | 🔑 Primary Key |
| name | VARCHAR | ⚠️ NOT NULL - Firmenname |
| address_street | VARCHAR | Straße |
| address_city | VARCHAR | Stadt |
| address_state | VARCHAR | Bundesland |
| address_postal_code | VARCHAR | PLZ |
| address_country | VARCHAR | Land |
| industry | VARCHAR | Branche |
| website | VARCHAR | Website |
| linkedin_company_url | VARCHAR | LinkedIn-Profil |
| company_size | VARCHAR | Unternehmensgröße |
| annual_revenue | BIGINT | Jahresumsatz |
| notes | TEXT | Notizen |
| tags | JSONB | Tags (JSON) |
| created_at | TIMESTAMP | Erstellt am |
| updated_at | TIMESTAMP | Aktualisiert am |
- person (Kontaktpersonen)
Ansprechpartner in Unternehmen:
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | INTEGER | 🔑 Primary Key |
| name | VARCHAR | ⚠️ NOT NULL - Name |
| VARCHAR | ||
| phone | VARCHAR | Telefon |
| linkedin_url | VARCHAR | LinkedIn-Profil |
| company_site_id | INTEGER | 🔗 FK → company_site |
| job_title | VARCHAR | Jobtitel |
| department | VARCHAR | Abteilung |
| notes | TEXT | Notizen |
| tags | JSONB | Tags (JSON) |
| created_at | TIMESTAMP | Erstellt am |
| updated_at | TIMESTAMP | Aktualisiert am |
- sales_opportunity (Verkaufschancen)
Sales-Pipeline und Opportunities:
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | INTEGER | 🔑 Primary Key |
| title | VARCHAR | ⚠️ NOT NULL - Titel |
| value_eur | NUMERIC | Wert in EUR |
| probability | INTEGER | Wahrscheinlichkeit (%) |
| status | VARCHAR | Status (default: 'open') |
| description | TEXT | Beschreibung |
| expected_close_date | DATE | Erwarteter Abschluss |
| actual_close_date | DATE | Tatsächlicher Abschluss |
| person_id | INTEGER | 🔗 FK → person |
| company_site_id | INTEGER | 🔗 FK → company_site |
| source | VARCHAR | Quelle |
| competitors | TEXT | Wettbewerber |
| next_steps | TEXT | Nächste Schritte |
| notes | TEXT | Notizen |
| tags | JSONB | Tags (JSON) |
| created_at | TIMESTAMP | Erstellt am |
| updated_at | TIMESTAMP | Aktualisiert am |
- event (Aktivitäten/Events)
Aktivitätsverlauf (Meetings, Calls, etc.):
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | INTEGER | 🔑 Primary Key |
| type | VARCHAR | ⚠️ NOT NULL - Event-Typ |
| description | TEXT | ⚠️ NOT NULL - Beschreibung |
| event_date | TIMESTAMP | ⚠️ NOT NULL - Event-Datum |
| person_id | INTEGER | 🔗 FK → person |
| company_site_id | INTEGER | 🔗 FK → company_site |
| opportunity_id | INTEGER | 🔗 FK → sales_opportunity |
| metadata | JSONB | Zusatzdaten (JSON) |
| created_at | TIMESTAMP | Erstellt am |
- data_files (Dateien & E-Mail-Anhänge)
Speichert Dateien (PDFs, Bilder, Office-Dokumente, E-Mail-Anhänge) mit Base64-Encoding:
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | INTEGER | 🔑 Primary Key |
| person_id | INTEGER | 🔗 FK → person |
| company_site_id | INTEGER | 🔗 FK → company_site |
| event_id | INTEGER | 🔗 FK → event |
| sales_opportunity_id | INTEGER | 🔗 FK → sales_opportunity |
| filename | VARCHAR | ⚠️ NOT NULL - Dateiname (oder E-Mail-Subject) |
| file_type | VARCHAR | ⚠️ NOT NULL - pdf, image, docx, pptx, xlsx, email, other |
| mime_type | VARCHAR | MIME-Type (z.B. application/pdf, message/rfc822) |
| file_size_bytes | BIGINT | Dateigröße in Bytes (max 100MB) |
| file_hash | VARCHAR | SHA-256 Hash (Duplikat-Erkennung) |
| source | VARCHAR | ⚠️ NOT NULL - email_attachment, user_upload, agent_download, email_message |
| source_email_id | VARCHAR | MS365 Message ID |
| source_path | VARCHAR | Originaler Dateipfad |
| file_data | TEXT | Base64-kodierter Dateiinhalt (NULL bei E-Mails ohne Anhang) |
| email_metadata | JSONB | E-Mail-Metadaten (from, to, cc, subject, date, importance) |
| email_body_text | TEXT | E-Mail-Body als Plain Text |
| email_body_html | TEXT | E-Mail-Body als HTML |
| extracted_text | TEXT | Extrahierter Text (für Volltextsuche) |
| extraction_method | VARCHAR | pdf_text, ocr, docx_parse, email_body, etc. |
| extraction_status | VARCHAR | pending, completed, failed, skipped |
| extraction_error | TEXT | Fehlermeldung bei Extraktion |
| description | TEXT | Benutzer-Beschreibung |
| tags | JSONB | Tags (JSON) |
| created_at | TIMESTAMP | Erstellt am |
| updated_at | TIMESTAMP | Aktualisiert am |
Beispiel - Komplette E-Mail speichern (mit Body):
INSERT INTO data_files (
person_id, filename, file_type, mime_type,
source, source_email_id, email_metadata,
email_body_text, email_body_html,
extracted_text, extraction_method, extraction_status
) VALUES (
1,
'Re: Contract Draft', -- Subject als filename
'email',
'message/rfc822',
'email_message',
'AAMkAGI2TG93AAA=',
'{
"subject": "Re: Contract Draft",
"from": {"name": "John Doe", "email": "john@example.com"},
"to": [{"email": "you@company.com"}],
"cc": [{"email": "legal@company.com"}],
"received_at": "2025-01-15T10:30:00Z",
"importance": "high",
"hasAttachments": true,
"conversationId": "AAQkAGI2..."
}'::jsonb,
'Hallo, anbei der überarbeitete Vertragsentwurf...', -- Plain text
'<html><body><p>Hallo, anbei der überarbeitete Vertragsentwurf...</p></body></html>',
'Hallo, anbei der überarbeitete Vertragsentwurf...', -- Für Suche
'email_body',
'completed'
) RETURNING id, filename;
Beispiel - E-Mail-Anhang speichern (verknüpft mit E-Mail):
-- Erst E-Mail speichern, dann Anhänge mit gleicher source_email_id
INSERT INTO data_files (
person_id, filename, file_type, mime_type, file_size_bytes,
source, source_email_id, file_data, email_metadata
) VALUES (
1, 'contract.pdf', 'pdf', 'application/pdf', 245678,
'email_attachment', 'AAMkAGI2TG93AAA=', -- Gleiche ID wie E-Mail!
'<base64-encoded-content>',
'{"subject": "Re: Contract Draft", "from": {"email": "john@example.com"}}'::jsonb
) RETURNING id, filename;
Alle Dateien einer E-Mail finden (E-Mail + Anhänge):
SELECT id, filename, file_type, source, file_size_bytes
FROM data_files
WHERE source_email_id = 'AAMkAGI2TG93AAA='
ORDER BY file_type = 'email' DESC, filename;
Beispiel - Datei aus _DATA_FROM_USER hochladen:
INSERT INTO data_files (
company_site_id, filename, file_type, mime_type, file_size_bytes,
source, source_path, file_data
) VALUES (
5, 'proposal.pdf', 'pdf', 'application/pdf', 512000,
'user_upload', '_DATA_FROM_USER/proposal.pdf',
'<base64-encoded-content>'
) RETURNING id, filename;
Volltextsuche in extrahiertem Text:
SELECT id, filename, person_id, company_site_id
FROM data_files
WHERE to_tsvector('german', extracted_text) @@ to_tsquery('german', 'Vertrag & Angebot');
- schema_migrations (System-Tabelle)
Datenbank-Migrationen:
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | BIGINT | Migrations-ID |
| applied | TIMESTAMP | Ausführungsdatum |
| description | VARCHAR | Beschreibung |
🔗 Beziehungen (Foreign Keys)
company_site (1) ──┬── (N) person ├── (N) sales_opportunity ├── (N) event └── (N) data_files
person (1) ────────┬── (N) sales_opportunity ├── (N) event └── (N) data_files
sales_opportunity (1) ─┬── (N) event └── (N) data_files
event (1) ──────────── (N) data_files
⚠️ WICHTIG: KEINE UNIQUE CONSTRAINTS AUF name!
Die Tabellen haben KEINE UNIQUE-Constraints auf name:
- company_site.name ist NICHT unique
- person.name ist NICHT unique
- sales_opportunity.title ist NICHT unique
NIEMALS ON CONFLICT (name) verwenden! Das führt zu Fehlern!
Stattdessen:
- ERST prüfen ob Eintrag existiert:
SELECT id FROM company_site WHERE name ILIKE '%..%' - DANN entweder UPDATE (wenn gefunden) oder INSERT (wenn nicht gefunden)
Beispiel - Firma erstellen oder finden:
-- SCHRITT 1: Prüfen ob existiert
SELECT id, name FROM company_site WHERE name ILIKE '%Kölner Stadt%' LIMIT 1;
-- SCHRITT 2a: Falls gefunden → UPDATE
UPDATE company_site SET updated_at = CURRENT_TIMESTAMP, notes = 'Updated...' WHERE id = <gefundene_id>;
-- SCHRITT 2b: Falls nicht gefunden → INSERT
INSERT INTO company_site (name, address_city, created_at, updated_at)
VALUES ('Neue Firma GmbH', 'Berlin', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id;
💡 Besonderheiten
- JSONB-Felder: tags, metadata, email_metadata für flexible Erweiterungen
- Timestamps: Auto-Update via CURRENT_TIMESTAMP
- Dual-System: Alte adressen-Tabelle + neues CRM-Schema
- Multi-Entity Events: Events können mit Person, Firma ODER Opportunity verknüpft sein
- Dateispeicherung: Base64-kodiert in TEXT-Feld (max 100MB)
- Volltextsuche: GIN-Index auf extracted_text für deutsche Suche
- Duplikat-Erkennung: SHA-256 Hash auf file_hash
📎 Datei-Workflows
E-Mail-Anhänge extrahieren (MS365)
-
E-Mails suchen:
mcp__ms365__list-mail-messages mit filter oder search -
Anhänge auflisten:
mcp__ms365__list-mail-attachments(messageId) -
Anhang-Inhalt holen (bereits Base64!):
mcp__ms365__get-mail-attachment(messageId, attachmentId) → contentBytes ist bereits Base64-kodiert -
In CRM speichern:
INSERT INTO data_files (person_id, filename, file_type, source, file_data, email_metadata) VALUES (...) RETURNING id;
User-Upload aus _DATA_FROM_USER
- Datei lesen und Base64-kodieren (in Python/Node)
- SHA-256 Hash berechnen für Duplikat-Check
- In CRM mit source = 'user_upload' speichern
Text-Extraktion
| Dateityp | Methode | extraction_method |
|---|---|---|
| Text-Extraktion (pypdf) | pdf_text | |
| PDF (gescannt) | OCR (pytesseract) | pdf_ocr |
| Bild | OCR (pytesseract) | ocr |
| DOCX | python-docx | docx_parse |
| PPTX | python-pptx | pptx_parse |
| XLSX | openpyxl | xlsx_parse |
Repository
