Skip to content

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
    └── Ehemalige

Tabs 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

prisma
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:

  1. Stammdaten — Name, Org, Adressen, E-Mails, Telefonnummern, Custom-Fields
  2. Verlauf — chronologisch: Anrufe, Mails, Dokumente, Aufgaben, Notizen
  3. 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:

  1. Person oder Organisation?
  2. Stammdaten (Name + min. eine Kommunikations-Methode)
  3. 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

MethodePfadWirkung
GET /platform/v1/external-contactsListe mit Filter (q, tags, kind, organizationId)
POST /platform/v1/external-contactsNeuen Kontakt anlegen
GET /platform/v1/external-contacts/:idDetail inkl. activities + members
PATCH /platform/v1/external-contacts/:idUpdate
DELETE /platform/v1/external-contacts/:idSoft-Delete
POST /platform/v1/external-contacts/:id/restoreAus Papierkorb
POST /platform/v1/external-contacts/:id/activitiesManuelle Verlaufs-Eintrag
POST /platform/v1/external-contacts/import-csvCSV-Bulk-Import (mehrteilig)
GET /platform/v1/external-contacts/:id/vcardvCard 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:

  1. Mein Fach Postfach — eingehende/ausgehende Mail an eine bekannte Kontakt-Adresse → ContactActivity mit kind=email
  2. Aufgaben — Aufgabe mit responsibleUserId einer 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

PhaseInhaltAufwand
AMigration + Backend-CRUD + Liste/Detail~6h
BVerlauf + Touchpoints (manuell)~3h
CVerknüpfungen (Document, WorkItem) + Auto-Touchpoints~4h
DCSV-Import + vCard-Export~3h
ESichtbarkeits-Matrix (privat/space/tenant) + Permission-Checks~3h
FApp-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

  1. 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.
  2. Mehrere E-Mail/Telefon-Felder als JSON oder separate Tabelle? JSON ist einfacher, separate Tabelle erlaubt Indexierung. Bei <50 Adressen pro Kontakt egal — JSON.
  3. Adress-Geocoding? Wenn Karte gewollt → später, nicht im MVP.
  4. Eltern-Verbindung zu Schülern? Heute über Membership/UserType gelöst. Wenn ein externer Eltern-Kontakt verknüpft werden soll → braucht eigenen studentRelations-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