0% прочитано

“Позначити все прочитаним” - це одна серверна операція, а не цикл запитів із браузера

Масова дія не повинна залежати від того, скільки нотифікацій зараз завантажено в інтерфейсі. Розбираю, чому “позначити все прочитаним” краще виконувати одним серверним запитом, як обмежити його поточним користувачем і як після цього синхронізувати клієнтський стан.

31 жовтня 2026 р. 9 хв читанняЛаравель

Кнопка “Позначити все прочитаним” виглядає просто. Але реалізація легко стає неправильною, якщо браузер починає проходити циклом по нотифікаціях і відправляти окремий запит для кожної.

У такому підході клієнт фактично вирішує, що означає “все”. Якщо на екрані завантажено лише перші 20 записів із 300, цикл позначить прочитаними тільки ці 20.

Для мене масова дія повинна описуватися на сервері як одна бізнесова операція: позначити всі непрочитані нотифікації поточного користувача прочитаними.

Масова дія повинна залежати від стану в базі даних, а не від того, яку частину списку зараз бачить браузер.

Цикл запитів у браузері ставить межу операції не там

Клієнт виконує одну бізнесову дію як багато окремих HTTP-запитів
for (const notification of notifications.value) {    await api.markAsRead(        notification.id,    );}

Проблема тут не лише в кількості запитів.

Такий код робить результат залежним від:

  • пагінації;
  • того, скільки записів уже завантажено;
  • порядку виконання запитів;
  • мережевих помилок посеред циклу;
  • повторного натискання кнопки;
  • того, чи з’явилися нові нотифікації під час виконання.

На сервері “все” має конкретне значення

Сервер має доступ до повного набору даних і може сформулювати операцію без прив’язки до поточного екрана.

Наприклад: оновити всі нотифікації поточного користувача, у яких read_at IS NULL.

Одна операція оновлює весь потрібний набір
$markedCount = Notification::query()    ->where(        'user_id',        $user->id,    )    ->whereNull(        'read_at',    )    ->update([        'read_at' => now(),    ]);

Критична частина - правильне обмеження операції поточним користувачем

Масовий UPDATE стає небезпечним, якщо в ньому нечітка умова.

Тому “позначити все прочитаним” не повинно означати “оновити всі непрочитані рядки таблиці”. Воно повинно означати “оновити всі непрочитані нотифікації, які належать саме цьому користувачу”.

Якщо система підтримує кілька контекстів, організацій або типів нотифікацій, потрібні додаткові умови повинні бути частиною того самого серверного запиту.

Повторне виконання повинно бути безпечним

Користувач може натиснути кнопку двічі. Браузер може повторити запит. Мережа може розірватися після того, як сервер уже виконав оновлення, але до того, як відповідь дійшла до клієнта.

Якщо сервер оновлює тільки записи з read_at IS NULL, повторний виклик просто не знайде нових рядків для зміни.

Це природно робить операцію ідемпотентною з погляду кінцевого стану.

Один момент часу краще за сотні трохи різних значень

Один момент часу для однієї масової дії
$readAt = now(); $markedCount = Notification::query()    ->where(        'user_id',        $user->id,    )    ->whereNull(        'read_at',    )    ->update([        'read_at' => $readAt,    ]);

Якщо ця дія логічно є однією операцією користувача, мені зручніше зафіксувати один момент часу й використати його для всіх оновлених рядків.

Тоді дані краще відображають факт: користувач виконав одну масову дію в конкретний момент.

Відповідь сервера повинна допомогти клієнту перейти до правильного стану

Сервер повертає результат масової операції
return response()->json([    'success' => true,    'markedCount' => $markedCount,    'unreadCount' => 0,]);

Після успішної відповіді клієнту не потрібно чекати десятки окремих відповідей і рахувати, які саме записи вже завершилися.

Він може встановити канонічний лічильник непрочитаних і оновити локально ті нотифікації, які вже є в пам’яті.

