feat(notifications): Web Push (VAPID) — браузерный push о минусах
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Канал Web Push для центра уведомлений (item 5). Ключи VAPID генерируются один раз
и хранятся в platform_settings (без ручной настройки секретов); публичный отдаётся
фронту. Таблица web_push_subscriptions (Phase42) + /api/web-push/{public-key,
subscribe,unsubscribe}. Отправка через web-push-csharp из фонового джоба минусов
(best-effort, протухшие подписки чистятся). SW: обработчики push/notificationclick
(am-v3→am-v4), клик открывает нужный путь. Кнопка «Вкл. push» в колокольчике.
Работает в т.ч. в iOS Safari для PWA на домашнем экране.

Тесты: integration — публичный ключ стабилен, подписка идемпотентна, 400/unsubscribe
(4/4 группы); unit 299/299.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 03:49:32 +05:00
parent 0fd04d4f5c
commit 560d52e8cd
15 changed files with 472 additions and 7 deletions

View file

@ -35,6 +35,7 @@
<PackageVersion Include="Minio" Version="6.0.5" /> <PackageVersion Include="Minio" Version="6.0.5" />
<PackageVersion Include="ClosedXML" Version="0.104.2" /> <PackageVersion Include="ClosedXML" Version="0.104.2" />
<PackageVersion Include="MailKit" Version="4.10.0" /> <PackageVersion Include="MailKit" Version="4.10.0" />
<PackageVersion Include="WebPush" Version="1.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
<PackageVersion Include="MediatR" Version="12.4.1" /> <PackageVersion Include="MediatR" Version="12.4.1" />
<PackageVersion Include="FluentValidation" Version="11.11.0" /> <PackageVersion Include="FluentValidation" Version="11.11.0" />

View file

