[@ant] fix(push): обработчик push в генерируемом SW + видимые логи доставки

Корень «push отправлен, но не виден»: workboxMode GenerateSW игнорирует
sourceFiles.serviceWorker, поэтому push/notificationclick из
custom-service-worker.ts в прод не попадали — браузер push получал, но SW его
не показывал (бэкенд честно «sent to N subscription(s)»). Доказано: прод
/service-worker.js содержит 0 push-обработчиков.

- desktop: public/push-sw.js (push → showNotification, notificationclick →
  focus/openWindow) + importScripts('push-sw.js') в extendGenerateSWOptions
- controller: «Доставлено»/«Канал пропущен» debug→info + сводка
  «web-push → user: N подписок» — чтобы доставка была видна в логах без БД
This commit is contained in:
coopops
2026-06-09 15:25:31 +00:00
parent 31b6f5805a
commit 7c01011a51
4 changed files with 90 additions and 2 deletions
@@ -55,6 +55,9 @@ export class WebPushChannelAdapter implements WebPushChannelPort {
const title = renderTemplate(template?.subject, message) || 'Уведомление'; const title = renderTemplate(template?.subject, message) || 'Уведомление';
const body = renderTemplate(template?.body, message); const body = renderTemplate(template?.body, message);
// info-уровень: видно сколько эндпоинтов адресуем (диагностика «push не пришёл»).
this.logger.log(`web-push → '${username}': ${subscriptions.length} активн. подписк(а), workflow=${message.workflowId}`);
let anyDelivered = false; let anyDelivered = false;
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
const dto = new WebPushSubscriptionDto(new WebPushSubscriptionDomainEntity(subscription)); const dto = new WebPushSubscriptionDto(new WebPushSubscriptionDomainEntity(subscription));
@@ -142,7 +142,8 @@ export class OutboxWorkerService implements OnModuleInit {
row.status = NotificationOutboxStatus.CANCELED; row.status = NotificationOutboxStatus.CANCELED;
row.lastError = result.error; row.lastError = result.error;
await this.outboxRepository.save(row); await this.outboxRepository.save(row);
this.logger.debug(`Канал пропущен (неприменим к получателю): ${ctx}: ${result.error}`); // info-уровень: председатель/оператор должен видеть, что и почему не ушло.
this.logger.log(`Канал пропущен (неприменим к получателю): ${ctx}: ${result.error}`);
return; return;
} }
@@ -164,7 +165,8 @@ export class OutboxWorkerService implements OnModuleInit {
if (result.delivered) { if (result.delivered) {
row.status = NotificationOutboxStatus.SENT; row.status = NotificationOutboxStatus.SENT;
row.lastError = undefined; row.lastError = undefined;
this.logger.debug(`Доставлено: ${ctx}`); // info-уровень: каждая успешная доставка видна в логах без рытья в БД.
this.logger.log(`Доставлено: ${ctx}${result.providerResponse ? ` (${result.providerResponse})` : ''}`);
} else if (row.attempts >= row.maxAttempts) { } else if (row.attempts >= row.maxAttempts) {
// Попытки исчерпаны — терминальный failed (виден/переотправляем на столе председателя). // Попытки исчерпаны — терминальный failed (виден/переотправляем на столе председателя).
row.status = NotificationOutboxStatus.FAILED; row.status = NotificationOutboxStatus.FAILED;
+77
View File
@@ -0,0 +1,77 @@
/* eslint-disable */
/*
* Push-обработчик service worker'а.
*
* Подключается в СГЕНЕРИРОВАННЫЙ Workbox'ом service-worker.js через
* `importScripts` (см. quasar.config.cjs → pwa.extendGenerateSWOptions →
* cfg.importScripts). Это единственный РАБОЧИЙ источник push-логики на проде:
* режим `workboxMode: 'GenerateSW'` игнорирует sourceFiles.serviceWorker, из-за
* чего обработчик в src-pwa/custom-service-worker.ts в прод НЕ попадал и push
* приходил в браузер, но не показывался (бэкенд при этом честно «sent»).
*
* Файл в public/ → Quasar кладёт его в корень сборки, importScripts видит как
* /push-sw.js. Это обычный SW-скрипт (не ES-модуль): только self.addEventListener.
*/
self.addEventListener('push', (event) => {
let data = {};
try {
if (event.data) {
// web-push шлёт JSON-строку {title, body, icon, badge, image, url, tag, ...}
data = event.data.json();
}
} catch (e) {
try {
data = { body: event.data && event.data.text() };
} catch (e2) {
data = {};
}
}
const title = data.title || 'Новое уведомление';
const options = {
body: data.body || '',
icon: data.icon || '/icons/icon-192x192.png',
badge: data.badge || '/icons/icon-192x192.png',
image: data.image,
tag: data.tag || 'general',
requireInteraction: !!data.requireInteraction,
silent: !!data.silent,
data: {
url: data.url || '/',
timestamp: data.timestamp || Date.now(),
},
};
if (Array.isArray(data.actions) && data.actions.length) {
options.actions = data.actions;
}
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clients) => {
// Если вкладка приложения уже открыта — фокусируем и навигируем её.
for (const client of clients) {
if ('focus' in client) {
client.focus();
if ('navigate' in client && url !== '/') {
try {
client.navigate(url);
} catch (e) {
/* navigate доступен не всегда — не критично */
}
}
return;
}
}
// Иначе открываем новую вкладку.
if (self.clients.openWindow) return self.clients.openWindow(url);
}),
);
});
+6
View File
@@ -274,6 +274,12 @@ module.exports = configure(function (ctx) {
useCredentialsForManifestTag: false, useCredentialsForManifestTag: false,
useFilenameHashes: true, // Включаем хеширование файлов useFilenameHashes: true, // Включаем хеширование файлов
extendGenerateSWOptions(cfg) { extendGenerateSWOptions(cfg) {
// Push-обработчик. В режиме GenerateSW Quasar игнорирует
// sourceFiles.serviceWorker (custom-service-worker.ts), поэтому
// обработчик push/notificationclick подмешиваем в генерируемый SW
// через importScripts из статического public/push-sw.js. Без этого
// push доходит до браузера, но SW его не показывает.
cfg.importScripts = ['push-sw.js'];
// Увеличиваем максимальный размер файла для кэширования // Увеличиваем максимальный размер файла для кэширования
cfg.maximumFileSizeToCacheInBytes = 5 * 1024 * 1024; // 5MB cfg.maximumFileSizeToCacheInBytes = 5 * 1024 * 1024; // 5MB
// Не включаем ревизию для определенных файлов // Не включаем ревизию для определенных файлов