Kontakte-CRM — Konzept
Das Problem
Der Kontakte-Hub zeigt heute nur interne Personen — Mitglieder mit Prilog-Account (UserDirectoryEntry). Was fehlt:
- Eltern, die kein Prilog haben — Adresse, Handynummer, Notiz "ist beim Familiengericht"
- Externe Dienstleister — Hausmeister-Service, IT-Firma, Schulpsychologin
- Behörden-Ansprechpartner — Jugendamt-Sachbearbeiter, Schulamt
- Lieferanten — Schulbuch-Verlag, Catering, Reinigung
- Ehemalige Mitarbeiter — alte Kontakte die noch wichtig sind
- Kunden (für die Prilog-GmbH selbst): Firmen-Ansprechpartner, AVV-Kontakt
Heute landen die in einer Excel-Liste, einem Outlook-Adressbuch, oder im Kopf eines einzelnen Mitarbeiters. Bei Wechsel = Wissen weg.
Lösung: Eine "Kontakte"-App, die interne + externe vereinheitlicht
Die App heißt für Endnutzer "Kontakte" (kein Rebrand zu "CRM"), erweitert um eine zweite Rubrik:
Kontakte-Hub
├── Mitglieder (UserDirectoryEntry, wie heute)
└── Externe (NEU — ExternalContact)
├── Eltern-extern
├── Lieferanten
├── Behörden
├── Kunden
└── EhemaligeTabs oben: Alle / Mitglieder / Externe. Filter via Tags + Kategorie.
App-gated über enabledModules.has('contacts-crm') — Schulen, die nur den Mitglieder-Verzeichnis-Funktionsumfang brauchen, sehen die Externe-Verwaltung gar nicht.
Datenmodell
model ExternalContact {
id String @id @default(cuid()) @db.VarChar(50)
tenantId String @map("tenant_id") @db.VarChar(64)
/// Person oder Organisation — bei Organisation ist firstName=null.
kind String @default("person") @db.VarChar(20) // person | organization
firstName String? @map("first_name") @db.VarChar(100)
lastName String? @map("last_name") @db.VarChar(100)
fullName String? @map("full_name") @db.VarChar(200) // für Orgs
salutation String? @db.VarChar(20) // Herr/Frau/Dr./Prof. Dr.
title String? @db.VarChar(100) // Berufsbezeichnung
/// Verknüpfung zu Organisation (wenn kind=person und Person bei einer Firma arbeitet)
organizationId String? @map("organization_id") @db.VarChar(50)
organization ExternalContact? @relation("members", fields: [organizationId], references: [id])
members ExternalContact[] @relation("members")
/// Multi-Wert-Felder als JSON-Array damit "geschäftlich" und "privat"
/// nebeneinander leben können.
emails Json @default("[]") // [{ label: "geschäftlich", value: "x@y.de", primary: true }]
phones Json @default("[]") // [{ label: "mobil", value: "+49 …" }]
addresses Json @default("[]") // [{ label: "Büro", street, postalCode, city, country }]
websites Json @default("[]")
socials Json @default("[]") // LinkedIn, Mastodon, etc.
/// Custom-Felder pro Tenant (z.B. "AVV unterschrieben am") — als JSON
/// damit ohne Migration neue Felder hinzukommen.
customFields Json @default("{}")
notes String? @db.Text
/// Sichtbarkeit: privat (nur Ersteller), space (Spaces-IDs), tenant (alle)
visibility String @default("tenant") @db.VarChar(20)
visibilityScopes String[]
ownerUserId String @map("owner_user_id") @db.VarChar(255)
/// Soft-Delete + Aktivitäts-Tracking
deletedAt DateTime? @map("deleted_at")
lastTouchAt DateTime? @map("last_touch_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tags ExternalContactTag[]
activities ContactActivity[]
@@index([tenantId, kind])
@@index([tenantId, organizationId])
@@index([tenantId, deletedAt])
@@map("external_contacts")
}
model ExternalContactTag {
contactId String @map("contact_id") @db.VarChar(50)
tagId String @map("tag_id") @db.VarChar(50)
contact ExternalContact @relation(fields: [contactId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([contactId, tagId])
@@map("external_contact_tags")
}
/// Touchpoint-Historie: wann hatten wir mit dem Kontakt zuletzt zu tun?
/// Wird sowohl manuell ("Heute angerufen") als auch automatisch
/// (E-Mail aus Mein Fach an die Adresse → automatischer Eintrag) gefuettert.
model ContactActivity {
id String @id @default(cuid()) @db.VarChar(50)
contactId String @map("contact_id") @db.VarChar(50)
tenantId String @map("tenant_id") @db.VarChar(64)
kind String @db.VarChar(30) // call | email | meeting | note | document | task
occurredAt DateTime @default(now()) @map("occurred_at")
actorId String @map("actor_id") @db.VarChar(255)
summary String? @db.Text
/// Optional: Verknuepfung zu Document, WorkItem, Email — damit Klick im
/// Verlauf direkt zur Quelle springt.
referenceType String? @map("reference_type") @db.VarChar(50)
referenceId String? @map("reference_id") @db.VarChar(50)
contact ExternalContact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([contactId, occurredAt(sort: Desc)])
@@index([tenantId, kind, occurredAt(sort: Desc)])
@@map("contact_activities")
}Wiederverwendung: Tags + Visibility-Logik entstehen aus dem bestehenden Tag-System (DMS) und Document-Visibility (Phase 14). Kein neues Sub-System.
UI
Hub-Liste
Wie heute (linke Spalte = Liste, rechte Spalte = Detail), aber:
- Tab-Bar oben: Alle / Mitglieder / Externe
- Filter-Sidebar: Kategorien (Tags), Organisation-Filter, "nur kürzlich kontaktiert"
- Liste-Eintrag: Name + Org + primary Email + Touchpoint-Indikator ("vor 3 Tagen telefoniert")
- Bulk-Aktionen: Tag setzen, exportieren (vCard/CSV), löschen
Detail-Pane
Aufgeteilt in 3 Sektionen:
- Stammdaten — Name, Org, Adressen, E-Mails, Telefonnummern, Custom-Fields
- Verlauf — chronologisch: Anrufe, Mails, Dokumente, Aufgaben, Notizen
- Verknüpfungen — verlinkte Dokumente, offene Aufgaben mit diesem Kontakt als Verantwortlichem
Header-Aktionen: Anrufen (mailto:/tel:), E-Mail schreiben, Notiz hinzufügen, Aufgabe anlegen, Dokument anhängen.
Neuer Kontakt
Modal mit drei Stufen:
- Person oder Organisation?
- Stammdaten (Name + min. eine Kommunikations-Methode)
- Tags + Sichtbarkeit (Default: tenant-weit sichtbar)
Import/Export
- CSV-Import mit Spalten-Mapping-Wizard (wie Excel-Outlook-Export)
- vCard 4.0 (Import + Export pro Kontakt + bulk)
- Sync-Endpoints für CardDAV (Phase 2, später) — damit Apple/Thunderbird das Adressbuch lesen können
Backend-Endpunkte
| Methode | Pfad | Wirkung |
|---|---|---|
GET /platform/v1/external-contacts | Liste mit Filter (q, tags, kind, organizationId) | |
POST /platform/v1/external-contacts | Neuen Kontakt anlegen | |
GET /platform/v1/external-contacts/:id | Detail inkl. activities + members | |
PATCH /platform/v1/external-contacts/:id | Update | |
DELETE /platform/v1/external-contacts/:id | Soft-Delete | |
POST /platform/v1/external-contacts/:id/restore | Aus Papierkorb | |
POST /platform/v1/external-contacts/:id/activities | Manuelle Verlaufs-Eintrag | |
POST /platform/v1/external-contacts/import-csv | CSV-Bulk-Import (mehrteilig) | |
GET /platform/v1/external-contacts/:id/vcard | vCard 4.0 Export |
Berechtigungen: contacts:read / contacts:write / contacts:manage_visibility. Default: jedes Tenant-Mitglied darf lesen + schreiben (Schulen sind klein), Admin darf Sichtbarkeit ändern.
Auto-Touchpoint-Hooks
Zwei Stellen wo Aktivitäten ohne Eingabe entstehen:
- Mein Fach Postfach — eingehende/ausgehende Mail an eine bekannte Kontakt-Adresse →
ContactActivitymit kind=email - Aufgaben — Aufgabe mit
responsibleUserIdeiner external-Person (geht heute noch nicht, aber denkbar) → kind=task
Damit der Verlauf gepflegt wird, ohne dass jemand explizit Notizen schreibt.
App-Vermarktung
Im Plugin-Store:
- Name: Kontakte-Pro
- Icon:
contacts - Tagline: "Alle Adressen im Griff. Kunden, Eltern, Behörden, Lieferanten."
- Default-Status: eingeschaltet für Pro-Tenants, deaktiviert für Free
- Stripe-Item: Add-on, monatlich, eine Pauschale (kein Per-User da Verzeichnis tenant-weit ist)
Phasenplan
| Phase | Inhalt | Aufwand |
|---|---|---|
| A | Migration + Backend-CRUD + Liste/Detail | ~6h |
| B | Verlauf + Touchpoints (manuell) | ~3h |
| C | Verknüpfungen (Document, WorkItem) + Auto-Touchpoints | ~4h |
| D | CSV-Import + vCard-Export | ~3h |
| E | Sichtbarkeits-Matrix (privat/space/tenant) + Permission-Checks | ~3h |
| F | App-Store-Eintrag + Pricing | ~1h |
| G (später) | CardDAV-Sync für Apple/Thunderbird | ~12h |
MVP = A+B+D+F (~13h, an einem Tag machbar). C+E sind Komfort, G ist Phase 2.
Status 2026-05-07 LIVE: Phasen A+B+D+F deployed. Migration 0060, ExternalContact + ContactActivity Modelle, vCard 4.0 Export, CSV-Import- Wizard mit Auto-Header-Erkennung, App-Store-Eintrag contacts-crm mit featureFlag contacts_crm. Tab "Externe" im Kontakte-Hub erscheint nur wenn die App im Tenant aktiviert ist. Phasen C+E offen.
Offene Fragen
- Verschmelzen mit UserDirectoryEntry? Ein gemeinsames Contact-Modell, wo "ist Mitglied" nur ein Flag ist? — Riskant, weil UserDirectoryEntry an Matrix-Sync gekoppelt ist. Empfehlung: getrennte Tabellen, in der UI vereinheitlicht via
ContactView-Aggregator. - Mehrere E-Mail/Telefon-Felder als JSON oder separate Tabelle? JSON ist einfacher, separate Tabelle erlaubt Indexierung. Bei <50 Adressen pro Kontakt egal — JSON.
- Adress-Geocoding? Wenn Karte gewollt → später, nicht im MVP.
- Eltern-Verbindung zu Schülern? Heute über
Membership/UserType gelöst. Wenn ein externer Eltern-Kontakt verknüpft werden soll → braucht eigenenstudentRelations-Link. Phase 2.
Warum so?
- Eine App, zwei Quellen — User sehen alle Kontakte am gleichen Ort, egal ob Mitglied oder extern. Keine Kontextwechsel.
- JSON-Multi-Werte — vermeidet Schema-Inflation für E-Mail-Typen
- Verlauf-Aktivitäten — der CRM-Aspekt (was war wann) ist genauso wichtig wie die Stammdaten
- App-gated — Schulen ohne CRM-Bedarf zahlen nicht für ungenutzte Features, gleichzeitig hat Prilog-GmbH selbst sofort ihr Kunden-Verzeichnis im eigenen Produkt
- Verknüpfungen statt eigener Inbox — Kontakte verknüpfen sich mit Dokumenten/Aufgaben/E-Mails, statt nochmal eigenes Postfach zu bauen