Refonte du système de notifications : passage aux notifications dérivées

Remplace le système de notifications stockées par un système de providers
qui dérivent les notifications des données existantes (commentaires, réponses,
demandes de projet, demandes de rendez-vous, validations de brief).

- Ajout du NotificationCollector et de l'interface NotificationProvider
- Création de 5 providers : Comment, Reply, ProjectRequest, AppointmentRequest, Content
- Métadonnées de notifications stockées directement sur les entités source
- Nouvelles routes mark-as-read et mark-all-read
- Mise à jour du frontend pour le nouveau système
- Route de migration pour les données existantes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-01-15 10:31:31 +01:00
parent c68b51f639
commit a7d315942a
26 changed files with 1406 additions and 137 deletions

View file

@ -24,6 +24,17 @@ tabs:
type: hidden
isValidated:
type: hidden
# Champs pour notification "content" (brief validé)
validatedBy:
type: hidden
validatedByName:
type: hidden
validatedByEmail:
type: hidden
validatedAt:
type: hidden
validationReadby:
type: hidden
pdf:
label: PDF
type: files

View file

@ -22,6 +22,19 @@ tabs:
fields:
stepName:
type: hidden
isValidated:
type: hidden
# Champs pour notification "content" (brief validé)
validatedBy:
type: hidden
validatedByName:
type: hidden
validatedByEmail:
type: hidden
validatedAt:
type: hidden
validationReadby:
type: hidden
pdf:
label: PDF
type: files

View file

@ -21,6 +21,7 @@ tabs:
fields:
lastCacheUpdate:
type: hidden
# Champs pour project-request
isClientRequest:
type: hidden
default: "false"
@ -30,6 +31,32 @@ tabs:
disabled: true
when:
isClientRequest: "true"
requestAuthor:
type: hidden
requestAuthorName:
type: hidden
requestAuthorEmail:
type: hidden
requestDate:
type: hidden
requestReadby:
type: hidden
# Champs pour appointment-request (DTL)
hasOptimizationRequest:
type: hidden
default: "false"
optimizationRequestDetails:
type: hidden
optimizationAuthor:
type: hidden
optimizationAuthorName:
type: hidden
optimizationAuthorEmail:
type: hidden
optimizationDate:
type: hidden
optimizationReadby:
type: hidden
currentStep:
label: Étape en cours
type: radio

View file

