Kommentarsystem
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 vonCommentService).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 mitcanParticipate()— 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
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:
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:
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:
{% 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):
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
@benutzernamegeparst und nur gegengetMentionableUsers()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, Tabellecrispy_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 (Tabellecrispy_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) |