@ -35,13 +35,14 @@ public sealed class NegativeStockNotificationJob
private readonly INotificationsPublisher _publisher; private readonly INotificationsPublisher _publisher;
private readonly ITelegramBotClient _tg; private readonly ITelegramBotClient _tg;
private readonly IPushSender _push; private readonly IPushSender _push;
private readonly IWebPushSender _webPush;
private readonly ILogger<NegativeStockNotificationJob> _log; private readonly ILogger<NegativeStockNotificationJob> _log;
public NegativeStockNotificationJob( public NegativeStockNotificationJob(
AppDbContext db, INotificationsPublisher publisher, ITelegramBotClient tg, AppDbContext db, INotificationsPublisher publisher, ITelegramBotClient tg,
IPushSender push, ILogger<NegativeStockNotificationJob> log) IPushSender push, IWebPushSender webPush, ILogger<NegativeStockNotificationJob> log)
{ {
_db = db; _publisher = publisher; _tg = tg; _push = push; _log = log; _db = db; _publisher = publisher; _tg = tg; _push = push; _webPush = webPush; _log = log;
} }
public async Task RunAsync(CancellationToken ct = default) public async Task RunAsync(CancellationToken ct = default)
@ -171,6 +172,11 @@ private sealed record OpenAlert(Guid Id, Guid StoreId, string StoreName, Guid Pr
try { await _push.SendAsync(recipients, title, $"{n} товаров в минусе — нужна инвентаризация", null, ct); } try { await _push.SendAsync(recipients, title, $"{n} товаров в минусе — нужна инвентаризация", null, ct); }
catch (Exception ex) { _log.LogDebug(ex, "push stub failed"); } catch (Exception ex) { _log.LogDebug(ex, "push stub failed"); }
// Web Push (VAPID) — в браузеры/PWA подписанных пользователей (в т.ч. при
// закрытой вкладке). Best-effort, мёртвые подписки чистятся внутри.
try { await _webPush.SendToUsersAsync(recipients, title, $"{n} товаров в минусе — нужна инвентаризация", link, null, ct); }
catch (Exception ex) { _log.LogDebug(ex, "web push failed"); }
// Telegram — владельцу org (если привязан). // Telegram — владельцу org (если привязан).
if (org.OwnerChatId is { } chatId && _tg.IsEnabled) if (org.OwnerChatId is { } chatId && _tg.IsEnabled)
{ {

View file

@ -0,0 +1,81 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Notifications;
using foodmarket.Domain.Notifications;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers;
/// <summary>Web Push (VAPID): отдаёт публичный ключ для подписки, принимает и
/// снимает подписки браузера. Доставка push идёт из фонового джоба уведомлений
/// об отрицательных остатках. Ключи VAPID генерируются и хранятся на сервере.</summary>
[ApiController]
[Authorize]
[Route("api/web-push")]
public class WebPushController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IWebPushSender _push;
public WebPushController(AppDbContext db, ITenantContext tenant, IWebPushSender push)
{
_db = db; _tenant = tenant; _push = push;
}
/// <summary>Публичный VAPID-ключ для <c>pushManager.subscribe</c> на фронте.</summary>
[HttpGet("public-key")]
public async Task<ActionResult<object>> PublicKey(CancellationToken ct)
{
var key = await _push.GetPublicKeyAsync(ct);
return Ok(new { publicKey = key });
}
public record Keys(string p256dh, string auth);
public record SubscribeInput(string endpoint, Keys keys);
/// <summary>Сохранить подписку текущего пользователя (идемпотентно по endpoint).</summary>
[HttpPost("subscribe")]
public async Task<IActionResult> Subscribe([FromBody] SubscribeInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId; var userId = _tenant.UserId;
if (orgId is null || userId is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(input.endpoint) || input.keys is null
|| string.IsNullOrWhiteSpace(input.keys.p256dh) || string.IsNullOrWhiteSpace(input.keys.auth))
return BadRequest(new { error = "Некорректная подписка." });
var existing = await _db.WebPushSubscriptions.FirstOrDefaultAsync(s => s.Endpoint == input.endpoint, ct);
var now = DateTime.UtcNow;
if (existing is null)
{
_db.WebPushSubscriptions.Add(new WebPushSubscription
{
OrganizationId = orgId.Value, UserId = userId.Value, Endpoint = input.endpoint,
P256dh = input.keys.p256dh, Auth = input.keys.auth, LastSeenAt = now,
});
}
else
{
existing.OrganizationId = orgId.Value; existing.UserId = userId.Value;
existing.P256dh = input.keys.p256dh; existing.Auth = input.keys.auth; existing.LastSeenAt = now;
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
public record UnsubscribeInput(string endpoint);
/// <summary>Снять подписку (при отключении push / выходе).</summary>
[HttpPost("unsubscribe")]
public async Task<IActionResult> Unsubscribe([FromBody] UnsubscribeInput input, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(input.endpoint))
{
var s = await _db.WebPushSubscriptions.FirstOrDefaultAsync(x => x.Endpoint == input.endpoint, ct);
if (s is not null) { _db.WebPushSubscriptions.Remove(s); await _db.SaveChangesAsync(ct); }
}
return NoContent();
}
}

View file

@ -439,6 +439,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Inventory // Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.INegativeStockAlertService, foodmarket.Infrastructure.Inventory.NegativeStockAlertService>(); builder.Services.AddScoped<foodmarket.Application.Inventory.INegativeStockAlertService, foodmarket.Infrastructure.Inventory.NegativeStockAlertService>();
builder.Services.AddScoped<foodmarket.Application.Notifications.IPushSender, foodmarket.Infrastructure.Notifications.StubPushSender>(); builder.Services.AddScoped<foodmarket.Application.Notifications.IPushSender, foodmarket.Infrastructure.Notifications.StubPushSender>();
builder.Services.AddScoped<foodmarket.Application.Notifications.IWebPushSender, foodmarket.Infrastructure.Notifications.WebPushSender>();
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>(); builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
// Sprint 33: модуль «Деньги» // Sprint 33: модуль «Деньги»

View file

@ -0,0 +1,20 @@
namespace foodmarket.Application.Notifications;
/// <summary>Отправка Web Push (VAPID) в браузеры/PWA пользователей. Ключи VAPID
/// генерируются и хранятся на сервере (PlatformSettings); публичный отдаётся
/// фронту для подписки. Доставка — best-effort: мёртвые подписки (410/404)
/// вычищаются.</summary>
public interface IWebPushSender
{
/// <summary>true — VAPID сконфигурирован (ключи есть/сгенерированы), можно слать.</summary>
bool IsEnabled { get; }
/// <summary>Публичный VAPID-ключ (base64url) для подписки на фронте. Генерирует
/// пару ключей при первом обращении и сохраняет в PlatformSettings.</summary>
Task<string> GetPublicKeyAsync(CancellationToken ct = default);
/// <summary>Разослать push подписанным устройствам перечисленных пользователей.
/// Не бросает: ошибки логируются, протухшие подписки удаляются.</summary>
Task SendToUsersAsync(IReadOnlyCollection<Guid> userIds, string title, string body,
string? link = null, IReadOnlyDictionary<string, string>? data = null, CancellationToken ct = default);
}

View file

@ -0,0 +1,23 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Notifications;
/// <summary>Подписка браузера пользователя на Web Push (VAPID). Одна запись на
/// устройство/браузер (ключ — Endpoint). Используется для доставки уведомлений
/// об отрицательных остатках в веб-админку/PWA (в т.ч. iOS Safari на домашнем
/// экране), даже когда вкладка закрыта.</summary>
public class WebPushSubscription : TenantEntity
{
public Guid UserId { get; set; }
/// <summary>URL push-сервиса браузера (уникален на подписку).</summary>
public string Endpoint { get; set; } = null!;
/// <summary>Публичный ключ клиента (ECDH, base64url) — для шифрования payload.</summary>
public string P256dh { get; set; } = null!;
/// <summary>Auth-секрет подписки (base64url).</summary>
public string Auth { get; set; } = null!;
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
}

View file

@ -33,4 +33,13 @@ public class PlatformSettings : Entity
public string? FromEmail { get; set; } public string? FromEmail { get; set; }
public string? FromName { get; set; } public string? FromName { get; set; }
// ── Web Push (VAPID) ──────────────────────────────────────────────────────
// Пара ключей VAPID генерируется один раз при первом обращении и хранится
// здесь (стабильна между рестартами). Публичный ключ отдаётся фронту для
// подписки; приватный — только серверу (в API-ответах не возвращается).
public string? WebPushVapidPublicKey { get; set; }
public string? WebPushVapidPrivateKey { get; set; }
/// <summary>VAPID subject — mailto:/URL контакта оператора push (по умолчанию mailto:).</summary>
public string? WebPushVapidSubject { get; set; }
} }

View file

@ -0,0 +1,91 @@
using System.Text.Json;
using foodmarket.Application.Notifications;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using WebPush;
namespace foodmarket.Infrastructure.Notifications;
/// <summary>Реальная отправка Web Push (VAPID) через web-push-csharp. Ключи VAPID
/// хранятся в PlatformSettings (singleton) — генерируются один раз при первом
/// обращении. Payload — JSON {title, body, url}, который разбирает service worker.
///
/// Best-effort: любая ошибка доставки логируется и не валит вызывающего; подписки,
/// на которые push-сервис ответил 404/410 (устройство отписалось), удаляются.</summary>
public sealed class WebPushSender : IWebPushSender
{
private readonly AppDbContext _db;
private readonly ILogger<WebPushSender> _log;
private static readonly WebPushClient _client = new();
public WebPushSender(AppDbContext db, ILogger<WebPushSender> log) { _db = db; _log = log; }
// Ключи в БД → всегда можно сгенерировать, поэтому включён всегда.
public bool IsEnabled => true;
public async Task<string> GetPublicKeyAsync(CancellationToken ct = default)
{
var (pub, _, _) = await EnsureKeysAsync(ct);
return pub;
}
private async Task<(string Public, string Private, string Subject)> EnsureKeysAsync(CancellationToken ct)
{
var ps = await _db.PlatformSettings.FirstOrDefaultAsync(ct);
if (ps is null)
{
ps = new Domain.Platform.PlatformSettings();
_db.PlatformSettings.Add(ps);
}
if (string.IsNullOrWhiteSpace(ps.WebPushVapidPublicKey) || string.IsNullOrWhiteSpace(ps.WebPushVapidPrivateKey))
{
var keys = VapidHelper.GenerateVapidKeys();
ps.WebPushVapidPublicKey = keys.PublicKey;
ps.WebPushVapidPrivateKey = keys.PrivateKey;
await _db.SaveChangesAsync(ct);
_log.LogInformation("Сгенерирована пара VAPID-ключей для Web Push");
}
var subject = string.IsNullOrWhiteSpace(ps.WebPushVapidSubject) ? "mailto:admin@food-market.kz" : ps.WebPushVapidSubject!;
return (ps.WebPushVapidPublicKey!, ps.WebPushVapidPrivateKey!, subject);
}
public async Task SendToUsersAsync(IReadOnlyCollection<Guid> userIds, string title, string body,
string? link = null, IReadOnlyDictionary<string, string>? data = null, CancellationToken ct = default)
{
if (userIds.Count == 0) return;
var subs = await _db.WebPushSubscriptions.Where(s => userIds.Contains(s.UserId)).ToListAsync(ct);
if (subs.Count == 0) return;
var (pub, priv, subject) = await EnsureKeysAsync(ct);
var vapid = new VapidDetails(subject, pub, priv);
var payload = JsonSerializer.Serialize(new
{
title, body, url = link ?? "/",
data = data ?? new Dictionary<string, string>(),
});
var dead = new List<Domain.Notifications.WebPushSubscription>();
foreach (var s in subs)
{
try
{
var pushSub = new PushSubscription(s.Endpoint, s.P256dh, s.Auth);
await _client.SendNotificationAsync(pushSub, payload, vapid, ct);
}
catch (WebPushException ex) when ((int)ex.StatusCode is 404 or 410)
{
dead.Add(s); // устройство отписалось — удаляем подписку
}
catch (Exception ex)
{
_log.LogWarning(ex, "Web push не доставлен на подписку {Id}", s.Id);
}
}
if (dead.Count > 0)
{
_db.WebPushSubscriptions.RemoveRange(dead);
await _db.SaveChangesAsync(ct);
}
}
}

View file

@ -78,6 +78,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>(); public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>();
public DbSet<foodmarket.Domain.Organizations.DeviceToken> DeviceTokens => Set<foodmarket.Domain.Organizations.DeviceToken>(); public DbSet<foodmarket.Domain.Organizations.DeviceToken> DeviceTokens => Set<foodmarket.Domain.Organizations.DeviceToken>();
public DbSet<foodmarket.Domain.Notifications.Notification> Notifications => Set<foodmarket.Domain.Notifications.Notification>(); public DbSet<foodmarket.Domain.Notifications.Notification> Notifications => Set<foodmarket.Domain.Notifications.Notification>();
public DbSet<foodmarket.Domain.Notifications.WebPushSubscription> WebPushSubscriptions => Set<foodmarket.Domain.Notifications.WebPushSubscription>();
public DbSet<Employee> Employees => Set<Employee>(); public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>(); public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>(); public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
@ -222,6 +223,16 @@ protected override void OnModelCreating(ModelBuilder builder)
b.HasIndex(x => new { x.OrganizationId, x.RecipientUserId, x.CreatedAt }); b.HasIndex(x => new { x.OrganizationId, x.RecipientUserId, x.CreatedAt });
}); });
builder.Entity<foodmarket.Domain.Notifications.WebPushSubscription>(b =>
{
b.ToTable("web_push_subscriptions");
b.Property(x => x.Endpoint).HasMaxLength(2000).IsRequired();
b.Property(x => x.P256dh).HasMaxLength(500).IsRequired();
b.Property(x => x.Auth).HasMaxLength(500).IsRequired();
b.HasIndex(x => x.Endpoint).IsUnique();
b.HasIndex(x => new { x.OrganizationId, x.UserId });
});
builder.ConfigureCatalog(); builder.ConfigureCatalog();
builder.ConfigureInventory(); builder.ConfigureInventory();
builder.ConfigurePurchases(); builder.ConfigurePurchases();

