---
title: 'Kommentarsystem'
description: 'Anleitung zur Integration des generischen CrispyCMS-Kommentarsystems in eigene Ressourcen wie Plugin-Entitäten, inklusive Provider-Implementierung und Twig-Einbindung.'
navLabel: 'Kommentarsystem'
navIcon: '💬'
---

CrispyCMS enthält ein generisches Kommentarsystem mit verschachtelten Antworten, Emoji-Reaktionen, @-Erwähnungen mit E-Mail-Benachrichtigung, Bearbeiten, Soft-Delete und dem Auflösen ganzer Threads. Es ist **polymorph** aufgebaut: Kommentare hängen nicht an einer festen Tabelle, sondern an einem Paar aus `resource_type` und `resource_id`. Der CMS-Kern registriert den Typ `page` (Kommentar-Karte im Seiten-Editor) — Sie können mit wenigen Zeilen jeden eigenen Ressourcentyp kommentierbar machen, z. B. eine Plugin-Entität.

---

## Architektur

| Baustein | Ort | Aufgabe |
| :--- | :--- | :--- |
| `CommentResourceProviderInterface` | `Crispy\Interfaces` | Adapter pro Ressourcentyp: Existenz, Zugriff, erwähnbare Benutzer, Name, URL |
| `CommentService` | `Crispy\Services` | Geschäftslogik + Provider-Registry |
| `CommentNotificationService` | `Crispy\Services` | Mention-/Reply-E-Mails (`comment.mjml.twig`) |
| `CommentApiController` | `PageControllers/CmsControl/Comments` | JSON-API unter `/admin/api/comments/{resourceType}/{resourceId}` |
| `Components/Comments.twig` | `src/templates` | Einbettbare UI-Komponente |
| `comments.js` / `comments.css` | `src/assets/{js,css}/CMSControl` | Client-seitiges Rendering |

Die Zugriffskontrolle liegt vollständig beim Provider und ist zweistufig:

- `canParticipate()` entscheidet über die reine Lese-Teilnahme: Kommentare sehen sowie eigene Kommentare bearbeiten/löschen (zusätzlich zu den Autor-/SUPERUSER-Regeln von `CommentService`).
- `canWrite()` entscheidet über **jede** Aktion, die den gemeinsamen Thread-Zustand verändert: einen neuen Kommentar/eine Antwort **erstellen**, mit Emoji **reagieren** und einen Thread **auflösen/wieder öffnen**. Für die meisten Ressourcentypen ist das identisch mit `canParticipate()` — ein Benutzer, der lesen darf, darf auch schreiben/reagieren/auflösen. Ein Ressourcentyp kann hier aber eine strengere, eigene Berechtigung verlangen (siehe Beispiel unten), sodass reine Leser wirklich nur lesen können.

Es gibt **keine** eigene Kommentar-Permission im Kern — jeder Ressourcentyp bildet beide Methoden auf sein eigenes Berechtigungsmodell ab.

---

## Schritt 1: Provider implementieren

Implementieren Sie `Crispy\Interfaces\CommentResourceProviderInterface` für Ihren Ressourcentyp. Der `resourceType`-Schlüssel muss stabil und kleingeschrieben sein — er wird in der Datenbank gespeichert und ist Teil der API-URL.

