From be29ab5529ce9df26622d4559cb0b5df064c297e Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Wed, 13 Aug 2025 12:45:32 +0200 Subject: [PATCH 1/6] Add command to create a stackable user notification --- .../CreateStackableUserNotification.class.php | 207 ++++++++++++++++++ .../UserNotificationHandler.class.php | 18 +- 2 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php diff --git a/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php b/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php new file mode 100644 index 00000000000..2ce605d0990 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php @@ -0,0 +1,207 @@ + + * @since 6.3 + */ +final class CreateStackableUserNotification +{ + public function __construct( + private readonly IUserNotificationEvent $event, + private readonly UserProfile $author, + private readonly IUserNotificationObject $object, + private readonly IUserNotificationObjectType $objectType, + private readonly int $baseObjectID, + /** @var array */ + private readonly array $recipients, + /** @var array */ + private readonly array $additionalData = [], + ) {} + + /** + * @return array + */ + public function __invoke(): array + { + $existingNotifications = $this->getExistingNotifications($this->event, $this->recipients); + + $notifications = []; + foreach ($this->recipients as $recipient) { + $notification = ($existingNotifications[$recipient->userID] ?? null); + $isNew = ($notification === null); + + if ($notification === null) { + $notification = $this->createNotification( + $this->objectType, + $this->event, + $this->object, + $this->baseObjectID, + $this->author, + \serialize($this->additionalData), + $recipient, + ); + } + + $notifications[$recipient->userID] = [ + 'isNew' => $isNew, + 'object' => $notification, + ]; + } + + $this->sortNotificationsById($notifications); + + $notificationIDs = $this->createUserNotifications($this->author, $notifications); + + $updatedNotifications = $this->getUserNotificationsByIds($notificationIDs); + + return \array_map(static function ($notificationData) use ($updatedNotifications) { + $notificationData['object'] = $updatedNotifications[$notificationData['object']->notificationID]; + + return $notificationData; + }, $notifications); + } + + /** + * @param array $notifications + * + * @return list + */ + private function createUserNotifications(UserProfile $author, array $notifications): array + { + if ($notifications === []) { + return []; + } + + $sql = "INSERT IGNORE INTO wcf1_user_notification_author + (notificationID, authorID, time) + VALUES (?, ?, ?)"; + $authorStatement = WCF::getDB()->prepare($sql); + $sql = "UPDATE wcf1_user_notification + SET timesTriggered = timesTriggered + ?, + guestTimesTriggered = guestTimesTriggered + ? + WHERE notificationID = ?"; + $triggerStatement = WCF::getDB()->prepare($sql); + + $authorId = $author->userID; + $isGuestTrigger = $authorId ? 1 : 0; + $now = TIME_NOW; + $notificationIDs = []; + + WCF::getDB()->beginTransaction(); + foreach ($notifications as $notificationData) { + $notificationID = $notificationData['object']->notificationID; + $notificationIDs[] = $notificationID; + + $authorStatement->execute([ + $notificationID, + $authorId, + $now, + ]); + + $triggerStatement->execute([ + 1, + $isGuestTrigger, + $notificationID, + ]); + } + WCF::getDB()->commitTransaction(); + + return $notificationIDs; + } + + /** + * @param list $notificationIDs + * + * @return array + */ + private function getUserNotificationsByIds(array $notificationIDs): array + { + $notificationList = new UserNotificationList(); + $notificationList->setObjectIDs($notificationIDs); + $notificationList->readObjects(); + + return $notificationList->getObjects(); + } + + /** + * @param array $recipients + * + * @return array + */ + private function getExistingNotifications(IUserNotificationEvent $event, array $recipients): array + { + $notificationList = new UserNotificationList(); + $notificationList->getConditionBuilder()->add("eventID = ?", [$event->eventID]); + $notificationList->getConditionBuilder()->add("eventHash = ?", [$event->getEventHash()]); + $notificationList->getConditionBuilder()->add("userID IN (?)", [\array_keys($recipients)]); + $notificationList->getConditionBuilder()->add("confirmTime = ?", [0]); + $notificationList->readObjects(); + + $existingNotifications = []; + foreach ($notificationList->getObjects() as $notification) { + $existingNotifications[$notification->userID] = $notification; + } + + return $existingNotifications; + } + + /** + * @param array $notifications + */ + private function sortNotificationsById(array &$notifications): void + { + \uasort($notifications, [self::class, 'compareByNotificationId']); + } + + /** + * Comparator for user notifications by their notificationID. + * + * @param array{isNew: bool, object: UserNotification} $left + * @param array{isNew: bool, object: UserNotification} $right + */ + private static function compareByNotificationId(array $left, array $right): int + { + return $left['object']->notificationID <=> $right['object']->notificationID; + } + + private function createNotification( + IUserNotificationObjectType $objectType, + IUserNotificationEvent $event, + IUserNotificationObject $object, + int $baseObjectID, + ?UserProfile $author, + string $additionalData, + User $recipient, + ): UserNotification { + $mailNotified = (($recipient->mailNotificationType === 'none' || $recipient->mailNotificationType === 'instant') ? 1 : 0); + + return UserNotificationEditor::create([ + 'packageID' => $objectType->packageID, + 'eventID' => $event->eventID, + 'objectID' => $object->getObjectID(), + 'baseObjectID' => $baseObjectID, + 'eventHash' => $event->getEventHash(), + 'authorID' => $author->userID ?: null, + 'mailNotified' => $mailNotified ? 0 : 1, + 'time' => TIME_NOW, + 'additionalData' => $additionalData, + 'userID' => $recipient->userID, + ]); + } +} diff --git a/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php b/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php index ea67829e387..3d565a6c000 100644 --- a/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php +++ b/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php @@ -3,6 +3,7 @@ namespace wcf\system\user\notification; use ParagonIE\ConstantTime\Hex; +use wcf\command\user\notification\CreateStackableUserNotification; use wcf\data\object\type\ObjectType; use wcf\data\object\type\ObjectTypeCache; use wcf\data\user\notification\event\recipient\UserNotificationEventRecipientList; @@ -277,17 +278,22 @@ public function fireEvent( ]; if ($event->isStackable()) { - $data['notifications'] = $notifications; - - $action = new UserNotificationAction([], 'createStackable', $data); + $notifications = (new CreateStackableUserNotification( + $event, + $event->getAuthor(), + $notificationObject, + $objectTypeObject, + $baseObjectID, + $recipients, + $additionalData + ))(); } else { $data['data']['timesTriggered'] = 1; $action = new UserNotificationAction([], 'createDefault', $data); + $result = $action->executeAction(); + $notifications = $result['returnValues']; } - $result = $action->executeAction(); - $notifications = $result['returnValues']; - // send notifications if ($event->supportsEmailNotification()) { foreach ($recipients as $recipient) { From 9246c6fa3c25a7c00e4db3691a5074d76d5f52eb Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Thu, 14 Aug 2025 11:41:51 +0200 Subject: [PATCH 2/6] Introduce command for creating non-stackable user notifications --- .../CreateStackableUserNotification.class.php | 59 ++++---- .../CreateUserNotification.class.php | 118 +++++++++++++++ .../notification/UserNotification.class.php | 3 + .../UserNotificationAction.class.php | 140 +++++------------- .../UserNotificationHandler.class.php | 51 +++---- 5 files changed, 206 insertions(+), 165 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/user/notification/CreateUserNotification.class.php diff --git a/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php b/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php index 2ce605d0990..ac955cf41d6 100644 --- a/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php +++ b/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php @@ -7,9 +7,6 @@ use wcf\data\user\notification\UserNotificationList; use wcf\data\user\User; use wcf\data\user\UserProfile; -use wcf\system\user\notification\event\IUserNotificationEvent; -use wcf\system\user\notification\object\IUserNotificationObject; -use wcf\system\user\notification\object\type\IUserNotificationObjectType; use wcf\system\WCF; /** @@ -23,15 +20,15 @@ final class CreateStackableUserNotification { public function __construct( - private readonly IUserNotificationEvent $event, + private readonly int $eventID, + private readonly string $eventHash, private readonly UserProfile $author, - private readonly IUserNotificationObject $object, - private readonly IUserNotificationObjectType $objectType, + private readonly int $objectID, + private readonly int $packageID, private readonly int $baseObjectID, /** @var array */ private readonly array $recipients, - /** @var array */ - private readonly array $additionalData = [], + private readonly string $additionalData, ) {} /** @@ -39,7 +36,7 @@ public function __construct( */ public function __invoke(): array { - $existingNotifications = $this->getExistingNotifications($this->event, $this->recipients); + $existingNotifications = $this->getExistingNotifications($this->eventID, $this->eventHash, $this->recipients); $notifications = []; foreach ($this->recipients as $recipient) { @@ -48,12 +45,13 @@ public function __invoke(): array if ($notification === null) { $notification = $this->createNotification( - $this->objectType, - $this->event, - $this->object, + $this->packageID, + $this->eventID, + $this->eventHash, + $this->objectID, $this->baseObjectID, $this->author, - \serialize($this->additionalData), + $this->additionalData, $recipient, ); } @@ -99,7 +97,7 @@ private function createUserNotifications(UserProfile $author, array $notificatio $triggerStatement = WCF::getDB()->prepare($sql); $authorId = $author->userID; - $isGuestTrigger = $authorId ? 1 : 0; + $isGuestTrigger = $authorId ? 0 : 1; $now = TIME_NOW; $notificationIDs = []; @@ -144,11 +142,11 @@ private function getUserNotificationsByIds(array $notificationIDs): array * * @return array */ - private function getExistingNotifications(IUserNotificationEvent $event, array $recipients): array + private function getExistingNotifications(int $eventID, string $eventHash, array $recipients): array { $notificationList = new UserNotificationList(); - $notificationList->getConditionBuilder()->add("eventID = ?", [$event->eventID]); - $notificationList->getConditionBuilder()->add("eventHash = ?", [$event->getEventHash()]); + $notificationList->getConditionBuilder()->add("eventID = ?", [$eventID]); + $notificationList->getConditionBuilder()->add("eventHash = ?", [$eventHash]); $notificationList->getConditionBuilder()->add("userID IN (?)", [\array_keys($recipients)]); $notificationList->getConditionBuilder()->add("confirmTime = ?", [0]); $notificationList->readObjects(); @@ -170,8 +168,6 @@ private function sortNotificationsById(array &$notifications): void } /** - * Comparator for user notifications by their notificationID. - * * @param array{isNew: bool, object: UserNotification} $left * @param array{isNew: bool, object: UserNotification} $right */ @@ -180,25 +176,30 @@ private static function compareByNotificationId(array $left, array $right): int return $left['object']->notificationID <=> $right['object']->notificationID; } + private function shouldNotifyByMail(User $recipient): bool + { + return $recipient->mailNotificationType === UserNotification::MAIL_NOTIFICATION_TYPE_NONE + || $recipient->mailNotificationType === UserNotification::MAIL_NOTIFICATION_TYPE_INSTANT; + } + private function createNotification( - IUserNotificationObjectType $objectType, - IUserNotificationEvent $event, - IUserNotificationObject $object, + int $packageID, + int $eventID, + string $eventHash, + int $objectID, int $baseObjectID, ?UserProfile $author, string $additionalData, User $recipient, ): UserNotification { - $mailNotified = (($recipient->mailNotificationType === 'none' || $recipient->mailNotificationType === 'instant') ? 1 : 0); - return UserNotificationEditor::create([ - 'packageID' => $objectType->packageID, - 'eventID' => $event->eventID, - 'objectID' => $object->getObjectID(), + 'packageID' => $packageID, + 'eventID' => $eventID, + 'objectID' => $objectID, 'baseObjectID' => $baseObjectID, - 'eventHash' => $event->getEventHash(), + 'eventHash' => $eventHash, 'authorID' => $author->userID ?: null, - 'mailNotified' => $mailNotified ? 0 : 1, + 'mailNotified' => $this->shouldNotifyByMail($recipient) ? 0 : 1, 'time' => TIME_NOW, 'additionalData' => $additionalData, 'userID' => $recipient->userID, diff --git a/wcfsetup/install/files/lib/command/user/notification/CreateUserNotification.class.php b/wcfsetup/install/files/lib/command/user/notification/CreateUserNotification.class.php new file mode 100644 index 00000000000..ed5c5e056d2 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/notification/CreateUserNotification.class.php @@ -0,0 +1,118 @@ + + * @since 6.3 + */ +final class CreateUserNotification +{ + public function __construct( + private readonly int $eventID, + private readonly string $eventHash, + private readonly UserProfile $author, + private readonly int $objectID, + private readonly int $packageID, + private readonly int $baseObjectID, + /** @var array */ + private readonly array $recipients, + private readonly string $additionalData, + ) {} + + /** + * @return array + */ + public function __invoke(): array + { + $notifications = []; + + foreach ($this->recipients as $recipient) { + $notification = $this->createNotificationForRecipient( + $recipient, + $this->packageID, + $this->eventID, + $this->objectID, + $this->baseObjectID, + $this->eventHash, + $this->author->userID, + $this->additionalData + ); + + $notifications[$recipient->userID] = [ + 'isNew' => true, + 'object' => $notification, + ]; + } + + $this->insertAuthors($notifications, $this->author->userID); + + return $notifications; + } + + private function createNotificationForRecipient( + User $recipient, + int $packageID, + int $eventID, + int $objectID, + int $baseObjectID, + string $eventHash, + int $authorID, + string $additionalData + ): UserNotification { + return UserNotificationEditor::create([ + 'packageID' => $packageID, + 'eventID' => $eventID, + 'objectID' => $objectID, + 'baseObjectID' => $baseObjectID, + 'eventHash' => $eventHash, + 'authorID' => $authorID, + 'mailNotified' => $this->shouldNotifyByMail($recipient) ? 0 : 1, + 'time' => \TIME_NOW, + 'timesTriggered' => 1, + 'additionalData' => $additionalData, + 'userID' => $recipient->userID, + ]); + } + + private function shouldNotifyByMail(User $recipient): bool + { + return $recipient->mailNotificationType === UserNotification::MAIL_NOTIFICATION_TYPE_NONE + || $recipient->mailNotificationType === UserNotification::MAIL_NOTIFICATION_TYPE_INSTANT; + } + + /** + * @param array $notifications + */ + private function insertAuthors(array $notifications, int $authorID): void + { + if ($notifications === []) { + return; + } + + $sql = "INSERT INTO wcf1_user_notification_author + (notificationID, authorID, time) + VALUES (?, ?, ?)"; + $statement = WCF::getDB()->prepare($sql); + + WCF::getDB()->beginTransaction(); + foreach ($notifications as $notificationData) { + $statement->execute([ + $notificationData['object']->notificationID, + $authorID, + \TIME_NOW, + ]); + } + WCF::getDB()->commitTransaction(); + } +} diff --git a/wcfsetup/install/files/lib/data/user/notification/UserNotification.class.php b/wcfsetup/install/files/lib/data/user/notification/UserNotification.class.php index f80efa7d88b..1fcc73e774d 100644 --- a/wcfsetup/install/files/lib/data/user/notification/UserNotification.class.php +++ b/wcfsetup/install/files/lib/data/user/notification/UserNotification.class.php @@ -29,6 +29,9 @@ */ class UserNotification extends DatabaseObject { + public const MAIL_NOTIFICATION_TYPE_NONE = 'none'; + public const MAIL_NOTIFICATION_TYPE_INSTANT = 'instant'; + /** * @inheritDoc */ diff --git a/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php b/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php index 48c65d65903..3eeb72245e8 100644 --- a/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php +++ b/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php @@ -3,8 +3,12 @@ namespace wcf\data\user\notification; use wcf\action\NotificationConfirmAction; +use wcf\command\user\notification\CreateStackableUserNotification; +use wcf\command\user\notification\CreateUserNotification; use wcf\data\AbstractDatabaseObjectAction; +use wcf\data\user\User; use wcf\data\user\UserProfile; +use wcf\system\cache\runtime\UserProfileRuntimeCache; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\exception\PermissionDeniedException; use wcf\system\request\LinkHandler; @@ -35,127 +39,53 @@ class UserNotificationAction extends AbstractDatabaseObjectAction * Creates a simple notification without stacking support, applies to legacy notifications too. * * @return mixed[][] + * @deprecated 6.3 use the `CreateDefaultUserNotification` command instead */ public function createDefault() { - $notifications = []; - foreach ($this->parameters['recipients'] as $recipient) { - $this->parameters['data']['userID'] = $recipient->userID; - $this->parameters['data']['mailNotified'] = (($recipient->mailNotificationType == 'none' || $recipient->mailNotificationType == 'instant') ? 1 : 0); - $notification = $this->create(); - - $notifications[$recipient->userID] = [ - 'isNew' => true, - 'object' => $notification, - ]; - } - - // insert author - $sql = "INSERT INTO wcf1_user_notification_author - (notificationID, authorID, time) - VALUES (?, ?, ?)"; - $statement = WCF::getDB()->prepare($sql); - - WCF::getDB()->beginTransaction(); - foreach ($notifications as $notificationData) { - $statement->execute([ - $notificationData['object']->notificationID, - $this->parameters['authorID'] ?: null, - TIME_NOW, - ]); - } - WCF::getDB()->commitTransaction(); - - return $notifications; + return (new CreateUserNotification( + $this->parameters['data']['eventID'], + $this->parameters['data']['eventHash'], + $this->getUserProfile($this->parameters['authorID']), + $this->parameters['data']['objectID'], + $this->parameters['data']['packageID'], + $this->parameters['data']['baseObjectID'], + $this->parameters['recipients'], + $this->parameters['data']['additionalData'] + ))(); } /** * Creates a notification or adds another author to an existing one. * * @return mixed[][] + * + * @deprecated 6.3 use the `CreateStackableUserNotification` command instead. */ public function createStackable() { - // get existing notifications - $notificationList = new UserNotificationList(); - $notificationList->getConditionBuilder()->add("eventID = ?", [$this->parameters['data']['eventID']]); - $notificationList->getConditionBuilder()->add("eventHash = ?", [$this->parameters['data']['eventHash']]); - $notificationList->getConditionBuilder()->add("userID IN (?)", [\array_keys($this->parameters['recipients'])]); - $notificationList->getConditionBuilder()->add("confirmTime = ?", [0]); - $notificationList->readObjects(); - $existingNotifications = []; - foreach ($notificationList as $notification) { - $existingNotifications[$notification->userID] = $notification; - } - - $notifications = []; - foreach ($this->parameters['recipients'] as $recipient) { - $notification = ($existingNotifications[$recipient->userID] ?? null); - $isNew = ($notification === null); - - if ($notification === null) { - $this->parameters['data']['userID'] = $recipient->userID; - $this->parameters['data']['mailNotified'] = (($recipient->mailNotificationType == 'none' || $recipient->mailNotificationType == 'instant') ? 1 : 0); - $notification = $this->create(); - } + return (new CreateStackableUserNotification( + $this->parameters['data']['eventID'], + $this->parameters['data']['eventHash'], + $this->getUserProfile($this->parameters['authorID']), + $this->parameters['data']['objectID'], + $this->parameters['data']['packageID'], + $this->parameters['data']['baseObjectID'], + $this->parameters['recipients'], + $this->parameters['data']['additionalData'] + ))(); + } - $notifications[$recipient->userID] = [ - 'isNew' => $isNew, - 'object' => $notification, - ]; + private function getUserProfile(?int $authorID): UserProfile + { + if ($authorID === null) { + return new UserProfile(new User(null, [])); } - - \uasort($notifications, static function ($a, $b) { - if ($a['object']->notificationID == $b['object']->notificationID) { - return 0; - } elseif ($a['object']->notificationID < $b['object']->notificationID) { - return -1; - } - - return 1; - }); - - // insert author - $sql = "INSERT IGNORE INTO wcf1_user_notification_author - (notificationID, authorID, time) - VALUES (?, ?, ?)"; - $authorStatement = WCF::getDB()->prepare($sql); - - // update trigger count - $sql = "UPDATE wcf1_user_notification - SET timesTriggered = timesTriggered + ?, - guestTimesTriggered = guestTimesTriggered + ? - WHERE notificationID = ?"; - $triggerStatement = WCF::getDB()->prepare($sql); - - WCF::getDB()->beginTransaction(); - $notificationIDs = []; - foreach ($notifications as $notificationData) { - $notificationIDs[] = $notificationData['object']->notificationID; - - $authorStatement->execute([ - $notificationData['object']->notificationID, - $this->parameters['authorID'] ?: null, - TIME_NOW, - ]); - $triggerStatement->execute([ - 1, - $this->parameters['authorID'] ? 0 : 1, - $notificationData['object']->notificationID, - ]); + if ($authorID === WCF::getUser()->userID) { + return new UserProfile(WCF::getUser()); } - WCF::getDB()->commitTransaction(); - - $notificationList = new UserNotificationList(); - $notificationList->setObjectIDs($notificationIDs); - $notificationList->readObjects(); - $updatedNotifications = $notificationList->getObjects(); - - return \array_map(static function ($notificationData) use ($updatedNotifications) { - $notificationData['object'] = $updatedNotifications[$notificationData['object']->notificationID]; - return $notificationData; - }, $notifications); + return UserProfileRuntimeCache::getInstance()->getObject($authorID); } /** diff --git a/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php b/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php index 3d565a6c000..844b671160a 100644 --- a/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php +++ b/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php @@ -4,6 +4,7 @@ use ParagonIE\ConstantTime\Hex; use wcf\command\user\notification\CreateStackableUserNotification; +use wcf\command\user\notification\CreateUserNotification; use wcf\data\object\type\ObjectType; use wcf\data\object\type\ObjectTypeCache; use wcf\data\user\notification\event\recipient\UserNotificationEventRecipientList; @@ -261,43 +262,34 @@ public function fireEvent( $recipientList->readObjects(); $recipients = $recipientList->getObjects(); if (!empty($recipients)) { - $data = [ - 'authorID' => $event->getAuthorID() ?: null, - 'data' => [ - 'eventID' => $event->eventID, - 'authorID' => $event->getAuthorID() ?: null, - 'objectID' => $notificationObject->getObjectID(), - 'baseObjectID' => $baseObjectID, - 'eventHash' => $event->getEventHash(), - 'packageID' => $objectTypeObject->packageID, - 'mailNotified' => $event->supportsEmailNotification() ? 0 : 1, - 'time' => TIME_NOW, - 'additionalData' => \serialize($additionalData), - ], - 'recipients' => $recipients, - ]; - if ($event->isStackable()) { $notifications = (new CreateStackableUserNotification( - $event, + $event->eventID, + $event->getEventHash(), $event->getAuthor(), - $notificationObject, - $objectTypeObject, + $notificationObject->getObjectID(), + $objectTypeObject->packageID, $baseObjectID, $recipients, - $additionalData + \serialize($additionalData) ))(); } else { - $data['data']['timesTriggered'] = 1; - $action = new UserNotificationAction([], 'createDefault', $data); - $result = $action->executeAction(); - $notifications = $result['returnValues']; + $notifications = (new CreateUserNotification( + $event->eventID, + $event->getEventHash(), + $event->getAuthor(), + $notificationObject->getObjectID(), + $objectTypeObject->packageID, + $baseObjectID, + $recipients, + \serialize($additionalData) + ))(); } // send notifications if ($event->supportsEmailNotification()) { foreach ($recipients as $recipient) { - if ($recipient->mailNotificationType == 'instant') { + if ($recipient->mailNotificationType === UserNotification::MAIL_NOTIFICATION_TYPE_INSTANT) { if (isset($notifications[$recipient->userID]) && $notifications[$recipient->userID]['isNew']) { $event->setObject( $notifications[$recipient->userID]['object'], @@ -324,15 +316,12 @@ public function fireEvent( $statement = WCF::getDB()->prepare($sql); foreach ($notifications as $userID => $notification) { - $notificationObject = $notification['object'] ?? null; - if ($notificationObject === null) { - continue; - } - \assert($notificationObject instanceof UserNotification); + $notificationObject = $notification['object']; + $statement->execute([ $notificationObject->notificationID, $notificationObject->time, - $userID + $userID, ]); } BackgroundQueueHandler::getInstance()->enqueueIn(new ServiceWorkerDeliveryBackgroundJob()); From 75d7d7a0dc93852f2094f4a74972f03b62d6cf0a Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Thu, 14 Aug 2025 11:51:53 +0200 Subject: [PATCH 3/6] Refactor `UserNotificationHandler` to improve readability --- .../UserNotificationHandler.class.php | 128 +++++++++--------- 1 file changed, 65 insertions(+), 63 deletions(-) diff --git a/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php b/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php index 844b671160a..774b42b74ea 100644 --- a/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php +++ b/wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php @@ -261,77 +261,79 @@ public function fireEvent( $recipientList->getConditionBuilder()->add('event_to_user.userID IN (?)', [$recipientIDs]); $recipientList->readObjects(); $recipients = $recipientList->getObjects(); - if (!empty($recipients)) { - if ($event->isStackable()) { - $notifications = (new CreateStackableUserNotification( - $event->eventID, - $event->getEventHash(), - $event->getAuthor(), - $notificationObject->getObjectID(), - $objectTypeObject->packageID, - $baseObjectID, - $recipients, - \serialize($additionalData) - ))(); - } else { - $notifications = (new CreateUserNotification( - $event->eventID, - $event->getEventHash(), - $event->getAuthor(), - $notificationObject->getObjectID(), - $objectTypeObject->packageID, - $baseObjectID, - $recipients, - \serialize($additionalData) - ))(); - } + if ($recipients === []) { + return; + } + + if ($event->isStackable()) { + $notifications = (new CreateStackableUserNotification( + $event->eventID, + $event->getEventHash(), + $event->getAuthor(), + $notificationObject->getObjectID(), + $objectTypeObject->packageID, + $baseObjectID, + $recipients, + \serialize($additionalData) + ))(); + } else { + $notifications = (new CreateUserNotification( + $event->eventID, + $event->getEventHash(), + $event->getAuthor(), + $notificationObject->getObjectID(), + $objectTypeObject->packageID, + $baseObjectID, + $recipients, + \serialize($additionalData) + ))(); + } - // send notifications - if ($event->supportsEmailNotification()) { - foreach ($recipients as $recipient) { - if ($recipient->mailNotificationType === UserNotification::MAIL_NOTIFICATION_TYPE_INSTANT) { - if (isset($notifications[$recipient->userID]) && $notifications[$recipient->userID]['isNew']) { - $event->setObject( - $notifications[$recipient->userID]['object'], - $notificationObject, - $userProfile, - $additionalData - ); - $event->setAuthors([$userProfile->userID => $userProfile]); - $this->sendInstantMailNotification( - $notifications[$recipient->userID]['object'], - $recipient, - $event - ); - } + // send notifications + if ($event->supportsEmailNotification()) { + foreach ($recipients as $recipient) { + if ($recipient->mailNotificationType === UserNotification::MAIL_NOTIFICATION_TYPE_INSTANT) { + if (isset($notifications[$recipient->userID]) && $notifications[$recipient->userID]['isNew']) { + $event->setObject( + $notifications[$recipient->userID]['object'], + $notificationObject, + $userProfile, + $additionalData + ); + $event->setAuthors([$userProfile->userID => $userProfile]); + $this->sendInstantMailNotification( + $notifications[$recipient->userID]['object'], + $recipient, + $event + ); } } } + } - $sql = "INSERT IGNORE INTO wcf1_service_worker_notification - (workerID, notificationID, time) - SELECT workerID, ?, ? - FROM wcf1_service_worker - WHERE userID = ?"; - $statement = WCF::getDB()->prepare($sql); + $sql = "INSERT IGNORE INTO wcf1_service_worker_notification + (workerID, notificationID, time) + SELECT workerID, ?, ? + FROM wcf1_service_worker + WHERE userID = ?"; + $statement = WCF::getDB()->prepare($sql); - foreach ($notifications as $userID => $notification) { - $notificationObject = $notification['object']; + foreach ($notifications as $userID => $notification) { + $notificationObject = $notification['object']; - $statement->execute([ - $notificationObject->notificationID, - $notificationObject->time, - $userID, - ]); - } - BackgroundQueueHandler::getInstance()->enqueueIn(new ServiceWorkerDeliveryBackgroundJob()); - // reset notification count - UserStorageHandler::getInstance()->reset(\array_keys($recipients), 'userNotificationCount'); - - $parameters['notifications'] = $notifications; - $parameters['recipients'] = $recipients; - EventHandler::getInstance()->fireAction($this, 'createdNotification', $parameters); + $statement->execute([ + $notificationObject->notificationID, + $notificationObject->time, + $userID, + ]); } + BackgroundQueueHandler::getInstance()->enqueueIn(new ServiceWorkerDeliveryBackgroundJob()); + // reset notification count + UserStorageHandler::getInstance()->reset(\array_keys($recipients), 'userNotificationCount'); + + $parameters['notifications'] = $notifications; + $parameters['recipients'] = $recipients; + EventHandler::getInstance()->fireAction($this, 'createdNotification', $parameters); } /** From 2e59c699a741e2be7264b603537f9ba6b80d0be8 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Thu, 14 Aug 2025 22:37:41 +0200 Subject: [PATCH 4/6] Introduce API endpoints to mark user notifications as read --- .../MarkAllUserNotificationsAsRead.ts | 18 ++++ .../MarkUserNotificationAsRead.ts | 24 ++++++ .../Core/Controller/User/Notification/List.ts | 9 +- .../Core/Ui/User/Menu/Data/Notification.ts | 10 +-- .../MarkAllUserNotificationsAsRead.js | 19 +++++ .../MarkUserNotificationAsRead.js | 21 +++++ .../Core/Controller/User/Notification/List.js | 8 +- .../Core/Ui/User/Menu/Data/Notification.js | 10 +-- .../files/lib/bootstrap/com.woltlab.wcf.php | 2 + .../MarkAllUserNotificationsAsRead.class.php | 85 +++++++++++++++++++ .../UserNotificationAction.class.php | 45 +++------- .../AllUserNotificationsMarkAsRead.class.php | 20 +++++ .../MarkAllUserNotificationsAsRead.class.php | 40 +++++++++ .../MarkUserNotificationAsRead.class.php | 47 ++++++++++ 14 files changed, 302 insertions(+), 56 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead.ts create mode 100644 ts/WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead.js create mode 100644 wcfsetup/install/files/lib/command/user/notification/MarkAllUserNotificationsAsRead.class.php create mode 100644 wcfsetup/install/files/lib/event/user/notification/AllUserNotificationsMarkAsRead.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/notifications/MarkAllUserNotificationsAsRead.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/users/notifications/MarkUserNotificationAsRead.class.php diff --git a/ts/WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead.ts b/ts/WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead.ts new file mode 100644 index 00000000000..84514a7c186 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead.ts @@ -0,0 +1,18 @@ +/** + * Marks all user notifications as read. + * + * @author Olaf Braun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result"; + +export async function markAllUserNotificationsAsRead(): Promise<[]> { + return fromInfallibleApiRequest(() => { + return prepareRequest(`${window.WSC_RPC_API_URL}core/users/notifications/mark-all-as-read`).post().fetchAsJson(); + }); +} diff --git a/ts/WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead.ts b/ts/WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead.ts new file mode 100644 index 00000000000..c73a2e475f3 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead.ts @@ -0,0 +1,24 @@ +/** + * Marks a user notification as read. + * + * @author Olaf Braun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ + +import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result"; +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; + +type Response = { + unreadNotifications: number; +}; + +export async function markUserNotificationAsRead(notificationId: number): Promise { + return fromInfallibleApiRequest(() => { + return prepareRequest(`${window.WSC_RPC_API_URL}core/users/notifications/${notificationId}/mark-as-read`) + .post() + .fetchAsJson(); + }); +} diff --git a/ts/WoltLabSuite/Core/Controller/User/Notification/List.ts b/ts/WoltLabSuite/Core/Controller/User/Notification/List.ts index 95c32ab6e76..ae79c2391fb 100644 --- a/ts/WoltLabSuite/Core/Controller/User/Notification/List.ts +++ b/ts/WoltLabSuite/Core/Controller/User/Notification/List.ts @@ -8,11 +8,12 @@ * @woltlabExcludeBundle tiny */ -import { dboAction } from "WoltLabSuite/Core/Ajax"; import { confirmationFactory } from "WoltLabSuite/Core/Component/Confirmation"; import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar"; import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; import { getPhrase } from "WoltLabSuite/Core/Language"; +import { markAllUserNotificationsAsRead } from "WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead"; +import { markUserNotificationAsRead } from "WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead"; function initMarkAllAsRead(): void { document.querySelector(".jsMarkAllAsConfirmed")?.addEventListener( @@ -29,7 +30,7 @@ async function markAllAsRead(): Promise { return; } - await dboAction("markAllAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction").dispatch(); + await markAllUserNotificationsAsRead(); showDefaultSuccessSnackbar().addEventListener("snackbar:close", () => { window.location.reload(); @@ -46,9 +47,7 @@ function initMarkAsRead(): void { } async function markAsRead(element: HTMLElement): Promise { - await dboAction("markAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction") - .objectIds([parseInt(element.dataset.objectId!)]) - .dispatch(); + await markUserNotificationAsRead(parseInt(element.dataset.objectId!, 10)); element.querySelector(".notificationListItem__unread")?.remove(); element.dataset.isRead = "true"; diff --git a/ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts b/ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts index d4dcd5119db..ca10de2ef15 100644 --- a/ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts +++ b/ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts @@ -14,6 +14,8 @@ import { registerProvider } from "../Manager"; import * as Language from "../../../../Language"; import { enableNotifications } from "../../../../Notification/Handler"; import { registerServiceWorker, updateNotificationLastReadTime } from "../../../../Notification/ServiceWorker"; +import { markUserNotificationAsRead } from "WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead"; +import { markAllUserNotificationsAsRead } from "WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead"; let originalFavicon = ""; function setFaviconCounter(counter: number): void { @@ -277,16 +279,14 @@ class UserMenuDataNotification implements DesktopNotifications, UserMenuProvider } async markAsRead(objectId: number): Promise { - const response = (await dboAction("markAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction") - .objectIds([objectId]) - .dispatch()) as ResponseMarkAsRead; + const { unreadNotifications } = await markUserNotificationAsRead(objectId); updateNotificationLastReadTime(); - this.updateCounter(response.totalCount); + this.updateCounter(unreadNotifications); } async markAllAsRead(): Promise { - await dboAction("markAllAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction").dispatch(); + await markAllUserNotificationsAsRead(); updateNotificationLastReadTime(); this.updateCounter(0); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead.js new file mode 100644 index 00000000000..d41f22a12ad --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead.js @@ -0,0 +1,19 @@ +/** + * Marks all user notifications as read. + * + * @author Olaf Braun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.markAllUserNotificationsAsRead = markAllUserNotificationsAsRead; + async function markAllUserNotificationsAsRead() { + return (0, Result_1.fromInfallibleApiRequest)(() => { + return (0, Backend_1.prepareRequest)(`${window.WSC_RPC_API_URL}core/users/notifications/mark-all-as-read`).post().fetchAsJson(); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead.js new file mode 100644 index 00000000000..6421a5ddd37 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead.js @@ -0,0 +1,21 @@ +/** + * Marks a user notification as read. + * + * @author Olaf Braun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "WoltLabSuite/Core/Api/Result", "WoltLabSuite/Core/Ajax/Backend"], function (require, exports, Result_1, Backend_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.markUserNotificationAsRead = markUserNotificationAsRead; + async function markUserNotificationAsRead(notificationId) { + return (0, Result_1.fromInfallibleApiRequest)(() => { + return (0, Backend_1.prepareRequest)(`${window.WSC_RPC_API_URL}core/users/notifications/${notificationId}/mark-as-read`) + .post() + .fetchAsJson(); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Notification/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Notification/List.js index 359b53ea363..3e38951be0d 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Notification/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Notification/List.js @@ -7,7 +7,7 @@ * @since 6.2 * @woltlabExcludeBundle tiny */ -define(["require", "exports", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Component/Confirmation", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Language"], function (require, exports, Ajax_1, Confirmation_1, Snackbar_1, PromiseMutex_1, Language_1) { +define(["require", "exports", "WoltLabSuite/Core/Component/Confirmation", "WoltLabSuite/Core/Component/Snackbar", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead", "WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead"], function (require, exports, Confirmation_1, Snackbar_1, PromiseMutex_1, Language_1, MarkAllUserNotificationsAsRead_1, MarkUserNotificationAsRead_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; @@ -21,7 +21,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Compo if (!result) { return; } - await (0, Ajax_1.dboAction)("markAllAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction").dispatch(); + await (0, MarkAllUserNotificationsAsRead_1.markAllUserNotificationsAsRead)(); (0, Snackbar_1.showDefaultSuccessSnackbar)().addEventListener("snackbar:close", () => { window.location.reload(); }); @@ -32,9 +32,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Compo }); } async function markAsRead(element) { - await (0, Ajax_1.dboAction)("markAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction") - .objectIds([parseInt(element.dataset.objectId)]) - .dispatch(); + await (0, MarkUserNotificationAsRead_1.markUserNotificationAsRead)(parseInt(element.dataset.objectId, 10)); element.querySelector(".notificationListItem__unread")?.remove(); element.dataset.isRead = "true"; } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.js index c077e3cc1c1..37ac4aa1e33 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.js @@ -6,7 +6,7 @@ * @license GNU Lesser General Public License * @woltlabExcludeBundle tiny */ -define(["require", "exports", "tslib", "../../../../Ajax", "../View", "../Manager", "../../../../Language", "../../../../Notification/Handler", "../../../../Notification/ServiceWorker"], function (require, exports, tslib_1, Ajax_1, View_1, Manager_1, Language, Handler_1, ServiceWorker_1) { +define(["require", "exports", "tslib", "../../../../Ajax", "../View", "../Manager", "../../../../Language", "../../../../Notification/Handler", "../../../../Notification/ServiceWorker", "WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead", "WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead"], function (require, exports, tslib_1, Ajax_1, View_1, Manager_1, Language, Handler_1, ServiceWorker_1, MarkUserNotificationAsRead_1, MarkAllUserNotificationsAsRead_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; @@ -206,14 +206,12 @@ define(["require", "exports", "tslib", "../../../../Ajax", "../View", "../Manage return element; } async markAsRead(objectId) { - const response = (await (0, Ajax_1.dboAction)("markAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction") - .objectIds([objectId]) - .dispatch()); + const { unreadNotifications } = await (0, MarkUserNotificationAsRead_1.markUserNotificationAsRead)(objectId); (0, ServiceWorker_1.updateNotificationLastReadTime)(); - this.updateCounter(response.totalCount); + this.updateCounter(unreadNotifications); } async markAllAsRead() { - await (0, Ajax_1.dboAction)("markAllAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction").dispatch(); + await (0, MarkAllUserNotificationsAsRead_1.markAllUserNotificationsAsRead)(); (0, ServiceWorker_1.updateNotificationLastReadTime)(); this.updateCounter(0); } diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index 3e860c32740..65dd75a9eae 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -226,6 +226,8 @@ static function (\wcf\event\endpoint\ControllerCollecting $event) { $event->register(new \wcf\system\endpoint\controller\core\users\groups\assignment\EnableAssignment()); $event->register(new \wcf\system\endpoint\controller\core\users\groups\assignment\DisableAssignment()); $event->register(new \wcf\system\endpoint\controller\core\users\groups\DeleteGroup()); + $event->register(new \wcf\system\endpoint\controller\core\users\notifications\MarkUserNotificationAsRead()); + $event->register(new \wcf\system\endpoint\controller\core\users\notifications\MarkAllUserNotificationsAsRead()); $event->register(new \wcf\system\endpoint\controller\core\menus\DeleteMenu()); $event->register(new \wcf\system\endpoint\controller\core\trophies\EnableTrophy()); $event->register(new \wcf\system\endpoint\controller\core\trophies\DisableTrophy()); diff --git a/wcfsetup/install/files/lib/command/user/notification/MarkAllUserNotificationsAsRead.class.php b/wcfsetup/install/files/lib/command/user/notification/MarkAllUserNotificationsAsRead.class.php new file mode 100644 index 00000000000..c59fd239145 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/notification/MarkAllUserNotificationsAsRead.class.php @@ -0,0 +1,85 @@ + + * @since 6.3 + */ +final class MarkAllUserNotificationsAsRead +{ + public function __construct( + private readonly int $userID, + ) {} + + public function __invoke(): void + { + // Step 1) Find the IDs of the unread notifications. + // This is done in a separate step, because this allows the UPDATE query to + // leverage fine-grained locking of exact rows based off the PRIMARY KEY. + // Simply updating all notifications belonging to a specific user will need + // to prevent concurrent threads from inserting new notifications for proper + // consistency, possibly leading to deadlocks. + $notificationIDs = $this->getUnreadNotificationIDs(); + + if ($notificationIDs !== []) { + // Step 2) Mark the notifications as read. + $this->markNotificationsAsRead($notificationIDs); + } + + $this->clearCache(); + + $event = new AllUserNotificationsMarkAsRead($this->userID); + EventHandler::getInstance()->fire($event); + } + + /** + * @return list + */ + private function getUnreadNotificationIDs(): array + { + $sql = "SELECT notificationID + FROM wcf1_user_notification + WHERE userID = ? + AND confirmTime = ? + AND time < ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $this->userID, + 0, + TIME_NOW, + ]); + + return $statement->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * @param list $notificationIDs + */ + private function markNotificationsAsRead(array $notificationIDs): void + { + $condition = new PreparedStatementConditionBuilder(); + $condition->add('notificationID IN (?)', [$notificationIDs]); + + $sql = "UPDATE wcf1_user_notification + SET confirmTime = ? + {$condition}"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute(\array_merge([TIME_NOW], $condition->getParameters())); + } + + private function clearCache(): void + { + UserStorageHandler::getInstance()->reset([$this->userID], 'userNotificationCount'); + } +} diff --git a/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php b/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php index 3eeb72245e8..26fba298d1a 100644 --- a/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php +++ b/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php @@ -5,17 +5,16 @@ use wcf\action\NotificationConfirmAction; use wcf\command\user\notification\CreateStackableUserNotification; use wcf\command\user\notification\CreateUserNotification; +use wcf\command\user\notification\MarkAllUserNotificationsAsRead; use wcf\data\AbstractDatabaseObjectAction; use wcf\data\user\User; use wcf\data\user\UserProfile; use wcf\system\cache\runtime\UserProfileRuntimeCache; -use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\exception\PermissionDeniedException; use wcf\system\request\LinkHandler; use wcf\system\style\FontAwesomeIcon; use wcf\system\user\notification\event\IUserNotificationEvent; use wcf\system\user\notification\UserNotificationHandler; -use wcf\system\user\storage\UserStorageHandler; use wcf\system\WCF; /** @@ -161,6 +160,8 @@ public function getNotificationData(): array * Validates parameters to mark a notification as confirmed. * * @return void + * + * @deprecated 6.3 */ public function validateMarkAsConfirmed() { @@ -174,6 +175,8 @@ public function validateMarkAsConfirmed() * Marks a notification as confirmed. * * @return array{markAsRead: int, totalCount: int} + * + * @deprecated 6.3 use the API-Endpoint `\wcf\system\endpoint\controller\core\users\notifications\MarkUserNotificationAsRead` instead. */ public function markAsConfirmed() { @@ -189,6 +192,8 @@ public function markAsConfirmed() * Validates parameters to mark all notifications of current user as confirmed. * * @return void + * + * @deprecated 6.3 */ public function validateMarkAllAsConfirmed() { @@ -199,42 +204,12 @@ public function validateMarkAllAsConfirmed() * Marks all notifications of current user as confirmed. * * @return array{markAllAsRead: bool} + * + * @deprecated 6.3 use the API-Endpoint `\wcf\system\endpoint\controller\core\users\notifications\MarkAllUserNotificationsAsRead` instead. */ public function markAllAsConfirmed() { - // Step 1) Find the IDs of the unread notifications. - // This is done in a separate step, because this allows the UPDATE query to - // leverage fine-grained locking of exact rows based off the PRIMARY KEY. - // Simply updating all notifications belonging to a specific user will need - // to prevent concurrent threads from inserting new notifications for proper - // consistency, possibly leading to deadlocks. - $sql = "SELECT notificationID - FROM wcf1_user_notification - WHERE userID = ? - AND confirmTime = ? - AND time < ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - WCF::getUser()->userID, - 0, - TIME_NOW, - ]); - $notificationIDs = $statement->fetchAll(\PDO::FETCH_COLUMN); - - if (!empty($notificationIDs)) { - // Step 2) Mark the notifications as read. - $condition = new PreparedStatementConditionBuilder(); - $condition->add('notificationID IN (?)', [$notificationIDs]); - - $sql = "UPDATE wcf1_user_notification - SET confirmTime = ? - {$condition}"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute(\array_merge([TIME_NOW], $condition->getParameters())); - } - - // Step 4) Clear cached values. - UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'userNotificationCount'); + (new MarkAllUserNotificationsAsRead(WCF::getUser()->userID)); return [ 'markAllAsRead' => true, diff --git a/wcfsetup/install/files/lib/event/user/notification/AllUserNotificationsMarkAsRead.class.php b/wcfsetup/install/files/lib/event/user/notification/AllUserNotificationsMarkAsRead.class.php new file mode 100644 index 00000000000..3abc82f4c93 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/notification/AllUserNotificationsMarkAsRead.class.php @@ -0,0 +1,20 @@ + + * @since 6.3 + */ +final class AllUserNotificationsMarkAsRead implements IPsr14Event +{ + public function __construct( + public readonly int $userID + ) {} +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/notifications/MarkAllUserNotificationsAsRead.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/notifications/MarkAllUserNotificationsAsRead.class.php new file mode 100644 index 00000000000..876ede6c11b --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/notifications/MarkAllUserNotificationsAsRead.class.php @@ -0,0 +1,40 @@ + + * @since 6.3 + */ +#[PostRequest('/core/users/notifications/mark-all-as-read')] +final class MarkAllUserNotificationsAsRead implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $this->assertUserIsLoggedIn(); + + (new \wcf\command\user\notification\MarkAllUserNotificationsAsRead(WCF::getUser()->userID)); + + return new JsonResponse([]); + } + + private function assertUserIsLoggedIn(): void + { + if (!WCF::getUser()->userID) { + throw new PermissionDeniedException(); + } + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/notifications/MarkUserNotificationAsRead.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/notifications/MarkUserNotificationAsRead.class.php new file mode 100644 index 00000000000..327636680e0 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/notifications/MarkUserNotificationAsRead.class.php @@ -0,0 +1,47 @@ + + * @since 6.3 + */ +#[PostRequest('/core/users/notifications/{id:\d+}/mark-as-read')] +final class MarkUserNotificationAsRead implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $notification = Helper::fetchObjectFromRequestParameter($variables['id'], UserNotification::class); + + $this->assertNotificationCanBeMarkedAsRead($notification); + + UserNotificationHandler::getInstance()->markAsConfirmedByIDs([$notification->notificationID]); + + return new JsonResponse([ + 'unreadNotifications' => UserNotificationHandler::getInstance()->getNotificationCount(true), + ]); + } + + private function assertNotificationCanBeMarkedAsRead(UserNotification $notification): void + { + if ($notification->userID !== WCF::getUser()->userID) { + throw new PermissionDeniedException(); + } + } +} From fbb9ea8aa2c508a2cfa573ba65094344c72b38e0 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Fri, 15 Aug 2025 08:33:37 +0200 Subject: [PATCH 5/6] Remove the unused type `ResponseMarkAsRead` --- ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts b/ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts index ca10de2ef15..83d77286b3b 100644 --- a/ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts +++ b/ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts @@ -130,11 +130,6 @@ type ResponseGetData = { totalCount: number; }; -type ResponseMarkAsRead = { - markAsRead: number; - totalCount: number; -}; - class UserMenuDataNotification implements DesktopNotifications, UserMenuProvider { private readonly button: HTMLElement; private readonly options: Options; From af5add2c3b3577c970413c6678526ecd3a9dac3e Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Fri, 15 Aug 2025 08:42:41 +0200 Subject: [PATCH 6/6] Refactor `CreateStackableUserNotification` to optimize notification updates and support chunked processing --- .../CreateStackableUserNotification.class.php | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php b/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php index ac955cf41d6..165ecb83866 100644 --- a/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php +++ b/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php @@ -7,6 +7,7 @@ use wcf\data\user\notification\UserNotificationList; use wcf\data\user\User; use wcf\data\user\UserProfile; +use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\WCF; /** @@ -90,33 +91,38 @@ private function createUserNotifications(UserProfile $author, array $notificatio (notificationID, authorID, time) VALUES (?, ?, ?)"; $authorStatement = WCF::getDB()->prepare($sql); - $sql = "UPDATE wcf1_user_notification - SET timesTriggered = timesTriggered + ?, - guestTimesTriggered = guestTimesTriggered + ? - WHERE notificationID = ?"; - $triggerStatement = WCF::getDB()->prepare($sql); $authorId = $author->userID; $isGuestTrigger = $authorId ? 0 : 1; $now = TIME_NOW; - $notificationIDs = []; + $notificationIDs = \array_map(static function ($notificationData) { + return $notificationData['object']->notificationID; + }, $notifications); WCF::getDB()->beginTransaction(); - foreach ($notifications as $notificationData) { - $notificationID = $notificationData['object']->notificationID; - $notificationIDs[] = $notificationID; - + foreach ($notificationIDs as $notificationID) { $authorStatement->execute([ $notificationID, $authorId, $now, ]); + } + + $chunks = \array_chunk($notificationIDs, 1_000); + foreach ($chunks as $chunk) { + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('notificationID IN (?)', [$chunk]); - $triggerStatement->execute([ + $sql = "UPDATE wcf1_user_notification + SET timesTriggered = timesTriggered + ?, + guestTimesTriggered = guestTimesTriggered + ? + " . $conditionBuilder; + $triggerStatement = WCF::getDB()->prepare($sql); + + $triggerStatement->execute(\array_merge([ 1, $isGuestTrigger, - $notificationID, - ]); + ], $conditionBuilder->getParameters())); } WCF::getDB()->commitTransaction();