Entity-Linking API
Verknüpfungen zwischen Tickets, Incidents, Problems, Changes, Assets, KB-Artikeln und Verträgen laufen zentral über /api/linking. Für jedes erlaubte Paar gibt es gleich aufgebaute Endpunkte zum Verknüpfen, Lösen und Auflisten — mit einheitlichen Berechtigungen, Statusregeln, Audit und Echtzeit-Updates.
Einheitliches Endpoint-Muster
Für jede registrierte Richtung eines Paares (quelle → ziel) gibt es drei Endpunkte. basePath ist die Quell-Entität (Plural), targetSlug die Ziel-Entität (Singular).
| Method | Endpoint | Body / Response |
|---|---|---|
POST | /api/linking/{basePath}/:id/link-{targetSlug} | Body { "{target}Id": "clx..." } → 204 |
DELETE | /api/linking/{basePath}/:id/link-{targetSlug}/:targetId | → 204 |
GET | /api/linking/{basePath}/:id/linked-{targetSlug}s | { "data": [...] } |
Beispiel: Ticket ↔ Problem
# Link (permission tickets.linkToProblems)
POST /api/linking/tickets/:id/link-problem
{ "problemId": "clx-problem-id" } # → 204 No Content
# Remove
DELETE /api/linking/tickets/:id/link-problem/clx-problem-id # → 204
# List the linked problems of a ticket (permission tickets.viewOwn)
GET /api/linking/tickets/:id/linked-problems
# → { "data": [
# // visible: id, problemNumber, title, status, priority, createdAt,
# // assignedTo{name}|null — WITHOUT a restricted flag
# { "id": "...", "problemNumber": "PRB-0042", "title": "…", "status": "INVESTIGATING",
# "priority": "HIGH", "createdAt": "…", "assignedTo": { "name": "Jane Smith" } },
# // hidden: stub with only id, problemNumber, status, createdAt, restricted: true
# { "id": "...", "problemNumber": "PRB-0043", "status": "NEW", "createdAt": "…", "restricted": true }
# ] }
# Reverse direction (permission problems.linkToTickets / problems.viewOwn)
POST /api/linking/problems/:id/link-ticket { "ticketId": "clx-ticket-id" }
GET /api/linking/problems/:id/linked-tickets
Unterstützte Verknüpfungs-Paare
Pro Richtung gilt die Permission {quelle}.linkTo{Ziel}; der List-Endpunkt nutzt {quelle}.viewOwn. „↔" = beide Richtungen registriert, „→" = nur diese Richtung.
| Paar | Permissions |
|---|---|
| Ticket ↔ Problem | tickets.linkToProblems / problems.linkToTickets |
| Ticket ↔ Change | tickets.linkToChanges / changes.linkToTickets |
| Ticket ↔ Incident | tickets.linkToIncidents / incidents.linkToTickets |
| Ticket ↔ Asset | tickets.linkToAssets / assets.linkToTickets |
| Ticket → Article (KB) | tickets.linkToKB |
| Ticket → Sub-Ticket (Ticket) | tickets.linkToTickets (Eltern-Kind, siehe unten) |
| Problem ↔ Change | problems.linkToChanges / changes.linkToProblems |
| Problem ↔ Incident | problems.linkToIncidents / incidents.linkToProblems |
| Problem ↔ Asset | problems.linkToAssets / assets.linkToProblems |
| Problem → Article (KB) | problems.linkToKB |
| Change ↔ Incident | changes.linkToIncidents / incidents.linkToChanges |
| Change ↔ Asset | changes.linkToAssets / assets.linkToChanges |
| Change → Article (KB) | changes.linkToKB |
| Incident ↔ Asset | incidents.linkToAssets / assets.linkToIncidents |
| Incident → Article (KB) | incidents.linkToKB |
| Asset ↔ Contract | assets.linkToContracts (Sonderlogik, siehe unten) |
Body-Feld richtet sich nach der Ziel-Entität: problemId, ticketId, changeId, incidentId, assetId, articleId oder childTicketId (jeweils eine ID-Pflichtangabe).
Verhalten & Regeln
| Regel | Beschreibung |
|---|---|
| Statusregel (Verknüpfen) | Quell-Entität darf nicht geschlossen/gesperrt sein. Das Verlinken AUF geschlossene Entitäten ist erlaubt (ITIL: PIR/Analytics) — mit einer Ausnahme: Ticket → Change lehnt einen abgeschlossenen Change mit 400 ENTITY_LOCKED ab. |
| Statusregel (Lösen) | Beide Seiten müssen offen sein, sonst blockiert. |
| Duplikat | 409 ALREADY_LINKED |
| Gestufte Liste | Einträge, die der Aufrufer nicht sehen darf, kommen als Stub mit restricted=true — die Zeile bleibt (der Zähler stimmt), der Inhalt fehlt. Als volle Zeile zählen auch die Vertretung des Bearbeiters und, bei Problem-Paaren, das aktive Mitglied der zugewiesenen Gruppe. |
| Umfang der Zeile | Eine verknüpfte Zeile trägt genau die Felder, die eine Verknüpfungs-Liste anzeigt — Nummer, Titel, Status, Priorität, Anlagezeit und den NAMEN der Zuweisung. Alles Weitere steht am Detail des Vorgangs: ganze Kategorie-Relationen, Zeitplan- und Auswirkungs-Felder und interne Zuordnungs-IDs (customerId, assignedToId, assignedGroupId …) sind nicht Teil dieser Antworten. Wer eigene Skripte gegen die linked-Routen fährt, holt solche Angaben über den jeweiligen Detail-Endpunkt. |
| Papierkorb | Soft-gelöschte Gegenstücke erscheinen nicht in den Listen, und weder Verlinken noch Entlinken erreicht sie (404). Die Verknüpfungs-Zeile selbst bleibt erhalten — sie kommt beim Wiederherstellen zurück. Eine Verknüpfung, deren Gegenstück im Papierkorb liegt, lässt sich also erst nach dem Restore lösen. |
| Verknüpfung fehlt (Unlink) | 404 LINK_NOT_FOUND — beide Enden existieren und sind sichtbar, nur die Verknüpfung nicht. So lässt sich unterscheiden, ob die Verknüpfung oder eines der Objekte fehlt. |
| Audit + Realtime | Jede Link/Unlink-Aktion wird auditiert (category LINKING) und broadcastet auf beiden Entitäten. |
| Actor | Verlinken ist eine NUTZER-Fähigkeit: Link, Unlink und Liste verlangen alle einen eingeloggten Benutzer — ein API-Key bekommt 403 FORBIDDEN. |
| Sicht auf die Quelle | Für die Liste gelten dieselben Rechte wie für den Vorgang selbst: Wer die Quelle nicht sehen darf, bekommt 403 FORBIDDEN; gibt es sie nicht oder liegt sie im Papierkorb, 404. So gibt die Verknüpfungs-Liste nie mehr preis als die Detailansicht. |
| Reihenfolge der Prüfungen | Zuerst wird die Sichtbarkeit geprüft, dann der Status: Wer eine Zeile nicht sehen darf, bekommt beim Ver- und Entknüpfen 403. Sonst verriete der Statuscode den Zustand eines fremden Vorgangs. Berechtigte erhalten bei geschlossenem Vorgang 409. |
Sub-Tickets (Ticket ↔ Ticket)
Ein Ticket kann Sub-Tickets tragen: ein Anliegen wird in Teilaufgaben zerlegt, die getrennt bearbeitet werden. Die Beziehung ist das einzige Paar aus zwei gleichen Typen und deshalb gerichtet — die ID in der URL ist immer das ELTERNTICKET, die ID im Body immer das Kind. Es gibt genau EINE Ebene: ein Kind kann keine eigenen Kinder haben, und ein Ticket, das bereits Kind ist, nimmt keine auf.
# Subordinate an existing ticket (permission tickets.linkToTickets)
POST /api/linking/tickets/:parentId/link-child-ticket
{ "childTicketId": "clx-child-ticket-id" } # → 204 No Content
# Release the relationship
DELETE /api/linking/tickets/:parentId/link-child-ticket/clx-child-ticket-id # → 204
# Sub-tickets of a parent ticket (tickets.viewOwn/viewAll + tickets.viewInternal)
GET /api/linking/tickets/:parentId/linked-child-tickets
# → { "data": [
# { "id": "...", "ticketNumber": "TKT-2026-000043", "title": "…", "status": "IN_PROGRESS",
# "priority": "HIGH", "source": "EMAIL", "createdAt": "…", "customer": { "name": "Max Mustermann" } },
# { "id": "...", "ticketNumber": "TKT-2026-000044", "status": "OPEN", "createdAt": "…", "restricted": true }
# ] }
Die Gegenrichtung hat keine eigene Listen-Route: das Elternticket steht als parentTicket (id, ticketNumber) am Kind selbst, zusammen mit childTicketCounts am Elternticket — siehe Tickets API.
| Ablehnung | HTTP | Wann |
|---|---|---|
CANNOT_LINK_TO_SELF | 400 | Ein Ticket kann nicht sein eigenes Sub-Ticket sein. |
TICKET_PARENT_TERMINAL | 409 | Das Elternticket ist RESOLVED, CLOSED oder SPAM. Ein gelöstes Elternticket mit offenem Kind wäre ein Widerspruch — der Zustand darf auch über das Verknüpfen nicht entstehen. Ein geschlossenes KIND darf dagegen untergeordnet werden: es zählt als erledigt. |
TICKET_NESTING_DEPTH | 409 | Die eine Ebene wäre überschritten: das Elternticket ist selbst ein Kind (details.reason = PARENT_IS_CHILD) oder das Kind trägt eigene Sub-Tickets (CHILD_HAS_CHILDREN). |
TICKET_ALREADY_HAS_PARENT | 409 | Ein Kind hat genau ein Elternticket, und es hängt bereits an einem anderen. Dasselbe Elternticket noch einmal ergibt ALREADY_LINKED. |
TICKET_HAS_PROCESS_LINKS | 409 | Das Kind ist mit Incidents, Problems oder Changes verknüpft (details.links nennt die Anzahlen je Art). Asset- und KB-Verknüpfungen bleiben erlaubt. |
CHILD_TICKET_CANNOT_LINK | 409 | Die Gegenrichtung derselben Regel: ein Ticket, das bereits Kind ist, lässt sich nicht mit einem Incident, Problem oder Change verknüpfen — auch nicht über Eskalation oder das Übernehmen eines Workarounds. details.targetType nennt die abgelehnte Art. |
Die Struktur ist eine interne Angabe: Die Kinder-Liste bleibt ohne tickets.viewInternal leer — auch für den Kunden des Elterntickets. Kind und Elternticket können verschiedenen Personen gehören; Nummer und Titel der Gegenseite gehören deshalb nicht in die Kundensicht. Leer statt 403: dieselbe Form, in der auch die Zähler am Ticket redigiert werden.
Anlegen mit parentTicketId, die Sperre gegen das Lösen eines Elterntickets mit offenen Kindern, das Verhalten beim Zusammenführen und der Listen-Filter stehen in der Tickets API. Tickets API →
Indirect-Link-Check
Prüft, ob zwischen zwei Entitäten bereits eine indirekte (transitive, 2-Hop) Verbindung besteht — z.B. um zirkuläre oder redundante Verlinkungen zu vermeiden, bevor ein direkter Link gesetzt wird.
GET /api/linking/check-indirect?sourceType=TICKET&sourceId=clx-a&targetType=PROBLEM&targetId=clx-b
# → { "data": [ { "viaType": "INCIDENT", "viaId": "clx-i", "viaNumber": "INC-2026-000042", "viaTitle": "…" } ] }
sourceType und targetType nehmen TICKET, PROBLEM und INCIDENT — genau die Typen, für die der Dienst Paare kennt; jeder andere Wert ist 400. Der Zwischen-Vorgang läuft durch dieselbe Sicht-Prüfung wie eine Liste: wer ihn nicht sehen darf, bekommt ihn auch hier nicht genannt.
Batch-Resolve
Löst mehrere verlinkte Kind-Entitäten gesammelt über ihre Eltern-Entität (Cascading Resolution Center).
# Resolve linked tickets via an incident/problem (permission tickets.editStatus)
POST /api/linking/batch-resolve?sourceType=INCIDENT&sourceId=clx
{ "ticketIds": ["clx-t1", "clx-t2"], "resolution": "Fixed via incident", "resolutionCode": "RESOLVED_BY_INCIDENT" }
# Resolve linked incidents via a problem (permission incidents.changeStatus)
POST /api/linking/batch-resolve-incidents?sourceId=clx
{ "incidentIds": ["clx-i1"], "resolution": "...", "resolutionCode": "...", "rootCauseShort": "Missing index" }
sourceType— INCIDENT oder PROBLEM (batch-resolve-incidents kennt nur Problem-Quellen und nimmt den Parameter nicht). Eine unbekannte oder für den Aufrufer unsichtbare Quelle ist 404 bzw. 403.- Nur Verlinktes wird aufgelöst: IDs, die nicht mit der Quelle verknüpft sind, zählen als skipped — so lässt die Antwort keine Rückschlüsse zu, ob fremde Vorgänge existieren.
- Elterntickets mit offenen Sub-Tickets werden übersprungen: Sie zählen als skipped und erscheinen in errors mit dem Code HAS_OPEN_CHILDREN — die Sammel-Auflösung bricht deswegen nicht ab, und die Oberfläche kann den Grund je Zeile benennen.
- Grenzen: höchstens 200 Zeilen je Aufruf, resolution max. 2 000 Zeichen, rootCauseShort max. 500, resolutionCode max. 50.
- Die Anzeige-Nummer der Quelle kommt vom Server — sie ist kein Eingabe-Parameter; sonst stünde ein frei gewählter Text in Ticket-Verlauf, Kunden-Benachrichtigung und Audit.
Asset ↔ Contract
Asset-Vertrags-Verknüpfungen haben eigene Endpunkte und Regeln:
| Method | Endpoint | Permission |
|---|---|---|
GET | /api/linking/assets/:id/linked-contracts | assets.viewOwn |
POST | /api/linking/assets/:id/link-contract | assets.linkToContracts |
DELETE | /api/linking/assets/:id/link-contract/:contractId | assets.linkToContracts |
// POST /api/linking/assets/:id/link-contract
{ "contractId": "clx-contract-id", "notes": "Maintenance contract" } // → 204
- Der Body wird geprüft: contractId ist Pflicht, notes optional (max. 1 000 Zeichen, null erlaubt); ein falscher Typ ist 400.
- Eine bereits bestehende Verknüpfung antwortet 409 ASSET_CONTRACT_LINK_EXISTS (die übrigen Paare melden ALREADY_LINKED).
- Dieser Weg setzt KEINEN Hauptvertrag: isPrimary bleibt false. Das Kennzeichen lebt auf der Vertrags-Seite — POST /api/contracts/:id/assets legt die Zuordnung mit Flag an, PATCH /api/contracts/:id/assets/:assetId schaltet es um.
Impact Tree
Der Impact Tree zeigt die vollständige Auswirkung eines Vorgangs (direkte Tickets + verlinkte Incidents mit deren Tickets = 2-Hop, inkl. SLA- und Zugriffs-Info). Abrufbar über die jeweiligen Endpunkte, z.B. GET /api/problems/:id/impact-tree und GET /api/incidents/:id/impact-tree — Basis für die Close-Dialoge (Cascading Resolution).
Auch der Impact Tree ist gestaffelt: ein verknüpfter Incident, den der Aufrufer nicht sehen darf, erscheint als Knoten mit accessible: false — Nummer, Status und Priorität bleiben (sonst stimmte der Zähler nicht), Titel, Zuweisung und die fachlichen Angaben fehlen, und die Auflösung überspringt ihn. Ob ein solcher Knoten automatisch auflösbar wäre, steht nicht an der Zeile — die Mengen-Aussage trägt summary.resolvableIncidentCount, und die zählt über alle verknüpften Incidents.
- ✓ Ein zentraler Einstieg für alle Verknüpfungen
- ✓ Symmetrische Paare, Permission pro Seite
- ✓ Gestufte Listen + Statusregeln
- ✓ Indirect-Check gegen Zyklen
- ✓ Batch-Resolve + Impact Tree
{entity}.linkTo{Targets}– Verlinken/Entfernen pro Quelle{entity}.viewOwn– verlinkte Liste lesentickets.editStatus/incidents.changeStatus– Batch-Resolveassets.linkToContracts– Asset↔Contract
Auth-/Rollenmodell: Permissions & RBAC
- Problems API – impact-tree, cascading-close, from-incidents
- Incidents API – Major-Incident-Bündelung, Batch-Resolve
- Changes API – relatedTickets/Problems/Incidents/Assets
- Assets API – Asset-Verknüpfungen & Verträge