```php
<?php

namespace MyPlugin;

use Crispy\Interfaces\CommentResourceProviderInterface;
use Crispy\Models\UserModel;
use Crispy\Enums\Permissions;
use Crispy\Enums\UserProperties;
use Crispy\Helper;
use Crispy\Repositories\UserRepository;

class ProjectCommentResourceProvider implements CommentResourceProviderInterface
{
    public function __construct(
        private ProjectRepository $projectRepository = new ProjectRepository(),
        private UserRepository $userRepository = new UserRepository(),
    ) {
    }

    public function getResourceType(): string
    {
        return 'myplugin-project';
    }

    public function resourceExists(int $resourceId): bool
    {
        return $this->projectRepository->fetchById($resourceId) !== null;
    }

    public function canParticipate(UserModel $user, int $resourceId): bool
    {
        // Bilden Sie hier ab, was "hat Zugriff auf die Ressource" für
        // Ihren Typ bedeutet — z. B. eine Feature-Permission:
        return $user->hasPermission(Permissions::SUPERUSER)
            || $user->hasPermission('myplugin.read_projects');
    }

    public function canWrite(UserModel $user, int $resourceId): bool
    {
        // Die meisten Ressourcentypen erlauben Schreiben überall dort, wo
        // auch Teilnahme erlaubt ist:
        return $this->canParticipate($user, $resourceId);
    }

    public function getMentionableUsers(int $resourceId): array
    {
        // Alle Benutzer, die den Kommentar auch sehen könnten. Nur diese
        // sind per @ erwähnbar und erhalten Mention-E-Mails.
        return array_values(array_filter(
            $this->userRepository->fetchAllUsers(),
            fn (UserModel $user) => !$user->hasProperty(UserProperties::DISABLED)
                && $user->canAccessAdminBackend()
                && $this->canParticipate($user, $resourceId)
        ));
    }

    public function getOwner(int $resourceId): ?UserModel
    {
        // Der "Besitzer" der Ressource beobachtet deren Kommentare
        // automatisch (erhält die "Neuer Kommentar"-Mail, bis er die
        // Beobachtung beendet). null, wenn es keinen Besitzer gibt.
        $ownerId = $this->projectRepository->fetchById($resourceId)?->getCreatedBy();

        return $ownerId ? $this->userRepository->getUserById($ownerId) : null;
    }

    public function getDisplayName(int $resourceId): string
    {
        return $this->projectRepository->fetchById($resourceId)?->getName() ?? '';
    }

    public function getUrl(int $resourceId): string
    {
        // Absolute Admin-URL — Ziel des Buttons in Benachrichtigungs-Mails.
        return Helper::getBaseUrl() . '/admin/myplugin/projects/' . $resourceId;
    }
}
```

> **Wichtig:** `getMentionableUsers()` ist gleichzeitig die Sicherheitsgrenze für Erwähnungen. Geben Sie hier niemals Benutzer zurück, die die Ressource nicht sehen dürfen — sonst können sie in Inhalte hineingezogen werden, auf die sie keinen Zugriff haben.

### Beispiel: eigene Schreib-Permission

Manche Ressourcentypen wollen Lesen und Schreiben bewusst trennen — z. B. das Archiv-Plugin (`plugins/archive`): Wer nur `archive.read` besitzt, sieht Kommentare auf Archiv-Datensätzen und -Dateien, kann aber **weder einen neuen Kommentar erstellen noch reagieren noch einen Thread auflösen/wieder öffnen**, es sei denn er besitzt zusätzlich `archive.create`, `archive.update` oder die eigens dafür registrierte Permission `archive.write_comments`. `ArchiveRecordCommentResourceProvider::canWrite()` (`plugins/archive/src/CommentResourceProviders/ArchiveRecordCommentResourceProvider.php`) macht das so:

```php
public function canParticipate(UserModel $user, int $resourceId): bool
{
    return $this->userService->checkPermissionStack(
        [Permissions::SUPERUSER->value, 'archive.read'],
        $user
    );
}

public function canWrite(UserModel $user, int $resourceId): bool
{
    return $this->userService->checkPermissionStack(
        [
            Permissions::SUPERUSER->value,
            'archive.create',
            'archive.update',
            'archive.write_comments',
        ],
        $user
    );
}
```

`canWrite()` wird zusätzlich zu `canParticipate()` (welches über `guard()` bereits für jede Aktion geprüft wird) an drei Stellen geprüft: `processPOSTRequest()` (Kommentar/Antwort erstellen), `processResolveRequest()` (Thread auflösen/wieder öffnen) und `processReactionRequest()` (Reagieren). Bearbeiten und Löschen eigener Kommentare, Beobachten und Als-gelesen-Markieren bleiben unverändert an `canParticipate()` sowie die bestehenden Autor-/SUPERUSER-Regeln gebunden — das sind keine Aktionen, die den Thread-Zustand für andere verändern.

