diff --git a/public/site/blueprints/pages/client-brief.yml b/public/site/blueprints/pages/client-brief.yml index 7574be8..9df9168 100644 --- a/public/site/blueprints/pages/client-brief.yml +++ b/public/site/blueprints/pages/client-brief.yml @@ -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 diff --git a/public/site/blueprints/pages/extended-brief.yml b/public/site/blueprints/pages/extended-brief.yml index b65d99f..477d4a1 100644 --- a/public/site/blueprints/pages/extended-brief.yml +++ b/public/site/blueprints/pages/extended-brief.yml @@ -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 diff --git a/public/site/blueprints/pages/project.yml b/public/site/blueprints/pages/project.yml index 946e238..6962aa5 100644 --- a/public/site/blueprints/pages/project.yml +++ b/public/site/blueprints/pages/project.yml @@ -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 diff --git a/public/site/config/config.php b/public/site/config/config.php index 72ea0da..37f3a33 100644 --- a/public/site/config/config.php +++ b/public/site/config/config.php @@ -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'), diff --git a/public/site/config/routes/migrate-notifications.php b/public/site/config/routes/migrate-notifications.php new file mode 100644 index 0000000..3aa033c --- /dev/null +++ b/public/site/config/routes/migrate-notifications.php @@ -0,0 +1,175 @@ + '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 + ]; + } +]; diff --git a/public/site/config/routes/request-optimization-appointment.php b/public/site/config/routes/request-optimization-appointment.php index 0cc94c0..d1e4b84 100644 --- a/public/site/config/routes/request-optimization-appointment.php +++ b/public/site/config/routes/request-optimization-appointment.php @@ -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() ]; } } diff --git a/public/site/config/routes/request-project-creation.php b/public/site/config/routes/request-project-creation.php index 9a474d7..1b9b378 100644 --- a/public/site/config/routes/request-project-creation.php +++ b/public/site/config/routes/request-project-creation.php @@ -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", diff --git a/public/site/config/routes/validate-brief.php b/public/site/config/routes/validate-brief.php index f8e97f1..fb2bf86 100644 --- a/public/site/config/routes/validate-brief.php +++ b/public/site/config/routes/validate-brief.php @@ -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." diff --git a/public/site/plugins/comments/routes/create.php b/public/site/plugins/comments/routes/create.php index 9db31cf..5641383 100644 --- a/public/site/plugins/comments/routes/create.php +++ b/public/site/plugins/comments/routes/create.php @@ -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; }, diff --git a/public/site/plugins/comments/routes/delete.php b/public/site/plugins/comments/routes/delete.php index 8bc9e07..5f96a96 100644 --- a/public/site/plugins/comments/routes/delete.php +++ b/public/site/plugins/comments/routes/delete.php @@ -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; }, diff --git a/public/site/plugins/comments/routes/reply.php b/public/site/plugins/comments/routes/reply.php index 68aeebd..2418dfe 100644 --- a/public/site/plugins/comments/routes/reply.php +++ b/public/site/plugins/comments/routes/reply.php @@ -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); } diff --git a/public/site/plugins/notifications/index.php b/public/site/plugins/notifications/index.php index 91da6f9..9b46c7e 100644 --- a/public/site/plugins/notifications/index.php +++ b/public/site/plugins/notifications/index.php @@ -1,27 +1,54 @@ "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"), + ], ]); diff --git a/public/site/plugins/notifications/routes/mark-all-read.php b/public/site/plugins/notifications/routes/mark-all-read.php new file mode 100644 index 0000000..c06cc80 --- /dev/null +++ b/public/site/plugins/notifications/routes/mark-all-read.php @@ -0,0 +1,42 @@ + '(: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() + ]); + } + } +]; diff --git a/public/site/plugins/notifications/routes/mark-as-read.php b/public/site/plugins/notifications/routes/mark-as-read.php new file mode 100644 index 0000000..736b39f --- /dev/null +++ b/public/site/plugins/notifications/routes/mark-as-read.php @@ -0,0 +1,46 @@ + '(: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() + ]); + } + } +]; diff --git a/public/site/plugins/notifications/src/NotificationCollector.php b/public/site/plugins/notifications/src/NotificationCollector.php new file mode 100644 index 0000000..113b041 --- /dev/null +++ b/public/site/plugins/notifications/src/NotificationCollector.php @@ -0,0 +1,123 @@ +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); + } +} diff --git a/public/site/plugins/notifications/src/NotificationProvider.php b/public/site/plugins/notifications/src/NotificationProvider.php new file mode 100644 index 0000000..cc11906 --- /dev/null +++ b/public/site/plugins/notifications/src/NotificationProvider.php @@ -0,0 +1,42 @@ +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; + } +} diff --git a/public/site/plugins/notifications/src/providers/CommentProvider.php b/public/site/plugins/notifications/src/providers/CommentProvider.php new file mode 100644 index 0000000..fc92ce1 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/CommentProvider.php @@ -0,0 +1,172 @@ +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; + } +} diff --git a/public/site/plugins/notifications/src/providers/ContentProvider.php b/public/site/plugins/notifications/src/providers/ContentProvider.php new file mode 100644 index 0000000..3f09800 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/ContentProvider.php @@ -0,0 +1,128 @@ +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; + } +} diff --git a/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php b/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php new file mode 100644 index 0000000..cc3d186 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php @@ -0,0 +1,111 @@ +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; + } +} diff --git a/public/site/plugins/notifications/src/providers/ReplyProvider.php b/public/site/plugins/notifications/src/providers/ReplyProvider.php new file mode 100644 index 0000000..467b245 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/ReplyProvider.php @@ -0,0 +1,142 @@ +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; + } +} diff --git a/public/site/templates/projects.json.php b/public/site/templates/projects.json.php index ba74b69..cf5a52a 100644 --- a/public/site/templates/projects.json.php +++ b/public/site/templates/projects.json.php @@ -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 = []; diff --git a/src/stores/api.js b/src/stores/api.js index d12707a..cbffdd8 100644 --- a/src/stores/api.js +++ b/src/stores/api.js @@ -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, diff --git a/src/stores/user.js b/src/stores/user.js index 7beaf69..b3404df 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -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, diff --git a/src/views/Notifications.vue b/src/views/Notifications.vue index c7db5b1..053eeba 100644 --- a/src/views/Notifications.vue +++ b/src/views/Notifications.vue @@ -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' diff --git a/vite.config.js b/vite.config.js index c235442..da73536 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => { minify: mode === 'production' ? 'esbuild' : false, }, server: { + cors: true, watch: { ignored: [ '**/node_modules/**',