ADR-0060: WhatsApp Business Cloud API, Feldkontakte ohne Web-Konto
- Status: proposed, teilweise ersetzt durch ADR-0069 (
project_contactsund Projektauflösung) - Datum: 2026-10-02
- Phase: Prozessmanagement
- Bezug: ADR-0059, ADR-0061, ADR-0031, ADR-0052
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
- WhatsApp Business Cloud API (Meta, direkt, kein BSP). Eine Business-Nummer pro Mandant. Nur 1:1-Chats, keine Gruppen.
- Identität = Absendernummer.
wa_idwird übercontact_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. - 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“. - Scope eines Kontakts = Issues, die ihm im Tracker zugewiesen sind (
tracker_issues.assignee_person_id), plus Projekte ausproject_contacts. Keine Sicht auf fremde Aufgaben, kein Zugriff auf Fachdaten außerhalb dieser Projekte. - Opt-in Pflicht. Ohne
opt_in_atkeine ausgehende Nachricht. Opt-in wird im Web dokumentiert (wer, wann, wie) und per Erstnachricht bestätigt. - Conversation pro Kontakt + Projekt.
conversationshältactive_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. - 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. - Webhook-Verarbeitung: Verify-Handshake (
hub.challenge), SignaturprüfungX-Hub-Signature-256mit App-Secret, Idempotenz überwamid, sofort202, alles Weitere in Celery (Queuesync, Übergabe anassistant). - Kanal-Abstraktion
messaging/mitMessagingBackend-Protocol (send_text,send_template,send_interactive,download_media). WhatsApp ist der erste Adapter. - 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.