Übersicht
Bei einem Anruf in Microsoft Teams sieht die Person heute nicht, wer anruft. Diese Spezifikation
legt den Proof of Concept der Anrufinformation fest: eine Seite von vd:suite für ConMeet in der
Seitenleiste des Teams-Gesprächs, die zur Rufnummer des Anrufers die passenden Kontakte aus der
aktiven ConMeet-Instanz der Person zeigt. Die Person wählt selbst, welche ihrer berechtigten
Instanzen aktiv ist. Die Spezifikation verarbeitet personenbezogene Daten (Rufnummern, Namen,
E-Mail-Adressen) und ist deshalb sensitive.
Verhältnis zu anderen Dokumenten
- User Stories — setzt US-01 und US-12 um; US-03 nur für „in ConMeet öffnen“; aus US-09 nur die Anmeldung beim ersten Öffnen (Abschnitt 2).
- 001 — ConMeet-Instanzen, Berechtigungen und Kontoverknüpfung — Instanz, Berechtigung (R22), Kontoverknüpfung (R30, R62), Prüfzustände (R63, R64), Schlüssel (R52). Verweise der Form „001-R30“ meinen Anforderungen dort.
- Releaseblocker — RB-001: Abruf aller Kontakte je Anruf.
- Architektur — BFF-Schnitt und Sitzungscookie, die Abschnitt 2 erweitert.
Ziele
- In einem eingehenden 1:1-Anruf in Teams zeigt die Anrufinformation die Kontakte aus ConMeet, die zur Rufnummer des Anrufers gehören, und öffnet ihren Kunden in ConMeet.
- Die Person meldet sich in Teams einmal mit ihrem Suite-Benutzer an und bleibt angemeldet, solange
ihre Teams-Sitzung gilt (gleitend wie
__Host-session); sie kann sich dort auch abmelden. - Die Anrufinformation sucht nur in der aktiven Instanz der Person; die Person schaltet sie selbst um und sieht bei mehreren Instanzen, woher die Daten stammen.
- Fehlende Berechtigung, fehlende Verknüpfung und fehlende Rufnummer zeigt die Anrufinformation als Hinweis mit dem nächsten Schritt statt eines Fehlers.
- Der Abruf aus ConMeet überschreitet nie die festgelegte Zahl der Aufrufe je Sekunde und Instanz.
Scope
Diese Spezifikation definiert:
- die Teams-App (Manifest, Seite in der Seitenleiste, Einbettung, Rufnummer aus Teams),
- die Anmeldung mit dem Suite-Benutzer innerhalb von Teams,
- die aktive ConMeet-Instanz je Person, ihre automatische Wahl und das Umschalten,
- die Zustände der Anrufinformation,
- die Kontaktsuche nach Rufnummer über die ConMeet Public API samt Begrenzung der Aufrufe,
- die Anzeige der Treffer und den Link nach ConMeet,
- GraphQL-Operationen und Oberfläche dafür.
Nicht im Scope:
- Verteilen und Anheften der App für alle Mitarbeitenden über Teams-Store und Einrichtungsrichtlinie (US-09) — im Proof of Concept wird die App als benutzerdefinierte App hochgeladen; die Anmeldung aus US-09 regelt Abschnitt 2,
- weitere Angaben zum Kontakt, der Kunde als eigene Anzeige und die Anzeige der getroffenen Nummer (US-17, F-028),
- andere Telefonanlagen als Teams (US-18),
- Besprechungen, Gruppenanrufe und ausgehende Anrufe — Teams liefert dort keine oder keine verlässliche Nummer; es gilt R9,
- E-Mail an den Kontakt und jeder andere Verweis aus US-03 außer R60,
- Kontakte von Lieferanten (
/suppliers/{id}/contacts), - andere Teams-Clients als die aus R101, etwa Teams für macOS,
- eine produktive Suche ohne Abruf aller Kontakte (RB-001).
Spezifikation
1. Teams-App
R1 Die Anrufinformation ist die Seite /teams/call des BFF (source/conmeet-webapp) in einer
eigenen Routing-Gruppe ohne die Seitenleiste und Kopfzeile der übrigen Anwendung.
R2 Das Teams-App-Manifest liegt im Repository neben dem BFF und deklariert /teams/call als
staticTab mit Scope personal und Kontext personalTab und meetingSidePanel. Ein Skript
erzeugt daraus das hochladbare Paket mit der Adresse der Umgebung. Vorbild ist das Manifest des
Prototyps (Q4), mit dem Teams im 1:1-Anruf die Rufnummer an die persönlich installierte App liefert.
R102 validDomains des Manifests enthält den Host des BFF und den Host von Keycloak der
jeweiligen Umgebung (lokal localhost:8091, entfernt der Host aus Parameters:suite-oidc-authority);
das Skript aus R2 setzt beide ein.
R101 Unterstützt sind im Proof of Concept Teams für Windows und Teams im Web mit Edge oder
Chrome. Nach erfolgreichem Tausch (R15) lädt die Seite die Daten des Layouts neu, ohne die Seite neu
zu laden (invalidateAll). Erkennt der Layout-Load dabei keine Sitzung, kam das Cookie
__Host-teams-session nicht an; die Anrufinformation zeigt dann statt R11 „Dieser Teams-Client wird
für die Anrufinformation noch nicht unterstützt. Bitte nutzen Sie Teams für Windows oder Teams im
Browser Edge oder Chrome.“ und bietet „Anmelden“ weiter an.
R3 Das Manifest fordert die ressourcenspezifischen Berechtigungen OnlineMeeting.ReadBasic.Chat
und OnlineMeetingParticipant.Read.Chat an; ohne sie liefert Teams die Rufnummer nicht.
R4 Nur /teams/call erlaubt die Einbettung, und nur durch Teams-Hosts
(Content-Security-Policy: frame-ancestors mit https://teams.microsoft.com,
https://*.teams.microsoft.com, https://*.cloud.microsoft, https://*.office.com).
R5 Alle anderen Routen, auch /teams/auth/*, sind nicht einbettbar (frame-ancestors 'none').
R6 Die Seite bindet @microsoft/teams-js im Browser ein und liest die Rufnummer des Anrufers
aus meeting.getMeetingDetailsVerbose() unter details.originalCallerInfo.phoneNumber. Die
Methode ist in Teams als Beta markiert; AGENTS.md führt sie als genehmigte Ausnahme.
R7 Die Seite folgt dem Farbschema von Teams: default hell, dark und contrast dunkel, und
wechselt mit, wenn Teams das Schema ändert.
R8 Die Seite ist für eine Breite ab 320 px einspaltig gestaltet; nichts scrollt waagrecht.
R9 Liefert Teams keine Rufnummer — Besprechung, Gruppenanruf, Aufruf außerhalb eines Anrufs, Aufruf außerhalb von Teams oder ein Fehler der Teams-Schnittstelle —, zeigt die Anrufinformation „Keine Rufnummer verfügbar“ und das Feld aus R77 leer.
R10 Eine eingegebene Rufnummer durchläuft dieselbe Suche wie eine aus Teams (Abschnitt 5).
R77 Ab Zustand 6 aus R33 steht oben in der Anrufinformation, unter der Auswahl aus R24, das
Feld „Rufnummer“ mit der Aktion „Suchen“, vorbelegt mit der Nummer aus Teams; in den Zuständen 1
bis 5 fehlt es. Die Person kann jede andere Nummer eingeben und suchen. Antwortet der Server auf
eine eingegebene Nummer mit INVALID_PHONE_NUMBER, steht die Meldung aus R65 am Feld und das übrige
Ergebnis bleibt unverändert; für eine Nummer aus Teams gilt nur R41.
R78 Das Feld aus R77 ist eine Hilfe für den Proof of Concept. Es ist ein eigenständiger Baustein, der nur eine Rufnummer an die Suche übergibt; Suche, Zustände (R33) und Schnittstelle (Abschnitt 8) hängen nicht von ihm ab. Entfällt er, zeigt R9 nur noch den Hinweis.
R79 Bei Treffern steht die gesuchte Rufnummer über der Liste, in der gelieferten oder eingegebenen Schreibweise.
2. Anmeldung in Teams
R11 Ist im eingebetteten Fenster keine Sitzung vorhanden, zeigt die Anrufinformation „Melden Sie sich mit Ihrem vd:suite-Konto an“ und die Schaltfläche „Anmelden“. Sie leitet nie im eingebetteten Fenster zu Keycloak weiter.
R94 Außerhalb von Teams (die Teams-Schnittstelle meldet keinen Teams-Host) führt „Anmelden“ das
Fenster selbst zu /auth/start?returnTo=/teams/call; es entsteht eine Webseiten-Sitzung, die
/teams/call dort nach R105 nutzt. R12 bis R15 entfallen außerhalb von Teams.
R12 „Anmelden“ öffnet über authentication.authenticate() von Teams ein eigenes Fenster mit
/teams/auth/start. Dort läuft ein Code Flow mit PKCE gegen den Realm vd-suite mit dem eigenen
Client der Teams-App (R96); Rückkehr über /teams/auth/callback. Die Anmeldung der Webseite
(/auth/*, __Host-session) ist nicht beteiligt; im Anmeldefenster entsteht keine Sitzung.
R96 Die Teams-App hat in Keycloak einen eigenen vertraulichen Client vd-suite-conmeet-teams
mit der Redirect-URI /teams/auth/callback. Audience und Scope sind wie beim Client der Webseite
vd-suite-conmeet-api; der Dienst nimmt Tokens beider Clients an. Der Client steht in der lokalen
Realm-Replik und wird für jede Suite-Umgebung bei Core angefragt; sein Secret kommt wie das des
Webseiten-Clients aus der Konfiguration, mit entfernter Suite aus
Parameters:suite-oidc-teams-client-secret (AGENTS.md).
R97 Jede Sitzung des BFF trägt den Client, für den sie angelegt wurde (web oder teams).
Eine Teams-Sitzung ist unabhängig von einer Webseiten-Sitzung derselben Person: Abmelden oder
Sitzungsende auf der einen Seite beendet die andere nicht.
R89 Vor R12 erzeugt das eingebettete Fenster einen Zufallswert state (32 Bytes), behält ihn im
Speicher der Seite und übergibt ihn base64url-kodiert (43 Zeichen) als Parameter an
/teams/auth/start. Bis /teams/auth/callback gelangt nur sein Hash (R98).
R98 /teams/auth/start legt den Hash von state zusammen mit PKCE-Verifier und einem eigenen,
neuen OIDC-state in frontend.oauth_states ab und leitet nur mit diesem eigenen Wert zu Keycloak
weiter. Der state aus R89 steht in keiner weiteren URL; /teams/auth/callback findet ihn über den
OIDC-state in dieser Zeile. Request-Logs des BFF enthalten keine Query-Strings von /teams/auth/*.
R99 Ohne state oder mit einem state, der nicht base64url-kodiert 32 Bytes ergibt, leitet
/teams/auth/start nicht weiter und zeigt „Die Anmeldung ist nur aus der Anrufinformation in Teams
möglich.“ Findet /teams/auth/callback keine passende Zeile oder scheitert der Code Flow, entsteht
kein Code; das Fenster meldet authentication.notifyFailure(), und es gilt R17.
R100 Der Einmalcode steht nur im Skript der Seite /teams/auth/callback, das ihn an
authentication.notifySuccess() übergibt; nie in einer URL, einem Log oder im Browser-Speicher.
Öffnet sich die Seite ohne Anmeldekontext von Teams, verwirft das Skript den Code und zeigt R99.
Wer state kennt, aber nicht den Code, kann nichts eintauschen (R15).
R13 Nach erfolgreicher Anmeldung erzeugt /teams/auth/callback im Anmeldefenster einen
Einmalcode und übergibt ihn mit authentication.notifySuccess() an das eingebettete Fenster.
R14 Der Einmalcode besteht aus 32 Zufallsbytes, liegt nur als Hash in der neuen Tabelle
frontend.teams_auth_codes und gilt 60 Sekunden und genau einmal. Zu ihm gehören der Hash von state aus R89, die Identität
aus dem ID-Token (sub) und die Tokens aus dem Code Flow, AES-256-GCM-verschlüsselt wie in
oauth_tokens. Eine Sitzung existiert zu diesem Zeitpunkt noch nicht.
R15 Das eingebettete Fenster tauscht den Code zusammen mit seinem state per POST an
/teams/auth/exchange ein; der Server prüft, dass state zum Code gehört. Erst dann legt er eine
neue Sitzung für den Client teams an, übernimmt die Tokens des Codes in sie, löscht den Code und
setzt das Cookie __Host-teams-session mit dem neu erzeugten Sitzungstoken: HttpOnly, Secure,
SameSite=None, Partitioned, Path=/, gleiche Laufzeit und gleiche gleitende Verlängerung wie
__Host-session.
R91 __Host-teams-session gilt nur für eine Sitzung des Clients teams und nur für die
Anrufinformation (R105); jede andere Route und Remote Function erkennt nur __Host-session einer
Sitzung des Clients web. Die Prüfung der Herkunft (Origin) von SvelteKit für Formulare und
Remote Functions bleibt eingeschaltet.
R105 Der Server-Hook legt beide Sitzungen getrennt ab: locals.session nur aus __Host-session
(Client web), locals.teamsSession nur aus __Host-teams-session (Client teams). Die
Sitzung der Anrufinformation ist locals.teamsSession, ohne sie locals.session (außerhalb von
Teams, R94). Sie nutzen ausschließlich:
- die Routen und der Layout-Load von
(teams), auchmemit der Prüfung nach WP4 (R95), - die Remote Functions unter
src/lib/features/call-information/, auch die für Verknüpfen und Schlüssel ersetzen (R34, R36), die dieselben GraphQL-Operationen wie 001 aufrufen, - GraphQL-Client und Token-Erneuerung für diese Aufrufe.
Aller übrige Code liest nur
locals.session. Remote-Function-Anfragen leitet der Hook nie zur Anmeldung weiter; ohne Sitzung antworten sie nach R74.
R90 /teams/auth/exchange nimmt nur POST-Anfragen mit dem Origin des BFF an. Abgelaufene oder
eingelöste Codes löscht der Server bei jedem Erzeugen und Einlösen eines Codes.
R17 Ein ungültiger, abgelaufener oder schon eingelöster Code oder ein unpassender state setzt
kein Cookie; die
Anrufinformation zeigt wieder R11 mit der Meldung „Die Anmeldung ist fehlgeschlagen. Bitte erneut
anmelden.“
R18 Bricht die Person die Anmeldung ab oder schließt das Fenster, bleibt R11 ohne Meldung stehen.
R19 Ist die Person angemeldet, aber nicht READY (NOT_PROVISIONED, NOT_LICENSED), zeigt die
Anrufinformation nur den Hinweistext der jeweiligen Zustandsseite (/not-provisioned,
/not-licensed) und den Link „Im Browser öffnen“ auf diese Seite (R80); sie sucht nicht.
R72 /teams/call, /teams/auth/start, /teams/auth/callback und /teams/auth/exchange liegen in
einer eigenen Routing-Gruppe (teams) mit eigenem Layout, nicht in (authenticated).
R73 Für (teams) gilt die allgemeine Weiterleitung zur Anmeldung nicht, ebenso wenig die
Weiterleitungen des (authenticated)-Layouts auf die Zustandsseiten; /teams/call zeigt R11, R19
und R94 selbst.
R95 Das Layout von (teams) lädt bei vorhandener Sitzung me wie (authenticated) (Zustand,
Berechtigungen, Prüfung auf denselben Core-Benutzer nach WP4). Erst bei READY ruft die
Anrufinformation ensureMyActiveConMeetInstance auf. /teams/auth/start und
/teams/auth/callback brauchen keine Sitzung; /teams/auth/start leitet im Anmeldefenster zu
Keycloak weiter.
R74 Remote Functions, die /teams/call aufruft, antworten ohne Sitzung mit einem Fehler statt
einer Weiterleitung. Endet die Sitzung der Anrufinformation während der Nutzung (Abmelden nach R107,
gescheiterte Erneuerung der Tokens, Ablauf, Sitzungsende nach WP4 des Integrationsplans), zeigt die
Anrufinformation beim nächsten Aufruf R11. Abmelden auf der Webseite beendet eine Teams-Sitzung
nicht (R97).
R107 Ab Zustand 2 aus R33 bietet die Anrufinformation „Abmelden“ an. In Teams löscht der Server
die Teams-Sitzung mit ihren Tokens, widerruft das Refresh-Token beim Client
vd-suite-conmeet-teams (Revocation Endpoint von Keycloak), löscht __Host-teams-session und zeigt
R11; die Realm-Sitzung bei Keycloak und eine Webseiten-Sitzung bleiben bestehen. Außerhalb von
Teams ist „Abmelden“ die Abmeldung der Webseite.
3. Aktive ConMeet-Instanz (US-12)
R20 Je Person gibt es höchstens eine aktive ConMeet-Instanz. Sie wird in der Vertikale
ConMeetInstances als Entität ActiveInstanceChoice gespeichert (UserId eindeutig,
ConMeetInstanceId), mandanteneigen und auditiert wie die übrigen Entitäten der Vertikale.
R21 Aktiv sein kann nur eine Instanz, für die die Person berechtigt ist (001-R22). Ob sie verknüpft ist, spielt für die Wahl keine Rolle.
R22 Hat die Person keine aktive Instanz und ist sie für genau eine Instanz berechtigt, speichert
der Server diese als aktive Instanz. Das geschieht nur in der Mutation
ensureMyActiveConMeetInstance, die die Anrufinformation bei jedem Laden aufruft; die Instanz
bleibt aktiv, auch wenn später weitere hinzukommen.
R23 Hat die Person keine aktive Instanz und ist sie für mehrere Instanzen berechtigt, zeigt die Anrufinformation „Bitte wählen Sie, mit welcher ConMeet-Instanz Sie arbeiten.“ und die Auswahl; sie sucht erst nach der Wahl.
R24 Ist die Person für mindestens zwei Instanzen berechtigt, zeigt die Anrufinformation oben eine Auswahl mit den Namen aller berechtigten Instanzen; ausgewählt ist die aktive.
R25 Ist die Person für genau eine Instanz berechtigt, zeigt die Anrufinformation weder eine Auswahl noch den Namen der Instanz.
R26 Wählt die Person eine andere Instanz, speichert der Server sie sofort als aktiv; die Anrufinformation sucht die zuletzt gesuchte Rufnummer (R79) erneut in der neuen Instanz, nicht einen ungesucht geänderten Inhalt des Felds. Gibt es noch keine gesuchte Nummer (erste Wahl aus Zustand 4), sucht sie die Nummer aus Teams.
R27 Die Wahl gilt für alle Sitzungen und Geräte der Person, bis sie umschaltet oder R28 bis R31 greift.
R28 Entzieht der Administrator der Person die Einzelberechtigung für ihre aktive Instanz
(001-R70) und ist sie danach nicht mehr berechtigt (also ohne GrantedToAll), löscht der Server
ihre ActiveInstanceChoice in derselben Transaktion physisch. Bleibt sie über GrantedToAll
berechtigt, bleibt die Wahl bestehen.
R29 Entfernt der Administrator GrantedToAll einer Instanz, löscht der Server in derselben
Transaktion die ActiveInstanceChoice aller Personen, die für diese Instanz danach nicht mehr
berechtigt sind. Jedes Löschen nach R28 bis R30 schreibt je Zeile eine Audit-Zeile wie 001-R15.
R92 ensureMyActiveConMeetInstance und setMyActiveConMeetInstance schreiben über EF Core wie
jede Entität der Vertikale (Interceptoren für Mandant, Anlage, Änderung und Audit), nicht über rohes
SQL. Der eindeutige Index ist (TenantId, UserId) mit UniqueWithIsDeletedFilter().
ensureMyActiveConMeetInstance legt nur an, wenn keine Wahl besteht; scheitert das Anlegen an der
Eindeutigkeit, weil eine gleichzeitige Anfrage schneller war, liest es die vorhandene Wahl.
setMyActiveConMeetInstance ändert die vorhandene Wahl oder legt sie an. Scheitert das Speichern an
der Zeilenversion xmin, weil gleichzeitig umgeschaltet wurde, lädt es die Wahl neu und speichert
einmal erneut; scheitert auch das, liefert es den gespeicherten Stand ohne Fehler. So gilt bei
gleichzeitigem Umschalten die zuletzt gespeicherte Wahl.
R93 Wer die aktive Instanz liest, prüft im selben Lesevorgang die Berechtigung (001-R22). Eine
ActiveInstanceChoice für eine Instanz ohne Berechtigung gilt als nicht vorhanden; so schadet
eine Wahl nicht, die gleichzeitig mit einem Entzug angelegt wurde.
R106 Findet ensureMyActiveConMeetInstance eine Wahl nach R93, die als nicht vorhanden gilt,
löscht es sie physisch mit Audit-Zeile wie R28 und wendet danach R22 an; ohne neue Wahl zeigt die
Anrufinformation R23. Wird die Person
für die alte Instanz wieder berechtigt, bevor sie die Anrufinformation erneut lädt, gilt die alte
Wahl wieder; das ist nur nach einem Wettlauf aus R93 möglich und hingenommen (Ausnahme von R32).
R30 Trennen einer Instanz (001-R15) löscht alle ActiveInstanceChoice, die auf sie verweisen.
R31 Entfernt Core die Person oder wechselt sie den Mandanten (001-R86, 001-R69), wird ihre
ActiveInstanceChoice über denselben Erweiterungspunkt (001-R87) physisch gelöscht.
R32 Nach R28 bis R30 gelten R22 und R23 erneut. Eine früher aktive Instanz wird nach erneuter Berechtigung nicht von selbst wieder aktiv, außer über R22.
4. Zustände der Anrufinformation
R33 Die Anrufinformation prüft die Zustände in dieser Reihenfolge und zeigt den ersten zutreffenden:
| Nr. | Bedingung | Anzeige |
|---|---|---|
| 1 | keine Sitzung | R11; direkt nach einem Tausch ohne ankommendes Cookie R101 |
| 2 | nicht READY |
R19 |
| 2a | Laden des Zustands gescheitert | R108 |
| 3 | für keine Instanz berechtigt | „Für Sie ist noch keine ConMeet-Instanz freigegeben. Wenden Sie sich an den Administrator Ihres Betriebs.“ |
| 4 | keine aktive Instanz | R23 |
| 5 | aktive Instanz nicht verknüpft | R34 |
| 6 | keine Rufnummer | R9 |
| 7 | Suche läuft | „Anrufer wird gesucht …“ |
| 8 | Suche fehlgeschlagen | R36, R87 |
| 9 | keine Treffer | „Kontakt nicht gefunden“ mit der gesuchten Rufnummer in der gelieferten oder eingegebenen Schreibweise |
| 10 | Treffer | Abschnitt 7 |
R34 Ist die aktive Instanz nicht verknüpft, zeigt die Anrufinformation „Ihr ConMeet-Konto ist noch nicht verknüpft.“ und die Aktion „Jetzt verknüpfen“. Sie öffnet das Verknüpfen aus 001-R62 in der Anrufinformation; nach dem Speichern sucht die Anrufinformation sofort.
R35 Kann die Schaltfläche „Einfügen“ (001-R101) in Teams die Zwischenablage nicht lesen, zeigt das Schlüsselfeld „Bitte mit Strg+V einfügen.“ (unter macOS „⌘+V“); Einfügen per Tastatur in das Feld bleibt möglich.
R36 Scheitert ein Aufruf der Suche an ConMeet, zeigt die Anrufinformation die Anzeige des
Zustands nach 001-R63 mit Statuscode und Antworttext nach 001-R64 sowie „Erneut suchen“. Bei
KEY_REJECTED bietet sie zusätzlich „Schlüssel ersetzen“ (001-R61) an. Bei SEARCH_TIMEOUT
zeigt sie die Meldung aus R65.
R87 Antwortet die Suche mit NO_ACTIVE_INSTANCE oder ACCOUNT_NOT_LINKED (etwa nach einem Entzug
während der Nutzung), ruft die Anrufinformation ensureMyActiveConMeetInstance erneut auf und zeigt
den Zustand, den R33 dann ergibt. INVALID_PHONE_NUMBER folgt R41 und R77.
R108 Scheitert ensureMyActiveConMeetInstance (Dienst nicht erreichbar, unerwarteter Fehler),
zeigt die Anrufinformation „Die Anrufinformation ist gerade nicht erreichbar.“ mit „Erneut
versuchen“. Antwortet setMyActiveConMeetInstance mit INSTANCE_NOT_GRANTED oder
INSTANCE_NOT_FOUND (Entzug oder Trennen zwischen Laden und Wahl), zeigt sie „Diese ConMeet-Instanz
steht Ihnen nicht mehr zur Verfügung.“, ruft ensureMyActiveConMeetInstance erneut auf und zeigt den
Zustand, den R33 dann ergibt.
R37 Ein Suchergebnis, das eintrifft, nachdem die Person umgeschaltet oder eine andere Nummer gesucht hat, wird verworfen; angezeigt wird nur das Ergebnis der jüngsten Suche.
5. Kontaktsuche
R38 Die Suche läuft im .NET-Dienst in einer neuen fachlichen Vertikale
ConMeet.Core/Features/CallInformation/ mit eigener vertical.json. Sie erreicht ConMeet
ausschließlich über den Contract aus R81, nie über Entitäten oder Service von ConMeetInstances,
und speichert nichts.
R39 Die Suche nutzt ausschließlich die gespeicherte aktive Instanz der Person und deren
persönlichen Schlüssel (001-R30), ausgewählt mit ConMeetInstanceSelector.Default (R82); R22
wendet sie nicht an. Ohne gespeicherte aktive Instanz antwortet der Server mit
NO_ACTIVE_INSTANCE, ohne Verknüpfung mit ACCOUNT_NOT_LINKED (R85).
Contract von ConMeetInstances
R81 Die Vertikale ConMeetInstances bietet in ihren Contracts genau eine Methode für Zugriffe
auf ConMeet im Namen der aufrufenden Person:
Task<T> DoOnConMeetInstance<T>(
ConMeetInstanceSelector instance,
Func<ConMeetInstanceContext, CancellationToken, Task<T>> action,
CancellationToken cancellationToken);
public sealed record ConMeetInstanceContext(Guid InstanceId, string InstanceName, HttpClient Http);
R82 ConMeetInstanceSelector wählt die Instanz. Es gibt nur ConMeetInstanceSelector.Default:
die gespeicherte aktive Instanz der aufrufenden Person (R20, R93). Die Person kommt aus dem Kontext
des Aufrufs; die Methode nimmt keine Benutzer-ID entgegen.
R83 Der persönliche Schlüssel verlässt ConMeetInstances nie. Er steht weder im Kontext noch in
DefaultRequestHeaders des Clients; ein DelegatingHandler der Vertikale setzt
Authorization: Bearer … erst beim Senden jeder Anfrage (001-R52).
R84 Der HttpClient aus R81 hat als BaseAddress die BaseUrl der Instanz mit abschließendem
/; Aufrufe nutzen relative Pfade ohne führendes / (api/public/v1/customers), damit ein Pfad in
der BaseUrl (001-R75) erhalten bleibt. Die Handler-Kette ist von außen nach innen: Begrenzung aus
R52 bis R54, Zeitüberschreitung je Aufruf aus 001-R63, Handler aus R83. Der Client gilt nur während
des Callbacks und wird danach verworfen.
R104 Der Handler aus R83 setzt den Schlüssel nur, wenn der Aufruf unter der BaseAddress liegt
(gleiches Schema, gleicher Host, gleicher Port, Pfad beginnt mit ihrem Pfad). Jeden anderen Aufruf
sendet er nicht, sondern bricht ihn mit einem Fehler ab; die Funktion aus R86 ordnet diesen Fehler
dem Zustand UNEXPECTED ohne Statuscode zu.
R85 Ohne aktive Instanz oder ohne Verknüpfung für sie ruft DoOnConMeetInstance den Callback
nicht auf und wirft den Fehler NO_ACTIVE_INSTANCE bzw. ACCOUNT_NOT_LINKED.
R86 Die Zuordnung einer Antwort oder eines Übertragungsfehlers zu den Zuständen aus 001-R63
liegt als statische Funktion in den Contracts von ConMeetInstances; die Suche (R45) und die
Verbindungsprüfung (001-R95) nutzen dieselbe Funktion. Das Cancellation Token des Callbacks geht an
jeden Aufruf und an das Warten in der Begrenzung (R46, R76).
Ablauf der Suche
R40 Rufnummern werden mit libphonenumber-csharp nach E.164 normalisiert; Standardregion für
Nummern ohne Ländervorwahl ist DE. Ein vorangestelltes tel: wird entfernt. Normalisierbar ist
eine Nummer, wenn Parse gelingt und IsPossibleNumber zutrifft; IsValidNumber wird nicht
verlangt, damit Nummern mit veralteten Metadaten noch treffen.
R41 Lässt sich die Rufnummer des Anrufers nicht normalisieren, antwortet der Server mit
INVALID_PHONE_NUMBER („Bitte geben Sie eine gültige Rufnummer ein.“) und ruft ConMeet nicht auf.
Bei einer Nummer aus Teams zeigt die Anrufinformation „Die Rufnummer
R42 Die Suche ruft alle Kunden der Instanz ab: GET {BaseUrl}/api/public/v1/customers mit
limit=200 und offset in Schritten von 200, bis offset mindestens count erreicht. Der Link
next wird nicht verfolgt.
R43 Für jeden Kunden ruft die Suche dessen Kontakte ab:
GET {BaseUrl}/api/public/v1/customers/{id}/contacts, ebenso seitenweise wie R42.
R103 Die Seiten der Kunden (R42) ruft die Suche nacheinander ab, die Kontaktlisten (R43) mit höchstens 4 gleichzeitig offenen Aufrufen je Suche. Die Begrenzung aus R53 bestimmt so die Dauer, solange ConMeet je Aufruf unter 400 ms antwortet.
R44 Antwortet ConMeet für die Kontakte eines Kunden mit 404, überspringt die Suche diesen Kunden.
R45 Jede andere Antwort außer 2xx bricht die Suche ab, ebenso ein Netzwerk- oder TLS-Fehler und
die Zeitüberschreitung eines einzelnen Aufrufs; das Ergebnis ist ein Zustand nach 001-R63 (über
R86) mit den Angaben nach 001-R64, Fehlercode CONMEET_REQUEST_FAILED.
R46 Jeder einzelne Aufruf folgt 001-R52. Die ganze Suche dauert höchstens 10 Sekunden,
gemessen im Dienst ab Eingang der Anfrage, Wartezeiten aus R54 eingeschlossen. Ist die Zeit
abgelaufen, bricht ein gemeinsames Cancellation Token alle laufenden und wartenden Aufrufe der Suche
ab; der Server antwortet mit SEARCH_TIMEOUT. Die Zeitüberschreitung je Aufruf aus 001-R63 kann
innerhalb dieser Grenze nicht greifen; ein hängendes ConMeet ergibt in der Suche deshalb
SEARCH_TIMEOUT, nicht UNREACHABLE.
R75 Eine laufende Suche bricht die Anrufinformation nie ab — Remote Functions vom Typ command
lassen sich nicht abbrechen. Deshalb sind, solange eine Suche läuft, „Suchen“, „Erneut suchen“ und
die Auswahl aus R24 gesperrt, bis Ergebnis oder Fehler da ist (nach R46 höchstens rund 10
Sekunden). Je Person läuft so höchstens eine Suche, und nie in zwei Instanzen zugleich (R70).
R76 Bricht die Verbindung des Browsers ab, etwa weil die Person die Anrufinformation schließt,
bricht der BFF seine Anfrage an den Dienst ab (request.signal), und der Dienst verknüpft diesen
Abbruch mit dem Cancellation Token aus R46; alle laufenden und wartenden Aufrufe der Suche enden
sofort.
R47 Verglichen werden die normalisierten Nummern fix_phone_number und phone_number jedes
Kontakts mit der normalisierten Nummer des Anrufers. Leere oder nicht normalisierbare Nummern zählen
nicht.
R48 Treffer ist jeder Kontakt, bei dem mindestens eine der Nummern aus R47 gleich ist.
R49 Gibt es keinen Treffer nach R48, ist Treffer jeder Kunde, dessen normalisierte
central_phone_number gleich ist (Zentrale). Gibt es Treffer nach R48, zählt central_phone_number
nicht.
R50 Zu jedem Treffer ist bekannt, über welches Feld er gefunden wurde: FIX_PHONE,
MOBILE_PHONE oder CENTRAL. Passen bei einem Kontakt beide Felder, gilt FIX_PHONE.
R51 Gefundene Kontakte und Kunden existieren nur im Speicher der laufenden Anfrage; sie werden weder in der Datenbank noch in einem Cache abgelegt.
6. Begrenzung der Aufrufe
R52 Jeder Aufruf der ConMeet Public API durch den Dienst, auch die Prüfungen aus 001-R95, läuft
durch eine Begrenzung je Instanz, geschlüsselt nach der normalisierten BaseUrl (001-R2). Sie gilt
über alle Personen, Anfragen und Mandanten des Dienstes hinweg; verbinden zwei Mandanten dieselbe
Adresse, teilen sie sich die Begrenzung, weil sie denselben ConMeet-Server belasten.
R53 Die Begrenzung lässt je Instanz höchstens ConMeet:RequestsPerSecond Aufrufe je Sekunde zu;
Standardwert 10. Die Aufrufe sind gleichmäßig verteilt: Umgesetzt mit System.Threading.RateLimiting
als Token Bucket mit Kapazität 1, der alle 1 / RequestsPerSecond Sekunden ein Token nachfüllt,
partitioniert nach der normalisierten BaseUrl (R52). Spitzen über die Grenze gibt es dadurch nicht.
R54 Ist die Grenze erreicht, wartet der Aufruf, bis er an der Reihe ist; die Begrenzung selbst verwirft ihn nie. Die Wartezeit zählt nicht zur Zeitüberschreitung des einzelnen Aufrufs aus 001-R63, wohl aber zur Gesamtdauer aus R46.
R55 Antwortet ConMeet mit 429, bricht die Suche nach R45 ab; der Fehlercode ist
CONMEET_REQUEST_FAILED mit dem Zustand RATE_LIMITED. Die Anrufinformation zeigt dafür „Zu viele
Anfragen an ConMeet. Bitte gleich erneut suchen.“ Der Dienst wiederholt den Aufruf nicht selbst.
7. Anzeige der Treffer
R56 Je Kontakt nach R48 zeigt die Anrufinformation: Name aus title, firstname, lastname;
Firma aus pretty_name des Kunden; fix_phone_number als „Festnetz“ und phone_number als „Mobil“
in der Schreibweise aus ConMeet; email. Leere Felder entfallen; sind firstname und lastname
leer, steht „Ohne Namen“.
R57 Je Kunde nach R49 zeigt die Anrufinformation pretty_name als Firma,
central_phone_number als „Zentrale“ und email des Kunden, ohne Namen einer Person.
R58 Die Anrufinformation zeigt alle Treffer, sortiert nach Firma, dann nach Nachname, dann nach
Vorname; verglichen ohne Unterschied von Groß- und Kleinschreibung nach deutscher Sortierung (de),
leere Werte zuletzt.
R59 Das Feld aus R50 liefert die Schnittstelle mit; die Anrufinformation zeigt es nicht.
R60 Jeder Treffer hat die Aktion „In ConMeet öffnen“ mit dem Link
{BaseUrl}/home/master-data/customers/{Kunden-id}/dashboard auf die Seite des Kunden, gebildet aus
Http.BaseAddress des Kontexts (R81, R84); eine
eigene Seite für den Kontakt öffnet er nicht. Er öffnet nach R80.
R61 Ist die Person für mehrere Instanzen berechtigt, zeigt die Auswahl aus R24 über den Treffern, aus welcher Instanz sie stammen.
R80 Jeder Link der Anrufinformation auf eine andere Seite — ConMeet (R60), Anleitung zum
Schlüssel (001-R62), Zustandsseiten (R19) — öffnet in Teams über app.openLink() im Browser des
Systems, außerhalb von Teams in einem neuen Tab. Die Anrufinformation selbst bleibt dabei stehen.
8. Schnittstelle
| Operation | Art | Policy | Ergebnis |
|---|---|---|---|
ensureMyActiveConMeetInstance |
Mutation | Standard | wendet R22 an; liefert berechtigte Instanzen (id, name), aktive Instanz oder null, ob die aktive verknüpft ist; R21–R25 |
setMyActiveConMeetInstance(instanceId) |
Mutation | Standard | dasselbe Ergebnis wie ensureMyActiveConMeetInstance, also auch, ob die neue aktive Instanz verknüpft ist; R21, R26, R92 |
findCallerContacts(phoneNumber) |
Query | Standard | Instanz (id, name), normalisierte Nummer, Treffer nach R48–R50; R39–R55 |
R62 ensureMyActiveConMeetInstance, setMyActiveConMeetInstance und findCallerContacts
wirken nur für die aufrufende Person;
sie nehmen keine Benutzer-ID entgegen.
R63 setMyActiveConMeetInstance antwortet für eine Instanz ohne Berechtigung mit
INSTANCE_NOT_GRANTED (001-R39), für eine fremde oder unbekannte mit INSTANCE_NOT_FOUND
(001-R46).
R64 Ein Treffer enthält customerId, customerName, contactId und name (beide null bei
CENTRAL), fixPhoneNumber, phoneNumber und centralPhoneNumber (jeweils nur die zum Treffer
angezeigten, sonst null), email, matchedBy (R50) und conMeetUrl (R60).
R65 Neue Fehlercodes mit Meldung, ergänzend zu 001-R66:
| Code | Meldung |
|---|---|
NO_ACTIVE_INSTANCE |
„Bitte wählen Sie, mit welcher ConMeet-Instanz Sie arbeiten.“ — die Anrufinformation zeigt sie nicht selbst, sondern ermittelt den Zustand nach R87 neu |
INVALID_PHONE_NUMBER |
„Bitte geben Sie eine gültige Rufnummer ein.“ |
SEARCH_TIMEOUT |
„Die Suche hat zu lange gedauert.“ mit „Erneut suchen“ |
CONMEET_REQUEST_FAILED |
Anzeige nach 001-R63 mit den Angaben nach 001-R64 als Erweiterung des Fehlers |
R66 Der BFF ruft die Operationen über Remote Functions vom Typ command auf (POST, Argumente im
Body); GraphQL-Anfragen vom BFF an den Dienst gehen als POST. Die Rufnummer gelangt so nur vom
Browser zum BFF und vom BFF zum Dienst, nie in eine URL.
9. Datenschutz
R67 Rufnummern — auch die gesuchte Nummer des Anrufers —, Namen und E-Mail-Adressen aus der Suche erscheinen in keinem Log, keiner Fehlermeldung und keinem Trace, auch nicht als GraphQL-Variable, Request-Body oder Span-Attribut; Logs nennen nur Instanz, Zustand, Zahl der Aufrufe und Dauer.
R68 Antworttexte nach 001-R64 werden in der Suche nur bei fehlgeschlagenen Aufrufen weitergegeben, nie bei 2xx.
Abgrenzung
R69 vd:suite für ConMeet hält keine Kopie der Kontakte oder Kunden aus ConMeet, auch nicht als Zwischenspeicher — ConMeet wird bei Bedarf abgefragt (US-01, Q1: 6:53); die Antwortzeit löst RB-001, nicht ein Cache.
R70 Die Suche läuft nie in mehreren Instanzen gleichzeitig — die Person soll Live- und Testdaten nicht vermischt sehen (US-12).
R71 Die Anrufinformation leitet im eingebetteten Fenster nie zur Anmeldung weiter — Keycloak lässt sich nicht einbetten, und ein Cookie aus einem Fenster oberster Ebene erreicht das partitionierte Teams-Fenster nicht (R13–R15).
Beispiel: Anruf bei der Tischlerei Muster
Mandant „Tischlerei Muster“ mit den Instanzen „Live“ (für „Alle“ freigegeben) und „Staging“
(zusätzlich für Ben). Clara und Ben haben ihr Konto für „Live“ verknüpft, Ben für „Staging“ noch
nicht. In „Live“ gibt es 60 Kunden. Kunde 5351 „Eheleute Friedrich Meyer“ hat den Kontakt Friedrich
Meyer mit fix_phone_number 02871/2345670. Kunde 5350 „Bauunternehmung Van de Weyer GmbH“ hat
central_phone_number +49 281 98765-0, aber keinen Kontakt mit dieser Nummer.
- Clara nimmt in Teams einen Anruf von
+4928712345670an und öffnet die Anrufinformation. Es gibt keine Sitzung → „Anmelden“ (R11). Im Anmeldefenster meldet sie sich an; der Code wird eingetauscht,__Host-teams-sessiongesetzt (R12–R15). - Clara ist nur für „Live“ berechtigt und hat keine aktive Instanz →
ensureMyActiveConMeetInstancespeichert „Live“ als aktiv; sie sieht keine Auswahl (R22, R25). - Die Suche ruft eine Seite Kunden und 60 Kontaktlisten ab, 61 Aufrufe im Abstand von 100 ms,
die Kontaktlisten mit bis zu 4 gleichzeitig; das dauert gut 6 Sekunden, solange ConMeet je
Aufruf unter 400 ms antwortet und niemand sonst „Live“ abfragt, und bleibt unter der Grenze von
10 Sekunden (R42, R43, R46, R53, R103). Mit 150 Kunden bräche die Suche mit
SEARCH_TIMEOUTab. 02871/2345670normalisiert zu+4928712345670→ Treffer Friedrich Meyer, Firma „Eheleute Friedrich Meyer“, „Festnetz 02871/2345670“,matchedBy = FIX_PHONE(R47, R50, R56). „In ConMeet öffnen“ führt aufhttps://muster.conmeet.example/home/master-data/customers/5351/dashboard(R60).- Ben ist für „Live“ und „Staging“ berechtigt, aber noch für keine aktiv. Bei einem Anruf von
0281 98765-0zeigt die Anrufinformation die Auswahl (R23). Er wählt „Live“ (R26). - Kein Kontakt hat diese Nummer; die Zentrale von Kunde 5350 passt → Treffer
„Bauunternehmung Van de Weyer GmbH“, „Zentrale +49 281 98765-0“,
matchedBy = CENTRAL(R49, R57). Oben steht „Live“ (R61). - Ben schaltet auf „Staging“; die Antwort sagt, dass sein Konto dort nicht verknüpft ist → „Jetzt verknüpfen“ (R34, R26). Er verknüpft; die Suche läuft in „Staging“ → „Kontakt nicht gefunden“.
- Anna entzieht Ben „Staging“. Seine aktive Instanz entfällt (R28). Beim nächsten Öffnen ist er nur noch für „Live“ berechtigt → „Live“ wird aktiv, die Auswahl verschwindet (R22, R25, R32).
- Ben tippt im Feld „Rufnummer“ eine andere Nummer ein, während die Suche noch läuft. „Suchen“ ist gesperrt, bis das Ergebnis da ist; danach sucht er die neue Nummer (R75, R77).
- Ben tippt „Abmelden“. Seine Teams-Sitzung endet, die Anrufinformation zeigt „Anmelden“; seine Sitzung auf der Webseite bleibt bestehen (R74, R97, R107).
Offene Fragen
- Welche Aufrufzahl je Sekunde lässt ConMeet zu (R53)? Der Standardwert 10 ist angenommen.
- Mit 10 Aufrufen je Sekunde und höchstens 10 Sekunden je Suche (R46) findet die Suche nur in
Instanzen mit höchstens rund 95 Kunden etwas; zwei gleichzeitige Suchen in derselben Instanz
teilen sich die Aufrufe und können beide mit
SEARCH_TIMEOUTenden. Im Proof of Concept hingenommen (RB-001); reicht das für die Instanzen dort? - Sollen inaktive Kunden (
is_active = false) in die Suche eingehen (R42)? Vorerst ja. - Braucht die App eine eigene Entra-App-Registrierung, damit Teams die Einwilligung zu R3 anbietet, oder reicht die Teams-App-ID (Q4: README, „Offene Punkte“)?
- Sind „Kontakt“ und „Ansprechpartner“ dasselbe (F-018)? Die Spezifikation sucht in den Kontakten
der Kunden (
/customers/{id}/contacts).