View file

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase42 — Web Push (VAPID): таблица подписок браузеров
/// (<c>web_push_subscriptions</c>) + колонки VAPID-ключей в platform_settings
/// (генерируются один раз при первом обращении). Идемпотентно.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261009040000_Phase42_WebPush")]
public partial class Phase42_WebPush : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.web_push_subscriptions (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""UserId"" uuid NOT NULL,
""Endpoint"" varchar(2000) NOT NULL,
""P256dh"" varchar(500) NOT NULL,
""Auth"" varchar(500) NOT NULL,
""LastSeenAt"" timestamp with time zone NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone
);
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_web_push_subscriptions_Endpoint""
ON public.web_push_subscriptions (""Endpoint"");
CREATE INDEX IF NOT EXISTS ""IX_web_push_subscriptions_Org_User""
ON public.web_push_subscriptions (""OrganizationId"", ""UserId"");
");
b.Sql(@"
ALTER TABLE public.platform_settings
ADD COLUMN IF NOT EXISTS ""WebPushVapidPublicKey"" text,
ADD COLUMN IF NOT EXISTS ""WebPushVapidPrivateKey"" text,
ADD COLUMN IF NOT EXISTS ""WebPushVapidSubject"" text;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"DROP TABLE IF EXISTS public.web_push_subscriptions;");
b.Sql(@"
ALTER TABLE public.platform_settings
DROP COLUMN IF EXISTS ""WebPushVapidPublicKey"",
DROP COLUMN IF EXISTS ""WebPushVapidPrivateKey"",
DROP COLUMN IF EXISTS ""WebPushVapidSubject"";
");
}
}
}