---

## Schritt 2: Provider registrieren

Registrieren Sie den Provider einmalig beim Setup, in Plugins typischerweise im EventSubscriber auf `ThemeEvents::SETUP`:

```php
use crisp\Events\ThemeEvents;
use Crispy\Services\CommentService;

public static function getSubscribedEvents(): array
{
    return [
        ThemeEvents::SETUP => 'onThemeInit',
    ];
}

public function onThemeInit(): void
{
    CommentService::registerProvider(new ProjectCommentResourceProvider());
}
```

Ab diesem Moment bedient die API `/admin/api/comments/myplugin-project/{id}` Ihren Ressourcentyp — ohne eigenen Controller.

---

## Schritt 3: Komponente einbetten

Binden Sie die Komponente an beliebiger Stelle Ihrer Admin-View ein (z. B. in einer Card) und laden Sie Styles und Treiber-Skript:

```twig
{% block PageCSS %}
  <link rel="stylesheet" href="{{ includeResource('css/CMSControl/comments.css') }}">
{% endblock %}

{% block Content %}
  <div class="card">
    <div class="card-header">
      <h3 class="card-title">
        <i class="fa-sharp-duotone fa-regular fa-comments text-primary me-2"></i>
        {{ 'CMSControl.Components.Comments.Title'|translate }}
      </h3>
    </div>
    <div class="card-body">
      {% include 'Components/Comments.twig' with {
        "commentResourceType": "myplugin-project",
        "commentResourceId": Project.id,
      } %}
    </div>
  </div>
{% endblock %}

{% block PageJs %}
  <script src="{{ includeResource('js/CMSControl/comments.js') }}"></script>
{% endblock %}
```

`comments.js` initialisiert jede `.crispy-comments`-Instanz auf der Seite unabhängig — mehrere Threads (auch unterschiedlicher Ressourcentypen) in einer View sind möglich. Referenz-Einbindung im Kern: `Views/Pages/EditPage.twig`.

---

## Schritt 4: Aufräumen beim Löschen der Ressource

`crispy_comments` hat **keinen** Fremdschlüssel auf die polymorphe Ressource. Der Lösch-Pfad Ihrer Ressource muss die Kommentare daher explizit entfernen (Reaktionen werden per FK-Kaskade mitgelöscht):

```php
use Crispy\Services\CommentService;

$commentService->purgeResource('myplugin-project', $project->getId());
```

Das entfernt Kommentare (Reaktionen und Lesebestätigungen kaskadieren per FK) sowie die Beobachtungs-Einträge. Referenz-Implementierung im Kern: `PageService::deletePage()`.

---

## Verhaltensregeln

Diese Regeln erzwingt der `CommentService` zentral — sie gelten für jeden Ressourcentyp:

