Skip to content

ADR-0060: WhatsApp Business Cloud API, Feldkontakte ohne Web-Konto

Kontext

Feldmitarbeiter sollen morgens ihre Aufgaben bekommen und per Nachricht zurückmelden („erledigt“, „Problem“, „brauche Material“), inklusive Fotos und Sprachnachrichten. Sie haben kein Linetra-Konto und sollen keines brauchen. Die offizielle WhatsApp-API liest keine privaten Gruppen mit; inoffizielle Gruppen-Bots verstoßen gegen die Nutzungsbedingungen (Sperrrisiko).

Entscheidung

  1. WhatsApp Business Cloud API (Meta, direkt, kein BSP). Eine Business-Nummer pro Mandant. Nur 1:1-Chats, keine Gruppen.
  2. Identität = Absendernummer. wa_id wird über contact_identities(channel='whatsapp') auf eine Person (Stammdaten) aufgelöst. Unbekannte Nummern bekommen eine Standardantwort und landen als „unbekannter Kontakt“ in der Inbox; es wird nichts verarbeitet.
  3. Keine direkten Schreibrechte. Der Kontakt löst nur Vorschläge aus; die KI bereitet vor, das Backoffice gibt frei (ADR-0061), erst dann wird geschrieben. Der Kontakt bekommt eine Eingangsbestätigung und später das Ergebnis (bestätigt, Rückfrage des Backoffice, oder nichts bei Abweisung). Eine Nummer kann im Web jederzeit widerrufen werden (revoked_at), danach ist sie „unbekannt“.
  4. Scope eines Kontakts = Issues, die ihm im Tracker zugewiesen sind (tracker_issues.assignee_person_id), plus Projekte aus project_contacts. Keine Sicht auf fremde Aufgaben, kein Zugriff auf Fachdaten außerhalb dieser Projekte.
  5. Opt-in Pflicht. Ohne opt_in_at keine ausgehende Nachricht. Opt-in wird im Web dokumentiert (wer, wann, wie) und per Erstnachricht bestätigt.
  6. Conversation pro Kontakt + Projekt. conversations hält active_project_id, last_inbound_at (24-Stunden-Fenster) und einen Zustand. Hat ein Kontakt mehrere Projekte, fragt die App per Interactive List nach dem Projekt; die KI rät nicht.
  7. Ausgehend außerhalb des 24-Stunden-Fensters nur per Template (Kategorie Utility): daily_briefing, daily_report, clarification_request, review_result (Backoffice-Entscheidung oder Rückfrage kommt oft Stunden später, also außerhalb des Fensters). Innerhalb des Fensters Freitext und Interactive Buttons/Lists.
  8. Webhook-Verarbeitung: Verify-Handshake (hub.challenge), Signaturprüfung X-Hub-Signature-256 mit App-Secret, Idempotenz über wamid, sofort 202, alles Weitere in Celery (Queue sync, Übergabe an assistant).
  9. Kanal-Abstraktion messaging/ mit MessagingBackend-Protocol (send_text, send_template, send_interactive, download_media). WhatsApp ist der erste Adapter.
  10. Aufbewahrung: Nachrichteninhalte und Medien werden nach konfigurierbarer Frist (Default 180 Tage) gelöscht; Zuordnungen und Tracker-Kommentare bleiben.

Datenmodell (Tenant-DB)

CREATE TABLE project_contacts (
  project_id uuid NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
  person_id  uuid NOT NULL REFERENCES persons(person_id) ON DELETE CASCADE,
  role       text NOT NULL DEFAULT 'field',     -- 'field' | 'manager' (Empfänger Tagesbericht)
  PRIMARY KEY (project_id, person_id)
);

CREATE TABLE conversations (
  conversation_id   uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  person_id         uuid REFERENCES persons(person_id),   -- NULL = unbekannter Kontakt
  channel           text NOT NULL,                        -- 'whatsapp'
  external_id       text NOT NULL,                        -- wa_id
  active_project_id uuid REFERENCES projects(project_id),
  state             text NOT NULL DEFAULT 'idle',         -- 'idle' | 'awaiting_project' | 'awaiting_field_confirm' | 'awaiting_reply'
  state_payload     jsonb NOT NULL DEFAULT '{}',          -- z. B. pending_action_id (awaiting_reply = Backoffice hat zurückgeschickt)
  last_inbound_at   timestamptz,
  last_outbound_at  timestamptz,
  created_at        timestamptz NOT NULL DEFAULT now(),
  UNIQUE (channel, external_id, active_project_id)
);

