Zielgruppen-Interna
Backend-Referenz für Kontaktauflösung, den Double-Opt-in-Lebenszyklus, Themen-Abonnements, die Conditions-Registry und die Segmentauswertung.
Dies ist die Entwicklerreferenz für die Zielgruppen-/Kontaktschicht des Convex-Backends: wie ein Kontakt gefunden oder angelegt wird, wie Double Opt-in (DOI) gesteuert wird, wie Themen-Mitgliedschaften geschrieben werden und wie Segmente und Automations-Bedingungen ausgewertet werden. Jeder Pfad der Art „gegeben ein Identifier, finde den Kontakt" und „passt dieser Kontakt" in der Codebase läuft über die hier beschriebenen Module. Die produktseitige Sicht auf diese Funktionen finden Sie unter Kontakte, Themen und Segmente.
Die Module liegen unter apps/api/convex/contacts/, apps/api/convex/topics/, apps/api/convex/conditions/ und apps/api/convex/lib/. Zu mehreren gibt es ein ADR; die ADRs sind die maßgebliche Designdokumentation, und die Quellkommentare zitieren sie über ihren Pfad (docs/adr/00xx-*.md).
Kontaktauflösung & -anlage
apps/api/convex/contacts/resolution.ts ist das eine Find-or-Create-Primitiv. Jeder Eingangspfad — eingehende E-Mail, Channel-Webhook, Massenimport, HTTP-API, Automations-Trigger, Formularübermittlung — erreicht einen Kontakt darüber.
Der Lookup ist einheitlich: Jeder Kontakt ist über contactIdentities.by_identifier (den (channel, identifier)-Index) geschlüsselt, nie über contacts.by_email. Für den Channel email wird contacts.email aus der primären Identitätszeile denormalisiert, damit alte Lesezugriffe auf contact.email weiter funktionieren, aber die Identitätszeile ist das Lookup-Primitiv. E-Mail-Identifier werden kleingeschrieben; telefonbasierte Channels (sms, whatsapp, phone) werden wortgetreu übernommen — die Aufrufer normalisieren zuvor auf E.164.
Der exportierte Helfer resolveContact(ctx, signal) (und seine Wire-Mutation resolve) verzweigt über mode:
| Modus | Bei Treffer | Ohne Treffer |
|---|---|---|
strict | wirft ALREADY_EXISTS | anlegen |
upsert | gibt die getroffene ID zurück, keine Feldaktualisierung | anlegen |
merge | patcht Felder, bei denen der neue Wert nicht leer ist (bei leer/undefined gewinnt der Bestand) | anlegen |
ResolveResult ist { contactId, action }, wobei action 'matched' | 'created' | 'updated' ist. Das Resolution-Modul ist bewusst effektfrei: Es besitzt beim Anlegen den Schreibvorgang der Identitätszeile, die Berechnung von searchableText und den Soft-Delete-Filter beim Lookup — aber es protokolliert keine Aktivität, fächert keine Automations-Trigger auf und pflegt nicht die Kontaktanzahl. Das bleibt bei den Aufrufern, verzweigt über action.
Channels und Quellen
Zwei Aufzählungen begrenzen die Eingaben (definiert in resolution.ts):
ChannelKind:email,sms,whatsapp,phone,generic,chat.ContactSource:api,import,form,transactional,inbound. Das wird zucontacts.sourceund wird in den Metadaten dercreated-Aktivität festgehalten.
Das Effektbündel beim Anlegen
Aufrufer, die einzeln anlegen, rufen resolveContact nicht direkt auf — sie gehen über createContact in apps/api/convex/contacts/creation.ts, das das effektfreie Primitiv umschließt und nur bei action === 'created' das einheitliche Created-Effektbündel auslöst:
incrementContactCount(ctx, 1)— hältcachedContactCountkorrekt.- Den Automations-Trigger
contact_created. - Eine
created-Kontaktaktivitätszeile, getaggt mitmetadata.source. - Ein
contact.created-Kunden-Webhook-Fanout überscheduleFanout(nur Channel E-Mail).
Das Modul für den Kontaktimport in großen Mengen ist die einzige Ausnahme: Es ruft resolveContact direkt auf und bündelt ein incrementContactCount pro Seite. Diese Effekte in dieser Schicht oberhalb des weiterhin effektfreien Primitivs zu halten, ist es, was die gebündelte Zählung des Imports und das Einzel-Anlege-Bündel ohne Doppelzählung koexistieren lässt.
Beim Soft Delete löscht deleteIdentitiesForContact jede contactIdentities-Zeile des Kontakts sofort hart (nicht erst nach dem 30-tägigen Aufbewahrungsfenster). Der Identifier — das datenschutzsensible Datum — wird ab dem ersten Tag wieder verwendbar, sodass das Anlegen eines frischen Kontakts für eine zuvor genutzte Adresse nie kollidiert.
Double-Opt-in-Lebenszyklus
apps/api/convex/contacts/doiLifecycle.ts ist der einzige Schreiber von contacts.doiStatus und dessen Begleitfeldern (doiConfirmationToken, doiTokenExpiresAt, doiConfirmedAt, doiAttestedSource). Es implementiert eine Drei-Zustands-Maschine mit diesen erlaubten Kanten:
| Von | Erlaubte Übergänge |
|---|---|
not_required | → pending |
pending | → confirmed |
confirmed | terminal (keine Kanten) |
Es gibt zwei Einstiegspunkte: transition (über contactId geschlüsselt) und transitionByConfirmationToken (über das URL-Token geschlüsselt, genutzt von den kundenseitigen Bestätigungs-Endpunkten). Beide geben ein TransitionOutcome zurück — Duplikate, unzulässige, terminale, abgelaufene und nicht gefundene Fälle werden gemeldet, nie geworfen:
reason | Bedeutung |
|---|---|
contact_not_found | Kein Kontakt zu dieser ID |
token_not_found | Kein Kontakt hält dieses Bestätigungstoken |
token_expired | Token liegt hinter doiTokenExpiresAt |
illegal_edge | Übergang aus dem aktuellen Zustand nicht erlaubt |
terminal | Bereits in einem Zustand ohne ausgehende Kanten |
Übergänge sind reducer-getrieben: Ein Reducer gibt { patch, effects, applied } zurück, und der Runner ist die einzige Stelle, die die DB und den Scheduler anfasst. Die Effekte sind:
send_confirmation_email— plant die Bestätigungs-E-Mail ein. Löst nur aus, wenn der Aufrufer einesiteUrlliefert und der Kontakt eineemailhat (Admin-Importe, die außerhalb der Reihe vorbestätigen, lassensiteUrlweg).fire_topic_subscribed_triggers— fächert zum Bestätigungszeitpunkt auf jede DOI-pflichtige Themen-Mitgliedschaft auf, die der Kontakt aktuell hält.contact_activity— einetopic_confirmed-Zeile pro DOI-pflichtiger Mitgliedschaft, plus einedoi_attested-Zeile auf dem Admin-Attest-Pfad.audit_log— löst nur auf dem Admin-Attest-Pfad aus (doi.admin_attested).
Tokens haben eine TTL von 7 Tagen (DOI_TOKEN_TTL_MS). Eine separate Operation, refreshPendingToken, erzeugt das Token neu und versendet die E-Mail erneut, ohne doiStatus zu ändern — sie verweigert mit not_pending, wenn der Kontakt aktuell nicht pending ist. Sie liegt in diesem Modul, damit jeder Schreibzugriff auf die DOI-Felder über eine Datei läuft.
Admin-Attest
Wenn ein Kontakt bei einer Quellplattform bereits DOI-bestätigt war (Mailchimp, Klaviyo, Stripe, eine vertrauenswürdige CSV), kann der Importpfad das außerhalb der Reihe attestieren. Die Admin-Attest-Variante ({ to: 'confirmed', source: 'admin_attest', attestSource }) lockert die sonst verweigerte Kante not_required → confirmed, hält doiAttestedSource fest und erzeugt den audit_log-Effekt plus die doi_attested-Aktivität. Der token-geschlüsselte Bestätigungspfad kann confirmed nie aus not_required erreichen.
Themen-Abonnement
apps/api/convex/topics/subscription.ts ist der einzige Schreiber von contactTopics. Es besitzt außerdem jede Pflege von topics.cachedMemberCount, das DOI-Gate beim Abonnieren und das quellenabhängige Effektbündel beim Abbestellen. Es gibt fünf Einstiegspunkte, nach Form geschlüsselt:
| Einstiegspunkt | Form |
|---|---|
subscribe / subscribeMany | ein Thema, ein oder mehrere Kontakte |
unsubscribe / unsubscribeMany | ein Thema, ein oder mehrere Kontakte |
unsubscribeAllForContact | ein Kontakt, ein Thema oder alle Themen |
SubscribeOutcome löst zu einem von subscribed, pending_doi oder already_member auf. Das Ergebnis pending_doi trägt das frisch geschriebene doiToken, damit ein Aufrufer, der eine Geschwisterzeile festhält (das formSubmissions.confirmationToken des Formularmoduls), den Kontakt nicht erneut lesen muss.
Das DOI-Gate beim Abonnieren
Beim Abonnieren ist DOI genau dann erforderlich, wenn topic.requireDoubleOptIn === true ist und der Aufrufer kein skipDoi übergeben hat. Steht DOI nicht im Weg (oder ist der Kontakt bereits confirmed), löst der Trigger topic_subscribed sofort aus. Andernfalls übergibt das Modul an transition(... to: 'pending') des DOI-Lebenszyklus und gibt pending_doi zurück — es löst den Trigger nicht selbst aus, denn der Effekt fire_topic_subscribed_triggers des Lebenszyklus deckt zum Bestätigungszeitpunkt jede DOI-pflichtige Mitgliedschaft auf einmal ab und würde sonst doppelt auslösen.
Quellenabhängige Unsubscribe-Effekte
Effekte pro Aufruf beim Abbestellen werden über die source des Unsubscribes gegatet. Diese Tabelle ist die einzige Stelle, an der „welche Seiteneffekte für welchen Auslöser feuern" steht (effectFlagsForUnsubscribeSource):
| Quelle | Formularbestätigungen löschen | Kampagnen-Unsub-Statistiken erhöhen | topic.unsubscribed-Webhook auslösen |
|---|---|---|---|
public_email_link | ja | ja | ja |
preferences_page | ja | nein | ja |
admin | nein | nein | nein |
public_api | nein | nein | nein |
Effekte pro Aufruf (das cachedMemberCount-Patch, contact.updatedAt, das Löschen der Formularbestätigungen, die Kampagnenstatistiken, der Webhook) feuern einmal pro Aufruf, unabhängig davon, wie viele Mitgliedschaften betroffen sind; Effekte pro Mitgliedschaft (das Löschen der Mitgliedschaftszeile und die topic_unsubscribed-Aktivitätszeile) feuern N-mal. unsubscribeAllForContact ist der Einstiegspunkt, den der öffentliche Abmeldelink nutzt; er gruppiert die Löschungen pro Thema, sodass der cachedMemberCount jedes Themas einmal gepatcht wird.
Conditions-Registry
apps/api/convex/conditions/ enthält die typisierte, austauschbare Registry, die sowohl Segmente als auch den Bedingungsschritt der Automation antreibt. Es gibt drei Bedingungsarten, jede ein ConditionTypeModule mit parseCondition / preloadLookup / preloadLookupForContacts / evaluate (conditions/types.ts). preloadLookup ist der Preload über die gesamte Population; preloadLookupForContacts ist die beschränkte Variante pro Kontakt, die der Einzelkontakt-Automationspfad und der seitenweise Segment-Builder nutzen:
| Art | Felder | Operatoren |
|---|---|---|
contact_property | jedes eingebaute Feld (email, firstName, lastName, source) oder ein eigener Property-Key | equals, not_equals, contains, not_contains, gt, lt, gte, lte, is_empty, not_empty, is_true, is_false |
email_activity | opened, clicked | is_true, is_false |
topic_membership | eine topicId | equals, not_equals |
Der Wert der Registry liegt in der Nahtstelle der gebündelten Auswertung (conditions/index.ts): Statt pro Kontakt erneut abzufragen, gruppiert preloadConditionsLookup die Bedingungen nach Art, übergibt jeden Batch an sein Modul und legt ein typisiertes, nach Art geschlüsseltes Lookup ab. (preloadConditionsLookupForContacts ist das beschränkte Geschwister, das für die Pfade pro Kontakt jeweils preloadLookupForContacts des Moduls antreibt.) Danach ist evaluateOne(condition, contact, lookup) eine pure O(1)-Prüfung. Die Preloads:
contact_propertylöst IDs eigener Properties über den Key auf und lädt danncontactPropertyValuesfür diese Properties vor; eingebaute Felder werden direkt vom Contact-Dokument gelesen. String-Operatoren sind Groß-/Kleinschreibung-unabhängig; numerische Operatoren konvertieren überNumber().email_activitywird direkt über die denormalisierten Flagscontact.hasOpened/contact.hasClickedausgewertet (gepflegt voncontactActivities/writer.ts); es hat kein vorgeladenes Lookup und führt keinen Scan durch — ein O(1)-Read auf der bereits geladenen Kontaktzeile.topic_membershipliestcontactTopics.by_topicfür jedes referenzierte Thema in ein Mitgliedschafts-Set.
parseCondition wirft bei einem Verstoß gegen Form, Operator oder Feld — Aufrufer behandeln einen Parse-Fehler als korrupte gespeicherte Daten, nicht als Nutzereingabe, denn Bedingungen werden zur Schreibzeit gegen das Storage validiert.
Segmentauswertung & die Listing-Engine
Die Schicht über der Auswertung einzelner Bedingungen — Filternormalisierung, die Kombination leer/UND/ODER und der Scan lebender Kontakte — liegt in apps/api/convex/conditions/segmentMatch.ts. Sie ist die alleinige Eigentümerin von „gleiche eine Kontaktpopulation gegen einen gespeicherten Filtersatz ab" und konsolidiert Logik, die zuvor über fünf offen ausgeschriebene Kopien auseinandergedriftet war. apps/api/convex/conditions/index.ts re-exportiert aus segmentMatch.ts eine rückwärtskompatible Oberfläche (evaluateCondition, evaluateSegmentCount, countLiveMatchesForSegments), die apps/api/convex/segments.ts über ./conditions importiert.
Das Modul hat zwei Schichten:
- Purer Kern —
parseSegmentFilters+makeSegmentPredicate. Diese werfen bei korrupten Filtern. Leere Bedingungen matchen jeden Kontakt; andernfalls werden Bedingungen mit kurzschließendem UND/ODER kombiniert. Das ist die Testoberfläche und die Nahtstelle, die der Versandpfad nutzt (er baut das Prädikat direkt, um die Eignungsfilterung in einem einzigen Durchlauf einzuflechten). - Nachsichtige asynchrone Bequemlichkeiten —
countLiveMatches,matchLiveContacts,countLiveMatchesForSegments. Diese backen den paginierten, Soft-Deletes ausschließendenby_deleted_at-Scan ein und behandeln korrupte Filter als null Treffer — die Haltung, die Vorschau-, Zähl- und Cron-Pfade wollen.
Ein Cron (crons.ts, alle 30 Minuten) ruft refreshAllSegmentCounts auf, um segments.cachedCount / cachedCountUpdatedAt für die Segmentliste in der UI frisch zu halten. countLiveMatchesForSegments wertet viele Segmente in einem Durchlauf aus: Es flacht die Bedingungen jedes Segments in ein einziges vorgeladenes Lookup ab und nutzt einen Kontakt-Scan wieder.
Die seitenweise, sortierbare Kontaktliste im Dashboard reitet über indizierte Reads auf der Resource-Listing-Engine (siehe ADR-010: Listing Engine und den Index by_deleted_at_and_created_at, in dem deletedAt führt, sodass soft-gelöschte Zeilen innerhalb des Index wegfallen). Segment-Matching ist eine andere Operation: beliebige Filterprädikate, die nicht auf einem einzelnen Index reiten können, also ein bewusst beschränkter Full-Table-Scan lebender Kontakte (siehe „Beschränkte Scans" weiter unten).
Überraschende Verhaltensweisen
Ein paar Verhaltensweisen bringen neue Leser ins Stolpern — sie sind beabsichtigt, aber erwähnenswert.
DOI ist standardmäßig erforderlich
Neue Themen haben standardmäßig requireDoubleOptIn: true (topics/topics.ts, args.requireDoubleOptIn ?? true). Ein Kontakt, der ein frisches Thema abonniert, landet daher in pending und muss bestätigen, es sei denn, die Betreiberin stellt das Thema ausdrücklich auf Single Opt-in oder der Aufrufer übergibt skipDoi. Neu angelegte Kontakte selbst starten bei doiStatus: 'not_required' — DOI wird auf Themenebene gegatet, angewendet beim Anlegen einer Mitgliedschaft, nicht beim Anlegen des Kontakts.
Das Feld doubleOptIn eines Formulars ist die Vereinigung von Formular- und Themensteuerung: Ist form.doubleOptIn === true, übergibt der Übermittlungspfad der Formulare (forms/submission.ts) forceDoi: true an topics.subscription.subscribe, und das Subscribe-Gate (topics/subscription.ts) verlangt DOI, wenn topic.requireDoubleOptIn === true || forceDoi === true (und skipDoi !== true). Ein Formular kann also selbst bei einem Single-Opt-in-Thema eine Bestätigung verlangen, es kann ein DOI-pflichtiges Thema aber nie aufweichen. Segment-Zielgruppen sind überhaupt nie DOI-gegatet.
Kontakte ohne E-Mail
contacts.email ist optional. Ein Kontakt, der über sms, whatsapp, phone oder generic hereinkam, hat überhaupt keine E-Mail. Identitäts-Lookups laufen über contactIdentities.by_identifier, sodass ein Kontakt ohne E-Mail über seinen Channel-Identifier vollständig adressierbar ist. Code, der eine E-Mail braucht, sichert sich entsprechend ab: Der topic.unsubscribed-Webhook fällt auf '' zurück, wenn ein Kontakt keine E-Mail hat (der Payload-Vertrag verlangt einen String), und ensureEmailIdentity ist bei Kontakten ohne E-Mail ein No-Op.
Beschränkte Scans
Segment-Matching ist ein beschränkter Full-Table-Scan, kein indizierter Read — ein beliebiges Filterprädikat kann nicht auf einem einzelnen Index reiten. segmentMatch.ts scannt lebende Kontakte in beschränkten Seiten zu 500 Zeilen über den auf deletedAt === undefined fixierten Index by_deleted_at (forEachLiveContact), sodass soft-gelöschte Zeilen innerhalb des Index wegfallen. (email_activity ist kein Scan mehr — es ist ein O(1)-Read auf den denormalisierten Flags contact.hasOpened / contact.hasClicked.) Behalten Sie das bei sehr großen Zielgruppen im Kopf — der Zähl-Cron amortisiert den Segment-Scan, und der Kampagnen-Versandpfad führt dasselbe Prädikat einmal pro Versand statt einmal pro Vorschau aus.