View file

@ -19,6 +19,7 @@
<PackageReference Include="OpenIddict.EntityFrameworkCore" /> <PackageReference Include="OpenIddict.EntityFrameworkCore" />
<PackageReference Include="Hangfire.PostgreSql" /> <PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="MailKit" /> <PackageReference Include="MailKit" />
<PackageReference Include="WebPush" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -13,8 +13,9 @@
* и ?v=<ASSET_VERSION> в ссылках (index.html, manifest, Logo.tsx). Иначе у тех, * и ?v=<ASSET_VERSION> в ссылках (index.html, manifest, Logo.tsx). Иначе у тех,
* кто уже открывал админку, останется старое лого из прекэша SW. * кто уже открывал админку, останется старое лого из прекэша SW.
* 2026-10: ребрендинг Food Market → ALL MARKET — fm-v2 → am-v3. * 2026-10: ребрендинг Food Market → ALL MARKET — fm-v2 → am-v3.
* 2026-10: Web Push (VAPID) — обработчики push/notificationclick; am-v3 → am-v4.
*/ */
const CACHE_VERSION = 'am-v3'; const CACHE_VERSION = 'am-v4';
const ASSET_VERSION = 'am3'; // ?v=am3 на нехэшированных бренд-ассетах const ASSET_VERSION = 'am3'; // ?v=am3 на нехэшированных бренд-ассетах
const STATIC_CACHE = `${CACHE_VERSION}-static`; const STATIC_CACHE = `${CACHE_VERSION}-static`;
const API_CACHE = `${CACHE_VERSION}-api`; const API_CACHE = `${CACHE_VERSION}-api`;
@ -45,6 +46,39 @@ self.addEventListener('activate', (event) => {
); );
}); });
// Web Push: сервер шлёт JSON {title, body, url}. Показываем нотификацию даже при
// закрытой вкладке. Клик — открываем/фокусируем админку на нужном пути (url).
self.addEventListener('push', (event) => {
let data = {};
try { data = event.data ? event.data.json() : {}; } catch { data = {}; }
const title = data.title || 'ALL MARKET';
const body = data.body || '';
const url = data.url || '/';
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: `/logo.svg?v=${ASSET_VERSION}`,
badge: `/favicon.svg?v=${ASSET_VERSION}`,
tag: 'negative-stock', // новые перекрывают старое, без спама
renotify: true,
data: { url },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const c of clients) {
if ('focus' in c) { c.navigate(target); return c.focus(); }
}
return self.clients.openWindow(target);
})
);
});
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const req = event.request; const req = event.request;
if (req.method !== 'GET') return; // не вмешиваемся в мутации if (req.method !== 'GET') return; // не вмешиваемся в мутации