@ -40,6 +40,7 @@ return [
require(__DIR__ . '/routes/validate-brief.php'),
require(__DIR__ . '/routes/request-project-creation.php'),
require(__DIR__ . '/routes/request-optimization-appointment.php'),
require(__DIR__ . '/routes/migrate-notifications.php'),
],
'hooks' => [
'page.create:after' => require_once(__DIR__ . '/hooks/create-steps.php'),

View file

@ -0,0 +1,175 @@
<?php
/**
* Script de migration unique pour le système de notifications dérivées.
*
* Ce script copie les `readby[]` des anciennes notifications vers les sources de données.
* À exécuter une seule fois après le déploiement, puis à supprimer.
*
* Usage: POST /migrate-notifications.json
*/
return [
'pattern' => 'migrate-notifications.json',
'method' => 'POST',
'action' => function () {
$user = kirby()->user();
// Vérifier que l'utilisateur est admin
if (!$user || $user->role()->id() !== 'admin') {
return [
'status' => 'error',
'message' => 'Cette action nécessite les droits administrateur.'
];
}
$migrated = [
'comments' => 0,
'replies' => 0,
'project-requests' => 0,
'appointment-requests' => 0,
'content' => 0,
'errors' => []
];
$projects = page('projects')->children();
foreach ($projects as $project) {
// Récupérer les anciennes notifications
$notifications = $project->notifications()->yaml() ?? [];
if (empty($notifications)) {
continue;
}
foreach ($notifications as $notification) {
try {
$type = $notification['type'] ?? 'comment';
$id = $notification['id'] ?? null;
$readby = $notification['readby'] ?? [];
if (empty($id) || empty($readby)) {
continue;
}
switch ($type) {
case 'comment':
case 'comment-reply':
$fileUuid = $notification['location']['file']['uuid'] ?? null;
if (!$fileUuid) continue 2;
$file = kirby()->file($fileUuid);
if (!$file) continue 2;
$comments = Yaml::decode($file->comments()->value()) ?? [];
$updated = false;
foreach ($comments as &$comment) {
// Vérifier si c'est le commentaire principal
if ($comment['id'] === $id) {
$existingReadby = $comment['readby'] ?? [];
$comment['readby'] = array_values(array_unique(array_merge($existingReadby, $readby)));
$updated = true;
$migrated['comments']++;
break;
}
// Vérifier dans les réponses
foreach ($comment['replies'] ?? [] as &$reply) {
if ($reply['id'] === $id) {
$existingReadby = $reply['readby'] ?? [];
$reply['readby'] = array_values(array_unique(array_merge($existingReadby, $readby)));
$updated = true;
$migrated['replies']++;
break 2;
}
}
}
if ($updated) {
$file->update(['comments' => $comments]);
}
break;
case 'project-request':
$existingReadby = $project->requestReadby()->yaml() ?? [];
$newReadby = array_values(array_unique(array_merge($existingReadby, $readby)));
$updateData = ['requestReadby' => $newReadby];
// Migrer aussi les métadonnées si elles n'existent pas encore
if ($project->requestAuthor()->isEmpty() && isset($notification['author'])) {
$updateData['requestAuthor'] = $notification['author']['uuid'] ?? '';
$updateData['requestAuthorName'] = $notification['author']['name'] ?? '';
$updateData['requestAuthorEmail'] = $notification['author']['email'] ?? '';
$updateData['requestDate'] = $notification['date'] ?? '';
}
$project->update($updateData);
$migrated['project-requests']++;
break;
case 'appointment-request':
$existingReadby = $project->optimizationReadby()->yaml() ?? [];
$newReadby = array_values(array_unique(array_merge($existingReadby, $readby)));
$updateData = ['optimizationReadby' => $newReadby];
// Migrer aussi les métadonnées si elles n'existent pas encore
if ($project->optimizationAuthor()->isEmpty() && isset($notification['author'])) {
$updateData['optimizationAuthor'] = $notification['author']['uuid'] ?? '';
$updateData['optimizationAuthorName'] = $notification['author']['name'] ?? '';
$updateData['optimizationAuthorEmail'] = $notification['author']['email'] ?? '';
$updateData['optimizationDate'] = $notification['date'] ?? '';
}
$project->update($updateData);
$migrated['appointment-requests']++;
break;
case 'content':
$briefUri = $notification['location']['page']['uri'] ?? null;
if (!$briefUri) continue 2;
$brief = page($briefUri);
if (!$brief) continue 2;
$existingReadby = $brief->validationReadby()->yaml() ?? [];
$newReadby = array_values(array_unique(array_merge($existingReadby, $readby)));
$updateData = ['validationReadby' => $newReadby];
// Migrer aussi les métadonnées si elles n'existent pas encore
if ($brief->validatedBy()->isEmpty() && isset($notification['author'])) {
$updateData['validatedBy'] = $notification['author']['uuid'] ?? '';
$updateData['validatedByName'] = $notification['author']['name'] ?? '';
$updateData['validatedByEmail'] = $notification['author']['email'] ?? '';
$updateData['validatedAt'] = $notification['date'] ?? '';
}
$brief->update($updateData);
$migrated['content']++;
break;
}
} catch (\Throwable $th) {
$migrated['errors'][] = [
'project' => $project->title()->value(),
'notification_id' => $id ?? 'unknown',
'type' => $type ?? 'unknown',
'error' => $th->getMessage()
];
}
}
}
$total = $migrated['comments'] + $migrated['replies'] +
$migrated['project-requests'] + $migrated['appointment-requests'] +
$migrated['content'];
return [
'status' => 'success',
'message' => "Migration terminée. $total notifications migrées.",
'details' => $migrated
];
}
];

View file

@ -7,37 +7,26 @@ return [
$json = file_get_contents('php://input');
$data = json_decode($json);
$user = kirby()->user();
$user = kirby()->user();
$project = page($data->projectUri);
$date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601);
try {
$newProject = $project->update([
$project->update([
"hasOptimizationRequest" => "true",
"optimizationRequestDetails" => esc("De la part de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details)
"optimizationRequestDetails" => esc("De la part de " . $user->name() . " (" . $user->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details),
// Métadonnées pour le système de notifications dérivées
"optimizationAuthor" => (string) $user->uuid(),
"optimizationAuthorName" => (string) $user->name(),
"optimizationAuthorEmail" => (string) $user->email(),
"optimizationDate" => $formattedDate,
"optimizationReadby" => [],
]);
} catch (\Throwable $th) {
return [
"status" => "error",
"message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine()
];
}
try {
$date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601);
$notificationData = [
"location" => [
"page" => $newProject
],
"date" => (string) $formattedDate,
"text" => nl2br("Objet : " . $data->subject . "\n" . esc($data->details)),
"author" => $user,
"id" => Str::uuid(),
"type" => "appointment-request",
];
$newProject->createNotification($notificationData);
// Note: Les notifications sont maintenant dérivées.
// Plus besoin d'appeler createNotification().
return [
"status" => "success",
@ -45,7 +34,7 @@ return [
} catch (\Throwable $th) {
return [
"status" => "error",
"message" => "Can't create notification. " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine()
"message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine()
];
}
}

View file

@ -11,15 +11,24 @@ return [
$client = kirby()->user()->client()->toPage()->uuid();
$date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601);
$projectData = [
"slug" => esc(Str::slug($data->title)),
"template" => "project",
"template" => "project",
"content" => [
"title" => esc($data->title),
"requestDetails" => esc("Demande de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . $data->details),
"requestDetails" => esc("Demande de " . $user->name() . " (" . $user->email() . ") : \n\n" . $data->details),
"client" => [$client],
"isClientRequest" => "true",
"isDTLEnabled" => esc($data->isDTLEnabled)
"isDTLEnabled" => esc($data->isDTLEnabled),
// Métadonnées pour le système de notifications dérivées
"requestAuthor" => (string) $user->uuid(),
"requestAuthorName" => (string) $user->name(),
"requestAuthorEmail" => (string) $user->email(),
"requestDate" => $formattedDate,
"requestReadby" => [],
]
];
@ -27,21 +36,8 @@ return [
try {
$newProject = $projects->createChild($projectData);
$date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601);
$notificationData = [
"location" => [
"page" => $newProject
],
"date" => (string) $formattedDate,
"text" => nl2br(esc($data->details)),
"author" => $user,
"id" => Str::uuid(),
"type" => "project-request",
];
$newProject->createNotification($notificationData);
// Note: Les notifications sont maintenant dérivées.
// Plus besoin d'appeler createNotification().
return [
"status" => "success",

View file

@ -9,27 +9,24 @@ return [
$page = page($data->briefUri);
$project = $page->parent();
try {
$newPage = $page->update([
'isValidated' => 'true'
]);
$user = kirby()->user();
try {
$timezone = new DateTimeZone('Europe/Paris');
$dateTime = new DateTime('now', $timezone);
$notification = [
'location' => [
'page' => $page,
],
'date' => $dateTime->format('Y-m-d\TH:i:sP'),
'text' => "Nouveau brief",
'author' => kirby()->user(),
'id' => Str::uuid(),
'type' => 'content'
];
$newPage = $page->update([
'isValidated' => 'true',
// Métadonnées pour le système de notifications dérivées
'validatedBy' => (string) $user->uuid(),
'validatedByName' => (string) $user->name(),
'validatedByEmail' => (string) $user->email(),
'validatedAt' => $dateTime->format('Y-m-d\TH:i:sP'),
'validationReadby' => [],
]);
$project->createNotification($notification);
// Note: Les notifications sont maintenant dérivées.
// Plus besoin d'appeler createNotification().
return [
"success" => "'" . $project->title()->value() . "' brief validated."

View file

@ -46,6 +46,7 @@ return [
'author' => kirby()->user(),
'id' => Str::uuid(),
'type' => 'comment',
'readby' => [], // Pour le système de notifications dérivées
];
if (isset($data->position->pageIndex)) {
@ -62,11 +63,8 @@ return [
echo json_encode(getFileData($newFile));
try {
$project->createNotification($commentData);
} catch (\Throwable $th) {
throw new Exception($th->getMessage() . '. line ' . $th->getLine() . ' in file ' . $th->getFile(), 1);
}
// Note: Les notifications sont maintenant dérivées des commentaires.
// Plus besoin d'appeler createNotification().
exit;
},

View file

@ -39,8 +39,8 @@ return [
echo json_encode(getFileData($newFile));
$project = $page->parents()->findBy('template', 'project');
$project->deleteNotification($data->id);
// Note: Les notifications sont maintenant dérivées des commentaires.
// La suppression du commentaire supprime automatiquement la notification.
exit;
},

View file

@ -31,18 +31,19 @@ return [
"author" => kirby()->user(),
"id" => Str::uuid(),
"type" => "comment-reply",
"readby" => [], // Pour le système de notifications dérivées
];
$newReply = new Reply($replyData);
$comment['replies'][] = $newReply->toArray();
}
}
$newFile = $file->update([
'comments' => $comments
]);
$project = $page->parents()->findBy("template", "project");
$project->createNotification($replyData);
// Note: Les notifications sont maintenant dérivées des commentaires.
// Plus besoin d'appeler createNotification().
return getFileData($newFile);
}

View file

@ -1,27 +1,54 @@
<?php
load([
"ProjectPage" => "models/ProjectPage.php",
], __DIR__);
use adrienpayet\notifications\NotificationCollector;
use adrienpayet\notifications\providers\CommentProvider;
use adrienpayet\notifications\providers\ReplyProvider;
use adrienpayet\notifications\providers\ProjectRequestProvider;
use adrienpayet\notifications\providers\AppointmentRequestProvider;
use adrienpayet\notifications\providers\ContentProvider;
// Charger les classes
F::loadClasses([
// Own classes
"adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php",
"adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php",
// Shared classes
"adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php",
"adrienpayet\\D2P\data\Author" => __DIR__ . "/../classes/Author.php",
"adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php",
"adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php",
"adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php",
"adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php",
// Nouvelles classes - Système de providers
"adrienpayet\\notifications\\NotificationProvider" => __DIR__ . "/src/NotificationProvider.php",
"adrienpayet\\notifications\\NotificationCollector" => __DIR__ . "/src/NotificationCollector.php",
"adrienpayet\\notifications\\providers\\CommentProvider" => __DIR__ . "/src/providers/CommentProvider.php",
"adrienpayet\\notifications\\providers\\ReplyProvider" => __DIR__ . "/src/providers/ReplyProvider.php",
"adrienpayet\\notifications\\providers\\ProjectRequestProvider" => __DIR__ . "/src/providers/ProjectRequestProvider.php",
"adrienpayet\\notifications\\providers\\AppointmentRequestProvider" => __DIR__ . "/src/providers/AppointmentRequestProvider.php",
"adrienpayet\\notifications\\providers\\ContentProvider" => __DIR__ . "/src/providers/ContentProvider.php",
// Anciennes classes - Gardées pour rétro-compatibilité pendant migration
"adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php",
"adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php",
// Classes partagées
"adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php",
"adrienpayet\\D2P\\data\\Author" => __DIR__ . "/../classes/Author.php",
"adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php",
"adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php",
"adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php",
"adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php",
]);
// Créer et configurer le collector
$collector = new NotificationCollector();
$collector->register(new CommentProvider());
$collector->register(new ReplyProvider());
$collector->register(new ProjectRequestProvider());
$collector->register(new AppointmentRequestProvider());
$collector->register(new ContentProvider());
Kirby::plugin("adrienpayet/pdc-notifications", [
"routes" => [
require(__DIR__ . "/routes/readAll.php"),
require(__DIR__ . "/routes/read.php")
],
"options" => [
"collector" => $collector
],
"routes" => [
// Nouvelles routes
require(__DIR__ . "/routes/mark-as-read.php"),
require(__DIR__ . "/routes/mark-all-read.php"),
// Anciennes routes - Gardées pour rétro-compatibilité
require(__DIR__ . "/routes/readAll.php"),
require(__DIR__ . "/routes/read.php"),
],
]);

View file

@ -0,0 +1,42 @@
<?php
/**
* Route pour marquer toutes les notifications comme lues.
* Parcourt tous les projets accessibles à l'utilisateur.
*/
return [
'pattern' => '(:all)mark-all-notifications-read.json',
'method' => 'POST',
'action' => function () {
try {
$user = kirby()->user();
if (!$user) {
throw new Exception('User not authenticated');
}
$collector = kirby()->option('adrienpayet.pdc-notifications.collector');
if (!$collector) {
throw new Exception('NotificationCollector not initialized');
}
// Récupérer les projets selon le rôle
if ($user->role()->name() === 'admin') {
$projects = page('projects')->children()->toArray();
} else {
$projects = $user->projects()->toPages()->toArray();
}
$count = $collector->markAllAsRead($projects, $user);
return json_encode([
'status' => 'success',
'message' => "$count notifications marked as read"
]);
} catch (\Throwable $th) {
return json_encode([
'status' => 'error',
'message' => $th->getMessage()
]);
}
}
];

View file

@ -0,0 +1,46 @@
<?php
/**
* Route pour marquer une notification comme lue.
* Délègue au bon provider selon le type de notification.
*/
return [
'pattern' => '(:all)mark-notification-read.json',
'method' => 'POST',
'action' => function () {
$json = file_get_contents('php://input');
$data = json_decode($json);
if (!$data || !isset($data->type) || !isset($data->id)) {
return json_encode([
'status' => 'error',
'message' => 'Missing required fields: type, id'
]);
}
try {
$collector = kirby()->option('adrienpayet.pdc-notifications.collector');
if (!$collector) {
throw new Exception('NotificationCollector not initialized');
}
$success = $collector->markAsRead(
$data->type,
$data->id,
(array) $data,
kirby()->user()
);
return json_encode([
'status' => $success ? 'success' : 'error',
'message' => $success ? 'Notification marked as read' : 'Failed to mark notification as read'
]);
} catch (\Throwable $th) {
return json_encode([
'status' => 'error',
'message' => $th->getMessage()
]);
}
}
];

View file

@ -0,0 +1,123 @@
<?php
namespace adrienpayet\notifications;
use Kirby\Cms\Page;
use Kirby\Cms\User;
/**
* Collecteur de notifications qui agrège tous les providers.
*
* Permet de :
* - Enregistrer des providers de notifications
* - Collecter toutes les notifications de tous les providers
* - Déléguer le markAsRead au bon provider
*/
class NotificationCollector
{
/** @var NotificationProvider[] */
private array $providers = [];
/**
* Enregistre un nouveau provider.
*/
public function register(NotificationProvider $provider): void
{
$this->providers[$provider->getType()] = $provider;
}
/**
* Collecte toutes les notifications de tous les providers pour un projet.
*
* @param Page $project Le projet à scanner
* @param User $user L'utilisateur courant
* @return array Liste triée par date décroissante
*/
public function collect(Page $project, User $user): array
{
$all = [];
foreach ($this->providers as $provider) {
try {
$notifications = $provider->collect($project, $user);
$all = array_merge($all, $notifications);
} catch (\Throwable $e) {
// Log l'erreur mais continue avec les autres providers
error_log("NotificationCollector: Error in {$provider->getType()}: " . $e->getMessage());
}
}
// Trier par date décroissante
usort($all, function ($a, $b) {
$dateA = strtotime($a['date'] ?? '0');
$dateB = strtotime($b['date'] ?? '0');
return $dateB - $dateA;
});
return $all;
}
/**
* Marque une notification comme lue en déléguant au bon provider.
*
* @param string $type Le type de notification
* @param string $id L'identifiant de la notification
* @param array $location Informations de localisation
* @param User $user L'utilisateur qui marque comme lu
* @return bool True si succès
*/
public function markAsRead(string $type, string $id, array $location, User $user): bool
{
if (!isset($this->providers[$type])) {
error_log("NotificationCollector: Unknown notification type: $type");
return false;
}
try {
return $this->providers[$type]->markAsRead($id, $location, $user);
} catch (\Throwable $e) {
error_log("NotificationCollector: Error marking as read: " . $e->getMessage());
return false;
}
}
/**
* Marque toutes les notifications comme lues pour un utilisateur.
*
* @param Page[] $projects Liste des projets
* @param User $user L'utilisateur
* @return int Nombre de notifications marquées comme lues
*/
public function markAllAsRead(array $projects, User $user): int
{
$count = 0;
foreach ($projects as $project) {
$notifications = $this->collect($project, $user);
foreach ($notifications as $notification) {
if (!($notification['isRead'] ?? false)) {
$success = $this->markAsRead(
$notification['type'],
$notification['id'],
$notification,
$user
);
if ($success) {
$count++;
}
}
}
}
return $count;
}
/**
* Retourne la liste des types de notifications enregistrés.
*/
public function getRegisteredTypes(): array
{
return array_keys($this->providers);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace adrienpayet\notifications;
use Kirby\Cms\Page;
use Kirby\Cms\User;
/**
* Interface pour les providers de notifications.
*
* Chaque type de notification (comment, project-request, etc.)
* a son propre provider qui sait :
* - Collecter les notifications depuis la source de données
* - Marquer une notification comme lue sur la source
*/
interface NotificationProvider
{
/**
* Retourne le type de notification géré par ce provider.
* Ex: 'comment', 'comment-reply', 'project-request'
*/
public function getType(): string;
/**
* Collecte toutes les notifications de ce type pour un projet et un utilisateur.
*
* @param Page $project Le projet à scanner
* @param User $user L'utilisateur courant (pour filtrer ses propres actions)
* @return array Liste des notifications au format standard
*/
public function collect(Page $project, User $user): array;
/**
* Marque une notification comme lue.
*
* @param string $id L'identifiant de la notification
* @param array $location Informations de localisation (ex: _file, _projectUri)
* @param User $user L'utilisateur qui marque comme lu
* @return bool True si succès, false sinon
*/
public function markAsRead(string $id, array $location, User $user): bool;
}

View file

@ -0,0 +1,111 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "appointment-request".
* Dérivé depuis les champs du projet quand hasOptimizationRequest est true.
*/
class AppointmentRequestProvider implements NotificationProvider
{
public function getType(): string
{
return 'appointment-request';
}
public function collect(Page $project, User $user): array
{
// Pas de notification si pas de demande d'optimisation
if ($project->hasOptimizationRequest()->isFalse()) {
return [];
}
// Vérifier que les champs requis existent
if ($project->optimizationAuthor()->isEmpty()) {
return [];
}
$userUuid = (string) $user->uuid();
$authorUuid = $project->optimizationAuthor()->value();
// Ne pas notifier l'auteur de sa propre demande
if ($authorUuid === $userUuid) {
return [];
}
$readby = $project->optimizationReadby()->isNotEmpty()
? Yaml::decode($project->optimizationReadby()->value())
: [];
if (!is_array($readby)) {
$readby = [];
}
return [[
'id' => 'appointment-request-' . (string) $project->uuid(),
'type' => 'appointment-request',
'text' => $project->optimizationRequestDetails()->value() ?? '',
'author' => [
'uuid' => $authorUuid,
'name' => $project->optimizationAuthorName()->value() ?? '',
'email' => $project->optimizationAuthorEmail()->value() ?? '',
'role' => 'client',
],
'date' => $project->optimizationDate()->value() ?? '',
'location' => [
'page' => [
'uri' => $project->uri(),
'title' => (string) $project->title(),
'template' => 'project',
],
'project' => [
'uri' => $project->uri(),
'title' => (string) $project->title(),
]
],
'readby' => $readby,
'isRead' => in_array($userUuid, $readby),
'_projectUri' => $project->uri(),
]];
}
public function markAsRead(string $id, array $location, User $user): bool
{
$projectUri = $location['_projectUri'] ?? null;
if (!$projectUri) {
return false;
}
$project = page($projectUri);
if (!$project) {
return false;
}
$readby = $project->optimizationReadby()->isNotEmpty()
? Yaml::decode($project->optimizationReadby()->value())
: [];
if (!is_array($readby)) {
$readby = [];
}
$userUuid = (string) $user->uuid();
if (in_array($userUuid, $readby)) {
return true;
}
$readby[] = $userUuid;
$project->update([
'optimizationReadby' => array_unique($readby)
]);
return true;
}
}

View file

@ -0,0 +1,172 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "comment".
* Collecte les commentaires depuis les fichiers des étapes du projet.
*/
class CommentProvider implements NotificationProvider
{
public function getType(): string
{
return 'comment';
}
public function collect(Page $project, User $user): array
{
$notifications = [];
$userUuid = (string) $user->uuid();
// Parcourir toutes les étapes du projet
foreach ($project->children() as $step) {
// Parcourir tous les fichiers de chaque étape
foreach ($step->files() as $file) {
if ($file->comments()->isEmpty()) {
continue;
}
$comments = Yaml::decode($file->comments()->value());
if (!is_array($comments)) {
continue;
}
foreach ($comments as $comment) {
// Ignorer les commentaires de type reply (gérés par ReplyProvider)
if (($comment['type'] ?? 'comment') === 'comment-reply') {
continue;
}
// Ne pas notifier l'auteur de son propre commentaire
$authorUuid = $comment['author']['uuid'] ?? '';
if ($authorUuid === $userUuid) {
continue;
}
$readby = $comment['readby'] ?? [];
$location = $comment['location'] ?? [];
// Assurer que location.project existe toujours
if (!isset($location['project'])) {
$location['project'] = [
'uri' => $project->uri(),
'title' => (string) $project->title(),
];
}
$notifications[] = [
'id' => $comment['id'],
'type' => 'comment',
'text' => $comment['text'] ?? '',
'author' => $comment['author'] ?? [],
'date' => $comment['date'] ?? '',
'location' => $location,
'position' => $comment['position'] ?? [],
'readby' => $readby,
'isRead' => in_array($userUuid, $readby),
// Métadonnées pour markAsRead
'_file' => (string) $file->uuid(),
'_stepUri' => $step->uri(),
];
}
}
// Parcourir aussi les sous-pages (ex: tracks dans virtual-sample)
foreach ($step->children() as $subPage) {
foreach ($subPage->files() as $file) {
if ($file->comments()->isEmpty()) {
continue;
}
$comments = Yaml::decode($file->comments()->value());
if (!is_array($comments)) {
continue;
}
foreach ($comments as $comment) {
if (($comment['type'] ?? 'comment') === 'comment-reply') {
continue;
}
$authorUuid = $comment['author']['uuid'] ?? '';
if ($authorUuid === $userUuid) {
continue;
}
$readby = $comment['readby'] ?? [];
$location = $comment['location'] ?? [];
// Assurer que location.project existe toujours
if (!isset($location['project'])) {
$location['project'] = [
'uri' => $project->uri(),
'title' => (string) $project->title(),
];
}
$notifications[] = [
'id' => $comment['id'],
'type' => 'comment',
'text' => $comment['text'] ?? '',
'author' => $comment['author'] ?? [],
'date' => $comment['date'] ?? '',
'location' => $location,
'position' => $comment['position'] ?? [],
'readby' => $readby,
'isRead' => in_array($userUuid, $readby),
'_file' => (string) $file->uuid(),
'_stepUri' => $subPage->uri(),
];
}
}
}
}
return $notifications;
}
public function markAsRead(string $id, array $location, User $user): bool
{
$fileUuid = $location['_file'] ?? null;
if (!$fileUuid) {
return false;
}
// Trouver le fichier par UUID (peut être avec ou sans préfixe file://)
$fileUri = str_starts_with($fileUuid, 'file://') ? $fileUuid : 'file://' . $fileUuid;
$file = kirby()->file($fileUri);
if (!$file) {
return false;
}
$comments = Yaml::decode($file->comments()->value());
if (!is_array($comments)) {
return false;
}
$userUuid = (string) $user->uuid();
$updated = false;
foreach ($comments as &$comment) {
if ($comment['id'] === $id) {
$comment['readby'] = $comment['readby'] ?? [];
if (!in_array($userUuid, $comment['readby'])) {
$comment['readby'][] = $userUuid;
$updated = true;
}
break;
}
}
if ($updated) {
$file->update(['comments' => $comments]);
}
return $updated;
}
}

View file

@ -0,0 +1,128 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "content".
* Dérivé depuis les briefs validés (isValidated = true).
*/
class ContentProvider implements NotificationProvider
{
public function getType(): string
{
return 'content';
}
public function collect(Page $project, User $user): array
{
$notifications = [];
$userUuid = (string) $user->uuid();
// Chercher les briefs validés (client-brief et extended-brief)
$briefTemplates = ['client-brief', 'extended-brief'];
foreach ($project->children() as $step) {
if (!in_array($step->intendedTemplate()->name(), $briefTemplates)) {
continue;
}
// Pas de notification si le brief n'est pas validé
if ($step->isValidated()->isFalse()) {
continue;
}
// Vérifier que les champs requis existent
if ($step->validatedBy()->isEmpty()) {
continue;
}
$authorUuid = $step->validatedBy()->value();
// Ne pas notifier l'auteur de sa propre validation
if ($authorUuid === $userUuid) {
continue;
}
$readby = $step->validationReadby()->isNotEmpty()
? Yaml::decode($step->validationReadby()->value())
: [];
if (!is_array($readby)) {
$readby = [];
}
$stepLabel = $step->intendedTemplate()->name() === 'client-brief'
? 'Brief client'
: 'Brief étendu';
$notifications[] = [
'id' => 'content-' . (string) $step->uuid(),
'type' => 'content',
'text' => "Nouveau $stepLabel validé",
'author' => [
'uuid' => $authorUuid,
'name' => $step->validatedByName()->value() ?? '',
'email' => $step->validatedByEmail()->value() ?? '',
'role' => 'client',
],
'date' => $step->validatedAt()->value() ?? '',
'location' => [
'page' => [
'uri' => $step->uri(),
'title' => (string) $step->title(),
'template' => $step->intendedTemplate()->name(),
],
'project' => [
'uri' => $project->uri(),
'title' => (string) $project->title(),
]
],
'readby' => $readby,
'isRead' => in_array($userUuid, $readby),
'_briefUri' => $step->uri(),
];
}
return $notifications;
}
public function markAsRead(string $id, array $location, User $user): bool
{
$briefUri = $location['_briefUri'] ?? null;
if (!$briefUri) {
return false;
}
$brief = page($briefUri);
if (!$brief) {
return false;
}
$readby = $brief->validationReadby()->isNotEmpty()
? Yaml::decode($brief->validationReadby()->value())
: [];
if (!is_array($readby)) {
$readby = [];
}
$userUuid = (string) $user->uuid();
if (in_array($userUuid, $readby)) {
return true;
}
$readby[] = $userUuid;
$brief->update([
'validationReadby' => array_unique($readby)
]);
return true;
}
}

View file

@ -0,0 +1,111 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "project-request".
* Dérivé depuis les champs du projet quand isClientRequest est true.
*/
class ProjectRequestProvider implements NotificationProvider
{
public function getType(): string
{
return 'project-request';
}
public function collect(Page $project, User $user): array
{
// Pas de notification si ce n'est pas une demande client
if ($project->isClientRequest()->isFalse()) {
return [];
}
// Vérifier que les champs requis existent
if ($project->requestAuthor()->isEmpty()) {
return [];
}
$userUuid = (string) $user->uuid();
$authorUuid = $project->requestAuthor()->value();
// Ne pas notifier l'auteur de sa propre demande
if ($authorUuid === $userUuid) {
return [];
}
$readby = $project->requestReadby()->isNotEmpty()
? Yaml::decode($project->requestReadby()->value())
: [];
if (!is_array($readby)) {
$readby = [];
}
return [[
'id' => 'project-request-' . (string) $project->uuid(),
'type' => 'project-request',
'text' => $project->requestDetails()->value() ?? '',
'author' => [
'uuid' => $authorUuid,
'name' => $project->requestAuthorName()->value() ?? '',
'email' => $project->requestAuthorEmail()->value() ?? '',
'role' => 'client',
],
'date' => $project->requestDate()->value() ?? '',
'location' => [
'page' => [
'uri' => $project->uri(),
'title' => (string) $project->title(),
'template' => 'project',
],
'project' => [
'uri' => $project->uri(),
'title' => (string) $project->title(),
]
],
'readby' => $readby,
'isRead' => in_array($userUuid, $readby),
'_projectUri' => $project->uri(),
]];
}
public function markAsRead(string $id, array $location, User $user): bool
{
$projectUri = $location['_projectUri'] ?? null;
if (!$projectUri) {
return false;
}
$project = page($projectUri);
if (!$project) {
return false;
}
$readby = $project->requestReadby()->isNotEmpty()
? Yaml::decode($project->requestReadby()->value())
: [];
if (!is_array($readby)) {
$readby = [];
}
$userUuid = (string) $user->uuid();
if (in_array($userUuid, $readby)) {
return true; // Déjà lu
}
$readby[] = $userUuid;
$project->update([
'requestReadby' => array_unique($readby)
]);
return true;
}
}

View file

@ -0,0 +1,142 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "comment-reply".
* Collecte les réponses aux commentaires depuis les fichiers.
*/
class ReplyProvider implements NotificationProvider
{
public function getType(): string
{
return 'comment-reply';
}
public function collect(Page $project, User $user): array
{
$notifications = [];
$userUuid = (string) $user->uuid();
// Parcourir toutes les étapes du projet
foreach ($project->children() as $step) {
$this->collectFromPage($step, $project, $userUuid, $notifications);
// Parcourir aussi les sous-pages (ex: tracks)
foreach ($step->children() as $subPage) {
$this->collectFromPage($subPage, $project, $userUuid, $notifications);
}
}
return $notifications;
}
private function collectFromPage(Page $page, Page $project, string $userUuid, array &$notifications): void
{
foreach ($page->files() as $file) {
if ($file->comments()->isEmpty()) {
continue;
}
$comments = Yaml::decode($file->comments()->value());
if (!is_array($comments)) {
continue;
}
foreach ($comments as $comment) {
$replies = $comment['replies'] ?? [];
foreach ($replies as $reply) {
// Ne pas notifier l'auteur de sa propre réponse
$authorUuid = $reply['author']['uuid'] ?? '';
if ($authorUuid === $userUuid) {
continue;
}
$readby = $reply['readby'] ?? [];
$location = $reply['location'] ?? $comment['location'] ?? [];
// Assurer que location.project existe toujours
if (!isset($location['project'])) {
$location['project'] = [
'uri' => $project->uri(),
'title' => (string) $project->title(),
];
}
$notifications[] = [
'id' => $reply['id'],
'type' => 'comment-reply',
'text' => $reply['text'] ?? '',
'author' => $reply['author'] ?? [],
'date' => $reply['date'] ?? '',
'location' => $location,
'position' => $reply['position'] ?? $comment['position'] ?? [],
'readby' => $readby,
'isRead' => in_array($userUuid, $readby),
// Métadonnées pour markAsRead
'_file' => (string) $file->uuid(),
'_parentCommentId' => $comment['id'],
'_stepUri' => $page->uri(),
];
}
}
}
}
public function markAsRead(string $id, array $location, User $user): bool
{
$fileUuid = $location['_file'] ?? null;
$parentCommentId = $location['_parentCommentId'] ?? null;
if (!$fileUuid) {
return false;
}
// Trouver le fichier par UUID (peut être avec ou sans préfixe file://)
$fileUri = str_starts_with($fileUuid, 'file://') ? $fileUuid : 'file://' . $fileUuid;
$file = kirby()->file($fileUri);
if (!$file) {
return false;
}
$comments = Yaml::decode($file->comments()->value());
if (!is_array($comments)) {
return false;
}
$userUuid = (string) $user->uuid();
$updated = false;
foreach ($comments as &$comment) {
// Si on a l'ID du parent, l'utiliser pour cibler
if ($parentCommentId && $comment['id'] !== $parentCommentId) {
continue;
}
$replies = &$comment['replies'] ?? [];
foreach ($replies as &$reply) {
if ($reply['id'] === $id) {
$reply['readby'] = $reply['readby'] ?? [];
if (!in_array($userUuid, $reply['readby'])) {
$reply['readby'][] = $userUuid;
$updated = true;
}
break 2;
}
}
}
if ($updated) {
$file->update(['comments' => $comments]);
}
return $updated;
}
}

View file

@ -7,24 +7,37 @@ if (!$kirby->user()) {
]);
}
function getProjectData($project)
{
// Récupérer le collector de notifications
$notificationCollector = $kirby->option('adrienpayet.pdc-notifications.collector');
function getProjectData($project, $user, $collector)
{
// Utiliser le nouveau système de notifications dérivées
$notifications = [];
if ($collector) {
try {
$notifications = $collector->collect($project, $user);
} catch (\Throwable $e) {
error_log("Error collecting notifications for project {$project->uri()}: " . $e->getMessage());
$notifications = [];
}
}
$data = [
'title' => $project->title()->value(),
'url' => $project->url(),
'uri' => '/' . $project->uri(),
'modified' => $project->modified('Y-MM-d'),
'currentStep' => $project->currentStep()->value(),
'status' => $project->status(),
'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '',
'steps' => $project->getSteps(),
'notifications' => Yaml::decode($project->notifications()->value),
'uuid' => (string) $project->uuid(),
'slug' => (string) $project->slug(),
'isDTLEnabled' => $project->isDTLEnabled()->isTrue(),
'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(),
];
'title' => $project->title()->value(),
'url' => $project->url(),
'uri' => '/' . $project->uri(),
'modified' => $project->modified('Y-MM-d'),
'currentStep' => $project->currentStep()->value(),
'status' => $project->status(),
'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '',
'steps' => $project->getSteps(),
'notifications' => $notifications,
'uuid' => (string) $project->uuid(),
'slug' => (string) $project->slug(),
'isDTLEnabled' => $project->isDTLEnabled()->isTrue(),
'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(),
];
if ($project->isDTLEnabled()) {
$data['designToLight'] = processDTLProposals($project);
@ -33,8 +46,12 @@ function getProjectData($project)
return $data;
}
$currentUser = $kirby->user();
try {
$children = $kirby->user()->role() == 'admin' ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project))->values() : $kirby->user()->projects()->toPages()->map(fn($project) => getProjectData($project))->values();
$children = $currentUser->role() == 'admin'
? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project, $currentUser, $notificationCollector))->values()
: $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser, $notificationCollector))->values();
} catch (\Throwable $th) {
throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1);
$children = [];

View file

@ -163,6 +163,34 @@ export const useApiStore = defineStore("api", () => {
}
}
/**
* Marque une notification comme lue.
* @param {Object} notification - L'objet notification complet (avec type, id, _file, _projectUri, etc.)
*/
async function markNotificationRead(notification) {
const headers = {
method: "POST",
body: JSON.stringify(notification),
};
try {
const response = await fetch("/mark-notification-read.json", headers);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.status === "error") {
throw new Error(data.message);
}
// Mettre à jour le store local
userStore.markNotificationRead(notification.id, notification.project?.uri || notification._projectUri);
return data;
} catch (error) {
console.error("Erreur lors du marquage de la notification:", error);
throw error;
}
}
// Ancienne fonction gardée pour rétro-compatibilité
async function readNotification(notificationId, projectId) {
const headers = {
method: "POST",
@ -215,6 +243,31 @@ export const useApiStore = defineStore("api", () => {
}
}
/**
* Marque toutes les notifications comme lues (nouveau système).
*/
async function markAllNotificationsRead() {
try {
const response = await fetch("/mark-all-notifications-read.json", {
method: "POST",
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.status === "error") {
throw new Error(data.message);
}
userStore.markAllNotificationsRead();
console.log("Toutes les notifications ont été marquées comme lues.");
return data;
} catch (error) {
console.error("Erreur lors du marquage de toutes les notifications:", error);
throw error;
}
}
// Ancienne fonction gardée pour rétro-compatibilité
async function readAllNotifications() {
try {
const response = await fetch("/read-all-notifications.json");
@ -243,6 +296,10 @@ export const useApiStore = defineStore("api", () => {
updateComment,
deleteComment,
replyComment,
// Nouvelles fonctions
markNotificationRead,
markAllNotificationsRead,
// Anciennes fonctions (rétro-compatibilité)
readNotification,
readAllNotifications,
validateBrief,

View file

@ -11,48 +11,76 @@ export const useUserStore = defineStore('user', () => {
const { projects } = storeToRefs(useProjectsStore());
/**
* Liste des notifications agrégées depuis tous les projets.
* Les notifications sont maintenant dérivées côté backend avec isRead pré-calculé.
*/
const notifications = computed(() => {
return projects.value?.flatMap((project) => {
if (!projects.value || !user.value) return [];
return projects.value.flatMap((project) => {
if (!project.notifications) return [];
return project.notifications
.filter((notification) => notification.author.uuid !== user.value.uuid)
.map((notification) => ({
...notification,
project: project,
isRead: notification.readby?.includes(user.value.uuid),
}));
return project.notifications.map((notification) => ({
...notification,
project: project,
// isRead est maintenant fourni par le backend
}));
});
});
function readNotification(notificationId, projectId) {
console.log('Read notification', notificationId, projectId);
/**
* Marque une notification comme lue dans le store local.
* @param {string} notificationId - L'ID de la notification
* @param {string} projectUri - L'URI du projet (optionnel, pour retrouver le projet)
*/
function markNotificationRead(notificationId, projectUri = null) {
if (!user.value?.uuid) return;
projects.value = projects.value.map((project) => {
// Si projectUri fourni, cibler le bon projet
if (projectUri && project.uri !== projectUri && `/${project.uri}` !== projectUri) {
return project;
}
return {
...project,
notifications: (project.notifications || []).map((notification) =>
notification.id === notificationId
? {
...notification,
isRead: true,
readby: [...new Set([...(notification.readby || []), user.value.uuid])],
}
: notification
),
};
});
}
/**
* Marque toutes les notifications comme lues dans le store local.
*/
function markAllNotificationsRead() {
if (!user.value?.uuid) return;
projects.value = projects.value.map((project) => ({
...project,
notifications:
project.uuid === projectId || project.uri === projectId
? project.notifications.map((notification) =>
notification.id === notificationId
? {
...notification,
readby: [
...new Set([...notification.readby, user.value.uuid]),
],
}
: notification
)
: project.notifications,
notifications: (project.notifications || []).map((notification) => ({
...notification,
isRead: true,
readby: [...new Set([...(notification.readby || []), user.value.uuid])],
})),
}));
}
// Anciennes fonctions gardées pour rétro-compatibilité
function readNotification(notificationId, projectId) {
markNotificationRead(notificationId, projectId);
}
function readAllNotifications() {
projects.value = projects.value.map((project) => ({
...project,
notifications: project.notifications.map((notification) => ({
...notification,
readby: [...new Set([...notification.readby, user.value.uuid])],
})),
}));
markAllNotificationsRead();
}
function canEditComment(comment) {
@ -63,6 +91,10 @@ export const useUserStore = defineStore('user', () => {
user,
isLogged,
notifications,
// Nouvelles fonctions
markNotificationRead,
markAllNotificationsRead,
// Anciennes fonctions (rétro-compatibilité)
readNotification,
readAllNotifications,
canEditComment,

View file

@ -119,14 +119,24 @@ function changeTab(newValue) {
function readAll() {
try {
api.readAllNotifications();
api.markAllNotificationsRead();
} catch (error) {
console.log('Could not read all notifications : ', error);
}
}
// Functions
function handleNotificationClick(notification) {
async function handleNotificationClick(notification) {
// Marquer la notification comme lue
if (!notification.isRead) {
try {
await api.markNotificationRead(notification);
} catch (error) {
console.log('Could not mark notification as read:', error);
}
}
// Naviguer vers la cible
const href =
notification.type === 'appointment-request'
? getHref(notification) + '?tab=designToLight'

View file

@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => {
minify: mode === 'production' ? 'esbuild' : false,
},
server: {
cors: true,
watch: {
ignored: [
'**/node_modules/**',