* **Bearbeiten** darf ausschließlich der Autor eines Kommentars (auch kein SUPERUSER). Bearbeitete Kommentare erhalten den Hinweis „(bearbeitet)".
* **Löschen** dürfen Autor und SUPERUSER. Es ist immer ein Soft-Delete: Der Kommentar zeigt anschließend „Dieser Kommentar wurde gelöscht" mit dem Benutzer **System**; Antworten darunter bleiben erhalten.
* **Auflösen** (Resolve) ist nur auf Top-Level-Kommentaren möglich und betrifft den gesamten Thread. Gelöste Threads werden standardmäßig ausgeblendet.
* **Reaktionen** stammen aus dem festen Set 👍 ❤️ 😂 😮 🎉 🙁 (`CommentService::ALLOWED_REACTIONS`). Erneutes Klicken derselben Reaktion entfernt sie.
* **Erwähnungen** werden als `@benutzername` geparst und nur gegen `getMentionableUsers()` aufgelöst.
* **Ungelesene Kommentare** werden pro Benutzer hervorgehoben („Neu"-Badge). Ein Kommentar gilt erst als gelesen, wenn er tatsächlich im Viewport sichtbar war (IntersectionObserver in `comments.js`, Tabelle `crispy_comment_reads`); eigene Kommentare gelten implizit als gelesen.
* **Beobachten**: Jeder Teilnehmer kann eine Ressource beobachten (Glocken-Button im Kommentarbereich) und erhält dann bei jedem neuen Kommentar eine E-Mail. Der Besitzer der Ressource (`getOwner()`) beobachtet automatisch und kann die Beobachtung beenden (Tabelle `crispy_comment_watches`).
* **Auto-Verlinkung**: http/https-URLs in Kommentaren werden automatisch als anklickbare Links gerendert (abschaltbar unter Einstellungen → Allgemein → Kommentare, `CMSControl_Comments_AutoLink_Enabled`).
* Die maximale Kommentarlänge beträgt 4000 Zeichen (`CommentService::MAX_CONTENT_LENGTH`).

---

## Benachrichtigungen

`CommentNotificationService` verschickt automatisch:

* **Mention-Mail** an jeden erwähnten Benutzer, beim Anlegen eines Kommentars (`CMSControl_Notification_CommentMention_Enabled`).
* **Reply-Mail** an den Autor des Eltern-Kommentars, beim Anlegen einer Antwort (`CMSControl_Notification_CommentReply_Enabled`), sofern dieser laut Provider weiterhin teilnehmen darf.
* **Resolved-Mail** an alle Beteiligten eines Threads, wenn dieser als gelöst markiert wird (`CMSControl_Notification_CommentResolved_Enabled`) — Beteiligte sind alle Autoren nicht gelöschter Kommentare des Threads (`CommentService::getThreadParticipants()`).
* **Created-Mail** an alle effektiven Beobachter der Ressource bei jedem neuen Kommentar (`CMSControl_Notification_CommentCreated_Enabled`) — Besitzer automatisch, weitere Benutzer per Glocken-Button (`CommentService::getEffectiveWatchers()`). Wer für denselben Kommentar bereits eine Mention-/Reply-Mail erhält, bekommt keine zusätzliche Created-Mail.

Alle vier sind unter **Einstellungen → Benachrichtigungen** einzeln abschaltbar. Ein Benutzer erhält nie zwei Mails für denselben Kommentar (Mention gewinnt gegenüber Reply, beide gegenüber Created), und niemand benachrichtigt sich selbst — weder als Autor noch als auflösende Person. Das Mail-Template ist `templates/mail/notifications/comment.mjml.twig`; im Development-Plugin ist es unter `/admin/mailtest` als `comment_mention` / `comment_reply` / `comment_resolved` / `comment_created` testbar.

---

## Weitere Funktionen

### Anheften (Pin)

Ein Top-Level-Thread kann oben in der Liste angeheftet werden — unabhängig vom Gelöst-Status. Anders als bei Erstellen/Reagieren/Auflösen ist die Berechtigung dafür bewusst **enger** als `canWrite()`: nur der Ressourcen-Besitzer (`CommentResourceProviderInterface::getOwner()`) oder ein SUPERUSER dürfen anheften/loslösen (`CommentService::canPinComment()`). comments.js sortiert angeheftete Threads client-seitig nach oben (stabile Sortierung — die chronologische Reihenfolge innerhalb der beiden Gruppen bleibt erhalten).

### Priorität ("Hohe Priorität")

Ein Top-Level-Thread kann zusätzlich (und unabhängig vom Gelöst-/Angeheftet-Status) als **Hohe Priorität** markiert werden. Dieselbe enge Berechtigung wie beim Anheften — `CommentService::canSetPriority()` delegiert direkt an `canPinComment()`, da beide Aktionen bewusst identisch eingeschränkt sind (nur Ressourcen-Besitzer/SUPERUSER). Aktuell nur ein einfaches Ja/Nein-Flag (`priority_at`/`priority_by` in der Datenbank, analog zu `resolved_at`/`resolved_by`, intern weiterhin `priority`/`isPriority` genannt), keine Abstufung nach Schweregrad. In der UI wird ein derart markierter Thread zusätzlich zum Badge farblich hervorgehoben (roter Rahmen/Hintergrund, siehe `.crispy-comments__thread--priority` in `comments.css`) — bei gleichzeitig angeheftetem Thread hat die Prioritäts-Hervorhebung Vorrang vor der Anheft-Hervorhebung.

### Thread einklappen

Threads mit Antworten lassen sich einzeln ein-/ausklappen, um lange Diskussionen kompakt zu halten. Dieser Zustand ist **rein clientseitig** (`state.collapsedThreads` in comments.js, keyed nach der Comment-ID des Elternkommentars) — er wird weder persistiert noch an den Server gesendet und geht bei einem vollständigen `load()` (z. B. nach dem Öffnen eines neuen Modals) verloren.

### Nur eingeschränkt wieder öffnen

**Auflösen** eines Threads bleibt an `canWrite()` gebunden — wer kommentieren darf, darf auch auflösen. **Wieder öffnen** eines bereits gelösten Threads ist dagegen auf eine explizite Positivliste beschränkt: den ursprünglichen Autor des Threads, die Person, die ihn aufgelöst hat, oder einen SUPERUSER (`CommentService::canReopenThread()`). `CommentApiController::processResolveRequest()` prüft das zusätzlich zu `canWrite()`, ausschließlich wenn `resolved: false` auf einen bereits aufgelösten Thread trifft — jemand, der zwischenzeitlich Schreibrechte verloren hat, kann also nicht mehr wieder öffnen, selbst wenn er ursprünglich aufgelöst hat.

### "Gesehen von"-Anzeige

Unter jedem Kommentar zeigt eine gestapelte Avatar-Liste (Tabler `avatar-list avatar-list-stacked`), wer den Kommentar bereits gelesen hat — mit Tooltip (Name) beim Hovern. Anders als man vermuten könnte, ist das **nicht** auf den Autor beschränkt: jeder Teilnehmer sieht dieselbe Liste (bewusste Design-Entscheidung, siehe `CommentService::presentComment()`s Kommentar zu `readBy`). Datenquelle ist die bereits vorhandene Lese-Tracking-Tabelle `crispy_comment_reads` — `CommentReadRepository::fetchGroupedForComments()` liefert sie gruppiert je Kommentar. Der Autor eines Kommentars taucht in seiner eigenen "Gesehen von"-Liste nie auf, da eigene Kommentare implizit als gelesen gelten und nie an `PUT .../read` gemeldet werden.

### Rollen-Erwähnungen

Neben `@username` können auch ganze Rollen erwähnt werden — alle Mitglieder der Rolle, die die Ressource laut `canParticipate()` sehen dürfen, erhalten dieselbe Mention-Mail. Da Rollennamen in diesem System **nicht eindeutig** sind (`cmscontrol_roles.name` hat keine Unique-Constraint), werden Rollen-Mentions als `@role:{id}` und nicht als `@RollenName` im Rohtext gespeichert — `Components/Comments.twig`/comments.js zeigen stattdessen den übersetzten Rollennamen an. `CommentService::resolveMentionedUsers()` löst beide Formen auf; `listForResource()` liefert zusätzlich zu `mentionableUsers` auch `mentionableRoles` für die kombinierte Autocomplete im Editor.

### "Tippt gerade…"-Anzeige

Während der Editor Fokus und nicht-leeren Inhalt hat, sendet comments.js alle 3 Sekunden ein Heartbeat an `PUT .../typing`. Andere geöffnete Threads pollen `GET .../typing` im selben Intervall und zeigen "X schreibt gerade…" unter dem Editor. Ein Heartbeat gilt nach `CommentTypingRepository::STALE_AFTER_SECONDS` (6s) als veraltet; `Crispy\Tasks\CommentTypingCleanupTask` räumt stündlich alte Zeilen aus `crispy_comment_typing` auf, rein zur Tabellenhygiene — die Frische-Prüfung selbst arbeitet bereits per Zeitstempel.

### Bearbeitungsverlauf

Jede Bearbeitung überschreibt nicht destruktiv: `CommentService::editComment()` schreibt den bisherigen Inhalt zuerst in `crispy_comment_edit_history`, bevor der neue Inhalt gespeichert wird. Der "(bearbeitet)"-Hinweis neben einem Kommentar ist klickbar und öffnet über `GET .../{commentId}/history` (`CommentService::getEditHistory()`) eine Liste aller früheren Versionen.

### Audit-Log

Jede verändernde Aktion — Erstellen, Bearbeiten, Löschen, Reagieren, Auflösen/Wieder-öffnen, Anheften/Loslösen, Hohe-Priorität markieren/entmarkieren, Beobachten/Nicht-mehr-Beobachten — wird über `AuditLogService::logManual()` protokolliert (`AuditLogCodes::COMMENT_*`, entity_type `comment` bzw. `comment_resource` für die Beobachtung, die keine eigene numerische ID hat). Die globale Audit-Log-Ansicht (`/admin/audit-log`) filtert auch nach diesen Entity-Typen. Eine ressourcenspezifische Audit-Trail-Ansicht (z. B. direkt neben `Components/Comments.twig`) existiert aktuell nicht — nur die globale Liste.

---

## API-Referenz

Alle Endpunkte erfordern eine gültige Backend-Session und `canParticipate()` des Providers; die mit „+ `canWrite()`" markierten Endpunkte prüfen zusätzlich `canWrite()`:

| Methode | Pfad | Aufgabe |
| :--- | :--- | :--- |
| `GET` | `/admin/api/comments/{type}/{id}` | Kommentare, erwähnbare Benutzer/Rollen, erlaubte Reaktionen, `canWrite`-Flag |
| `POST` | `/admin/api/comments/{type}/{id}` | Kommentar anlegen (`content`, optional `parent_id`) — + `canWrite()` |
| `PUT` | `/admin/api/comments/{type}/{id}/{commentId}` | Kommentar bearbeiten (`content`) |
| `DELETE` | `/admin/api/comments/{type}/{id}/{commentId}` | Kommentar löschen (Soft-Delete) |
| `PUT` | `/admin/api/comments/{type}/{id}/{commentId}/resolve` | Thread auflösen (`resolved: true`) — + `canWrite()`; wieder öffnen (`resolved: false`) eines bereits gelösten Threads — + `canWrite()` UND `canReopenThread()` (nur Autor/ursprünglicher Resolver/SUPERUSER) |
| `PUT` | `/admin/api/comments/{type}/{id}/{commentId}/pin` | Thread anheften/loslösen (`pinned: bool`) — nur Ressourcen-Besitzer/SUPERUSER (`canPinComment()`) |
| `PUT` | `/admin/api/comments/{type}/{id}/{commentId}/priority` | Thread als Hohe Priorität markieren/entmarkieren (`priority: bool`) — nur Ressourcen-Besitzer/SUPERUSER (`canSetPriority()`) |
| `GET` | `/admin/api/comments/{type}/{id}/{commentId}/history` | Frühere Inhaltsversionen eines bearbeiteten Kommentars |
| `PUT` | `/admin/api/comments/{type}/{id}/{commentId}/reactions` | Reaktion umschalten (`emoji`) — + `canWrite()` |
| `PUT` | `/admin/api/comments/{type}/{id}/read` | Kommentare als gelesen markieren (`comment_ids: int[]`) |
| `PUT` | `/admin/api/comments/{type}/{id}/watch` | Beobachtung starten/beenden (`watching: bool`) |
| `PUT` | `/admin/api/comments/{type}/{id}/typing` | "Tippt gerade"-Heartbeat setzen/löschen (`typing: bool`) |
| `GET` | `/admin/api/comments/{type}/{id}/typing` | Aktuell tippende Benutzer (für die Anzeige) |
