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..83d77286b3b 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 { @@ -128,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; @@ -277,16 +274,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/CreateStackableUserNotification.class.php b/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php new file mode 100644 index 00000000000..165ecb83866 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/notification/CreateStackableUserNotification.class.php @@ -0,0 +1,214 @@ + + * @since 6.3 + */ +final class CreateStackableUserNotification +{ + 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 + { + $existingNotifications = $this->getExistingNotifications($this->eventID, $this->eventHash, $this->recipients); + + $notifications = []; + foreach ($this->recipients as $recipient) { + $notification = ($existingNotifications[$recipient->userID] ?? null); + $isNew = ($notification === null); + + if ($notification === null) { + $notification = $this->createNotification( + $this->packageID, + $this->eventID, + $this->eventHash, + $this->objectID, + $this->baseObjectID, + $this->author, + $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); + + $authorId = $author->userID; + $isGuestTrigger = $authorId ? 0 : 1; + $now = TIME_NOW; + $notificationIDs = \array_map(static function ($notificationData) { + return $notificationData['object']->notificationID; + }, $notifications); + + WCF::getDB()->beginTransaction(); + 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]); + + $sql = "UPDATE wcf1_user_notification + SET timesTriggered = timesTriggered + ?, + guestTimesTriggered = guestTimesTriggered + ? + " . $conditionBuilder; + $triggerStatement = WCF::getDB()->prepare($sql); + + $triggerStatement->execute(\array_merge([ + 1, + $isGuestTrigger, + ], $conditionBuilder->getParameters())); + } + 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(int $eventID, string $eventHash, array $recipients): array + { + $notificationList = new UserNotificationList(); + $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(); + + $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']); + } + + /** + * @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 shouldNotifyByMail(User $recipient): bool + { + return $recipient->mailNotificationType === UserNotification::MAIL_NOTIFICATION_TYPE_NONE + || $recipient->mailNotificationType === UserNotification::MAIL_NOTIFICATION_TYPE_INSTANT; + } + + private function createNotification( + int $packageID, + int $eventID, + string $eventHash, + int $objectID, + int $baseObjectID, + ?UserProfile $author, + string $additionalData, + User $recipient, + ): UserNotification { + return UserNotificationEditor::create([ + 'packageID' => $packageID, + 'eventID' => $eventID, + 'objectID' => $objectID, + 'baseObjectID' => $baseObjectID, + 'eventHash' => $eventHash, + 'authorID' => $author->userID ?: null, + '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/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/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..26fba298d1a 100644 --- a/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php +++ b/wcfsetup/install/files/lib/data/user/notification/UserNotificationAction.class.php @@ -3,15 +3,18 @@ namespace wcf\data\user\notification; 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\database\util\PreparedStatementConditionBuilder; +use wcf\system\cache\runtime\UserProfileRuntimeCache; 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; /** @@ -35,127 +38,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); } /** @@ -231,6 +160,8 @@ public function getNotificationData(): array * Validates parameters to mark a notification as confirmed. * * @return void + * + * @deprecated 6.3 */ public function validateMarkAsConfirmed() { @@ -244,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() { @@ -259,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() { @@ -269,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(); + } + } +} 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..774b42b74ea 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,8 @@ namespace wcf\system\user\notification; 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; @@ -259,84 +261,79 @@ public function fireEvent( $recipientList->getConditionBuilder()->add('event_to_user.userID IN (?)', [$recipientIDs]); $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()) { - $data['notifications'] = $notifications; + if ($recipients === []) { + return; + } - $action = new UserNotificationAction([], 'createStackable', $data); - } else { - $data['data']['timesTriggered'] = 1; - $action = new UserNotificationAction([], 'createDefault', $data); - } + 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) + ))(); + } - $result = $action->executeAction(); - $notifications = $result['returnValues']; - - // send notifications - if ($event->supportsEmailNotification()) { - foreach ($recipients as $recipient) { - if ($recipient->mailNotificationType == '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'] ?? null; - if ($notificationObject === null) { - continue; - } - \assert($notificationObject instanceof UserNotification); - $statement->execute([ - $notificationObject->notificationID, - $notificationObject->time, - $userID - ]); - } - BackgroundQueueHandler::getInstance()->enqueueIn(new ServiceWorkerDeliveryBackgroundJob()); - // reset notification count - UserStorageHandler::getInstance()->reset(\array_keys($recipients), 'userNotificationCount'); + foreach ($notifications as $userID => $notification) { + $notificationObject = $notification['object']; - $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); } /**