View file

@ -15,7 +15,8 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { Bell, ShoppingCart, TruckIcon, AlertTriangle, X, Loader2 } from 'lucide-react' import { Bell, BellRing, ShoppingCart, TruckIcon, AlertTriangle, X, Loader2 } from 'lucide-react'
import { enableWebPush, getWebPushState, type WebPushState } from '@/lib/webPush'
import { useNotificationsHub, type SalePostedPayload, type SupplyPostedPayload } from '@/lib/useNotificationsHub' import { useNotificationsHub, type SalePostedPayload, type SupplyPostedPayload } from '@/lib/useNotificationsHub'
import { useNotifications, useMarkAllNotificationsRead, type NotificationRow } from '@/lib/useNotifications' import { useNotifications, useMarkAllNotificationsRead, type NotificationRow } from '@/lib/useNotifications'
import { useFormatCurrency } from '@/lib/useFormatCurrency' import { useFormatCurrency } from '@/lib/useFormatCurrency'
@ -41,6 +42,15 @@ export function NotificationCenter() {
const persisted = data?.items ?? [] const persisted = data?.items ?? []
const serverUnread = data?.unread ?? 0 const serverUnread = data?.unread ?? 0
const [pushState, setPushState] = useState<WebPushState>('default')
const [pushBusy, setPushBusy] = useState(false)
useEffect(() => { if (open) getWebPushState().then(setPushState).catch(() => {}) }, [open])
async function togglePush() {
if (pushBusy || pushState === 'subscribed') return
setPushBusy(true)
try { setPushState(await enableWebPush()) } catch { /* ignore */ } finally { setPushBusy(false) }
}
useNotificationsHub({ useNotificationsHub({
onSalePosted: (p) => setLive((prev) => prependLive(prev, { kind: 'sale', id: `sale-${p.saleId}`, at: p.postedAt, payload: p })), onSalePosted: (p) => setLive((prev) => prependLive(prev, { kind: 'sale', id: `sale-${p.saleId}`, at: p.postedAt, payload: p })),
onSupplyPosted: (p) => setLive((prev) => prependLive(prev, { kind: 'supply', id: `supply-${p.supplyId}`, at: p.postedAt, payload: p })), onSupplyPosted: (p) => setLive((prev) => prependLive(prev, { kind: 'supply', id: `supply-${p.supplyId}`, at: p.postedAt, payload: p })),
@ -112,10 +122,26 @@ export function NotificationCenter() {
<div role="dialog" aria-label="Уведомления" className="absolute left-0 bottom-full mb-2 w-80 max-w-[90vw] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg shadow-xl z-50"> <div role="dialog" aria-label="Уведомления" className="absolute left-0 bottom-full mb-2 w-80 max-w-[90vw] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg shadow-xl z-50">
<div className="flex items-center justify-between px-3 py-2 border-b border-slate-200 dark:border-slate-800"> <div className="flex items-center justify-between px-3 py-2 border-b border-slate-200 dark:border-slate-800">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Уведомления</h3> <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Уведомления</h3>
<div className="flex items-center gap-2">
{pushState !== 'unsupported' && (
<button
type="button"
onClick={togglePush}
disabled={pushBusy || pushState === 'subscribed' || pushState === 'denied'}
title={pushState === 'subscribed' ? 'Push-уведомления включены'
: pushState === 'denied' ? 'Push запрещены в браузере'
: 'Получать push даже при закрытой вкладке'}
className={`inline-flex items-center gap-1 text-[11px] ${pushState === 'subscribed' ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-100'}`}
>
{pushBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <BellRing className="w-3.5 h-3.5" />}
{pushState === 'subscribed' ? 'Push вкл.' : pushState === 'denied' ? 'Push ✕' : 'Вкл. push'}
</button>
)}
<button type="button" onClick={() => setOpen(false)} aria-label="Закрыть" className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-100"> <button type="button" onClick={() => setOpen(false)} aria-label="Закрыть" className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-100">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
</div>
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto">
{isEmpty ? ( {isEmpty ? (
<div className="px-3 py-8 text-center text-sm text-slate-500 dark:text-slate-400"> <div className="px-3 py-8 text-center text-sm text-slate-500 dark:text-slate-400">

View file

@ -0,0 +1,77 @@
import { api } from './api'
/**
* Web Push (VAPID) на фронте: запрос публичного ключа, подписка через
* существующий service worker, регистрация подписки на сервере. Доставка —
* из фонового джоба уведомлений об отрицательных остатках. Работает в Chrome/
* Firefox/Edge и в iOS Safari для PWA, добавленного на домашний экран.
*/
export function isWebPushSupported(): boolean {
return typeof window !== 'undefined'
&& 'serviceWorker' in navigator
&& 'PushManager' in window
&& 'Notification' in window
}
export type WebPushState = 'unsupported' | 'denied' | 'subscribed' | 'default'
export async function getWebPushState(): Promise<WebPushState> {
if (!isWebPushSupported()) return 'unsupported'
if (Notification.permission === 'denied') return 'denied'
try {
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
if (sub) return 'subscribed'
} catch { /* нет регистрации — ниже вернём default */ }
return 'default'
}
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
const out = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i)
return out
}
/** Включить push: разрешение → подписка в браузере → регистрация на сервере.
* Возвращает итоговое состояние. */
export async function enableWebPush(): Promise<WebPushState> {
if (!isWebPushSupported()) return 'unsupported'
const perm = await Notification.requestPermission()
if (perm !== 'granted') return perm === 'denied' ? 'denied' : 'default'
const reg = (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.register('/sw.js'))
await navigator.serviceWorker.ready
const { publicKey } = (await api.get<{ publicKey: string }>('/api/web-push/public-key')).data
let sub = await reg.pushManager.getSubscription()
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey) as unknown as BufferSource,
})
}
const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } }
await api.post('/api/web-push/subscribe', {
endpoint: json.endpoint,
keys: { p256dh: json.keys?.p256dh, auth: json.keys?.auth },
})
return 'subscribed'
}
/** Отключить push: снять подписку в браузере и на сервере. */
export async function disableWebPush(): Promise<WebPushState> {
if (!isWebPushSupported()) return 'unsupported'
try {
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
if (sub) {
await api.post('/api/web-push/unsubscribe', { endpoint: sub.endpoint }).catch(() => {})
await sub.unsubscribe()
}
} catch { /* already gone */ }
return 'default'
}

View file

@ -159,4 +159,33 @@ public async Task Notify_job_creates_persistent_notification_for_admin_and_marks
(await api.Http.PostAsJsonAsync("/api/notifications/read-all", new { })).EnsureSuccessStatusCode(); (await api.Http.PostAsJsonAsync("/api/notifications/read-all", new { })).EnsureSuccessStatusCode();
(await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32().Should().Be(0); (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32().Should().Be(0);
} }
[Fact]
public async Task WebPush_public_key_generated_and_subscription_roundtrips()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"wp-{Guid.NewGuid():N}");
// Публичный VAPID-ключ генерируется при первом обращении и стабилен.
var k1 = (await api.GetJsonAsync("/api/web-push/public-key")).GetProperty("publicKey").GetString();
k1.Should().NotBeNullOrEmpty();
var k2 = (await api.GetJsonAsync("/api/web-push/public-key")).GetProperty("publicKey").GetString();
k2.Should().Be(k1, "ключ хранится в platform_settings, не перегенерируется");
// Подписка идемпотентна по endpoint.
var sub = new { endpoint = "https://push.example.com/" + Guid.NewGuid().ToString("N"),
keys = new { p256dh = "BBindkeyp256dhExampleValue", auth = "authSecretExample" } };
(await api.Http.PostAsJsonAsync("/api/web-push/subscribe", sub)).StatusCode
.Should().Be(System.Net.HttpStatusCode.NoContent);
(await api.Http.PostAsJsonAsync("/api/web-push/subscribe", sub)).StatusCode
.Should().Be(System.Net.HttpStatusCode.NoContent);
// Невалидная подписка → 400.
(await api.Http.PostAsJsonAsync("/api/web-push/subscribe", new { endpoint = "", keys = new { p256dh = "", auth = "" } })).StatusCode
.Should().Be(System.Net.HttpStatusCode.BadRequest);
// Снятие подписки.
(await api.Http.PostAsJsonAsync("/api/web-push/unsubscribe", new { endpoint = sub.endpoint })).StatusCode
.Should().Be(System.Net.HttpStatusCode.NoContent);
}
} }