Інтерфейс оновлює тільки локальне представлення, а не визначає обсяг операції

Після успіху клієнт синхронізує лише вже завантажені дані
async function markAllAsRead() {    const result =        await api.markAllAsRead();     unreadCount.value =        result.unreadCount;     notifications.value =        notifications.value.map(            (notification) => ({                ...notification,                readAt:                    notification.readAt                    ?? new Date().toISOString(),            }),        );

Тут важливо розділити дві ролі.

Сервер вирішує, які записи входять у “все”. Браузер лише відображає результат для тієї частини списку, яка вже присутня в інтерфейсі.

Наступна сторінка пагінації вже прийде із сервера з правильним read_at, тому клієнту не потрібно було завантажувати її наперед.

Що робити, якщо нова нотифікація з’явилася одночасно з масовою дією

Тут уже потрібне чітке продуктове правило.

Можливі два різні значення кнопки:

  • позначити прочитаним усе, що існувало на момент натискання;
  • позначити прочитаним усе, що відповідає умові на момент виконання серверного UPDATE.

У більшості звичайних інтерфейсів другий варіант достатньо природний. Але якщо межа дії має бути строгою, її краще зафіксувати явно, наприклад через created_at <= $actionStartedAt.

Транзакція потрібна не завжди, але межа операції повинна бути зрозумілою

Якщо вся дія складається з одного SQL UPDATE, база даних уже виконує цей SQL-вираз як одну операцію.

Але якщо разом із масовим оновленням треба змінити окремий лічильник, записати аудит або створити інший пов’язаний стан, тоді варто оцінити, чи мають ці записи змінюватися в одній транзакції.

Головне - не додавати транзакцію механічно, а визначити справжню межу узгодженості.

Маршрут “позначити все” не повинен приймати довільний user_id від клієнта

Якщо операція стосується поточного авторизованого користувача, його ідентифікатор краще брати з контексту автентифікації.

Запит на кшталт POST /notifications/read-all не потребує user_id у тілі лише для того, щоб сервер потім повірив цьому значенню.

Це зменшує поверхню помилки й робить межу авторизації очевиднішою.

Не перетворюйте масову дію на сотні однакових подій без потреби

Якщо після зміни read_at немає окремої бізнесової реакції на кожну нотифікацію, не обов’язково завантажувати всі моделі й викликати save() для кожної лише заради подій моделі.

Масовий UPDATE може бути правильнішим і значно дешевшим.

Якщо ж кожна зміна справді має запускати важливу окрему поведінку, це вже інша вимога, і її треба врахувати свідомо.

Що варто перевірити тестами

Мінімальний набір сценаріїв
1. Оновлюються лише непрочитані сповіщення поточного користувача.2. Для вже прочитаних сповіщень зберігається наявне значення `read_at`.3. Сповіщення, що належать іншому користувачеві, залишаються без змін.4. Операція працює, навіть якщо в інтерфейсі користувача завантажено лише одну сторінку.5. Повторний запит є безпечним.6. Відповідь містить очікувану кількість непрочитаних сповіщень.7. Нове сповіщення, створене після виконання операції, залишається непрочитаним.8. Дотримуються вимоги щодо обмеження контекстом орендаря (tenant) або організації.9. Авторизація не залежить від ідентифікатора користувача (`user_id`), наданого браузером.10. Клієнт оновлює завантажені рядки після отримання успішної відповіді від сервера.

Масова дія повинна бути масовою вже на рівні сервера

“Позначити все прочитаним” - це хороший приклад того, як межа між клієнтом і сервером впливає на правильність системи.

Браузер не повинен перераховувати завантажені елементи й вирішувати, що саме входить у “все”. Він повинен попросити сервер виконати одну чітку операцію.

Сервер визначає повний набір, виконує масове оновлення і повертає канонічний результат. Інтерфейс лише відображає цей результат.

Якщо кнопка означає “все”, її результат не повинен залежати від того, скільки рядків браузер уже встиг завантажити.