CREATE TABLE messages (
  message_id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conversation_id uuid NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE,
  direction       text NOT NULL,                          -- 'in' | 'out'
  external_id     text UNIQUE,                            -- wamid (Idempotenz)
  kind            text NOT NULL,                          -- 'text' | 'audio' | 'image' | 'document' | 'interactive' | 'template'
  body            text,                                   -- Text oder Button-/List-Antwort
  transcript      text,                                   -- Whisper (audio)
  template_name   text,
  status          text,                                   -- out: 'sent' | 'delivered' | 'read' | 'failed'
  error           text,
  issue_id        uuid REFERENCES tracker_issues(issue_id),  -- finale Zuordnung (KI oder manuell)
  created_at      timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON messages (conversation_id, created_at);

CREATE TABLE message_attachments (
  attachment_id       uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  message_id          uuid NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE,
  file_id             uuid NOT NULL REFERENCES entity_files(file_id),
  media_sha256        text NOT NULL,
  tracker_attachment_id text,                             -- ADR-0062
  storage_path        text,                               -- ADR-0062
  UNIQUE (message_id, media_sha256)
);

CREATE TABLE briefing_runs (
  run_id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id  uuid NOT NULL REFERENCES projects(project_id),
  person_id   uuid NOT NULL REFERENCES persons(person_id),
  kind        text NOT NULL,                              -- 'daily_briefing' | 'daily_report'
  for_date    date NOT NULL,
  message_id  uuid REFERENCES messages(message_id),
  payload     jsonb NOT NULL,                             -- gerenderte Issue-Liste / Aggregation
  created_at  timestamptz NOT NULL DEFAULT now(),
  UNIQUE (project_id, person_id, kind, for_date)
);

Control-DB

-- phone_number_id aus dem Webhook-Payload → Mandant
CREATE TABLE messaging_channels (
  channel_id  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL REFERENCES tenants(tenant_id) ON DELETE CASCADE,
  provider    text NOT NULL,                              -- 'whatsapp'
  external_id text NOT NULL,                              -- phone_number_id
  UNIQUE (provider, external_id)
);

Begründung

Die Nummer ist die einzige Identität, die ein Feldmitarbeiter ohne Onboarding hat; Meta verifiziert sie, Spoofing innerhalb von WhatsApp ist praktisch ausgeschlossen. Trotzdem ist sie schwächer als ein Login (SIM-Wechsel, geteiltes Gerät) — deshalb schreibt der Kontakt nie direkt, sondern die App entscheidet mit Bestätigungsschleife (ADR-0061). Conversation pro Kontakt und Projekt hält die Fachdaten getrennt, ohne pro Projekt eine Nummer zu brauchen. Templates sind kein Komfort, sondern Meta-Pflicht außerhalb des 24-Stunden-Fensters; wer das ignoriert, dessen Briefing kommt nicht an.

Verworfene Alternativen

Alternative Warum verworfen
WhatsApp-Gruppe pro Projekt mit Bot Offizielle API liest Gruppen nicht ausreichend; inoffizielle Clients = Sperrrisiko
Business Solution Provider (Twilio, 360dialog) Kosten pro Nachricht ohne Mehrwert für 1:1; Cloud API ist direkt nutzbar
Nummer pro Projekt Kosten, Nummernverwaltung, Mitarbeiter müsste wissen, wem er schreibt
Web-Konten für Feldmitarbeiter Onboarding-Hürde, Passwortpflege; Nummer reicht als Identität bei App-seitiger Kontrolle
Linetra-App (Capacitor) statt WhatsApp Installationshürde; WhatsApp ist schon auf jedem Gerät. Die App bleibt für Erfassung (INF-07)
Signal / Threema Kein Business-API-Äquivalent mit Templates; später über MessagingBackend möglich
Fertige Inbox-Produkte (Whinta, WhatsTeam, n8n) Liefern Chat+Inbox, nicht „ein Chat = ein Projekt + Tool-Calls auf eigene Fachdaten“

Folgen

  • Meta Business-Verifizierung und Template-Freigabe früh starten (Tage bis Wochen).
  • Dedizierte Nummer; sie kann nicht parallel in der normalen WhatsApp-App genutzt werden.
  • Datenschutz: Meta als Auftragsverarbeiter, Telefonnummern von Mitarbeitern, Zeitstempel; Opt-in, Löschfrist, ggf. Betriebsrat. Die App erzwingt „kein Opt-in → keine Nachricht“.
  • Neue Routen /operations/inbox (Akte je Kontakt/Projekt, Chatverlauf, direkt antworten) und /operations/review (Freigabe-Queue, ADR-0061); Kontakt-Identitäten an /master-data/actors/[id].
  • Compose: optional whisper (faster-whisper) für Sprachnachrichten.
  • Site-Shell-Regel: neuer Primärbereich Betrieb (/operations/*) ist nachzuziehen.