diff --git a/Directory.Packages.props b/Directory.Packages.props index 270613a..bd50536 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,6 +35,7 @@ + diff --git a/src/food-market.api/Background/NegativeStockNotificationJob.cs b/src/food-market.api/Background/NegativeStockNotificationJob.cs index 74b387e..7773f89 100644 --- a/src/food-market.api/Background/NegativeStockNotificationJob.cs +++ b/src/food-market.api/Background/NegativeStockNotificationJob.cs @@ -35,13 +35,14 @@ public sealed class NegativeStockNotificationJob private readonly INotificationsPublisher _publisher; private readonly ITelegramBotClient _tg; private readonly IPushSender _push; + private readonly IWebPushSender _webPush; private readonly ILogger _log; public NegativeStockNotificationJob( AppDbContext db, INotificationsPublisher publisher, ITelegramBotClient tg, - IPushSender push, ILogger log) + IPushSender push, IWebPushSender webPush, ILogger 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) @@ -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); } 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 (если привязан). if (org.OwnerChatId is { } chatId && _tg.IsEnabled) { diff --git a/src/food-market.api/Controllers/WebPushController.cs b/src/food-market.api/Controllers/WebPushController.cs new file mode 100644 index 0000000..19d18c6 --- /dev/null +++ b/src/food-market.api/Controllers/WebPushController.cs @@ -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; + +/// Web Push (VAPID): отдаёт публичный ключ для подписки, принимает и +/// снимает подписки браузера. Доставка push идёт из фонового джоба уведомлений +/// об отрицательных остатках. Ключи VAPID генерируются и хранятся на сервере. +[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; + } + + /// Публичный VAPID-ключ для pushManager.subscribe на фронте. + [HttpGet("public-key")] + public async Task> 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); + + /// Сохранить подписку текущего пользователя (идемпотентно по endpoint). + [HttpPost("subscribe")] + public async Task 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); + + /// Снять подписку (при отключении push / выходе). + [HttpPost("unsubscribe")] + public async Task 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(); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index b428cbe..588b17d 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -439,6 +439,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // Inventory builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); // Sprint 33: модуль «Деньги» diff --git a/src/food-market.application/Notifications/IWebPushSender.cs b/src/food-market.application/Notifications/IWebPushSender.cs new file mode 100644 index 0000000..0a5e3d3 --- /dev/null +++ b/src/food-market.application/Notifications/IWebPushSender.cs @@ -0,0 +1,20 @@ +namespace foodmarket.Application.Notifications; + +/// Отправка Web Push (VAPID) в браузеры/PWA пользователей. Ключи VAPID +/// генерируются и хранятся на сервере (PlatformSettings); публичный отдаётся +/// фронту для подписки. Доставка — best-effort: мёртвые подписки (410/404) +/// вычищаются. +public interface IWebPushSender +{ + /// true — VAPID сконфигурирован (ключи есть/сгенерированы), можно слать. + bool IsEnabled { get; } + + /// Публичный VAPID-ключ (base64url) для подписки на фронте. Генерирует + /// пару ключей при первом обращении и сохраняет в PlatformSettings. + Task GetPublicKeyAsync(CancellationToken ct = default); + + /// Разослать push подписанным устройствам перечисленных пользователей. + /// Не бросает: ошибки логируются, протухшие подписки удаляются. + Task SendToUsersAsync(IReadOnlyCollection userIds, string title, string body, + string? link = null, IReadOnlyDictionary? data = null, CancellationToken ct = default); +} diff --git a/src/food-market.domain/Notifications/WebPushSubscription.cs b/src/food-market.domain/Notifications/WebPushSubscription.cs new file mode 100644 index 0000000..510dc99 --- /dev/null +++ b/src/food-market.domain/Notifications/WebPushSubscription.cs @@ -0,0 +1,23 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Notifications; + +/// Подписка браузера пользователя на Web Push (VAPID). Одна запись на +/// устройство/браузер (ключ — Endpoint). Используется для доставки уведомлений +/// об отрицательных остатках в веб-админку/PWA (в т.ч. iOS Safari на домашнем +/// экране), даже когда вкладка закрыта. +public class WebPushSubscription : TenantEntity +{ + public Guid UserId { get; set; } + + /// URL push-сервиса браузера (уникален на подписку). + public string Endpoint { get; set; } = null!; + + /// Публичный ключ клиента (ECDH, base64url) — для шифрования payload. + public string P256dh { get; set; } = null!; + + /// Auth-секрет подписки (base64url). + public string Auth { get; set; } = null!; + + public DateTime LastSeenAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/food-market.domain/Platform/PlatformSettings.cs b/src/food-market.domain/Platform/PlatformSettings.cs index 966180f..2947854 100644 --- a/src/food-market.domain/Platform/PlatformSettings.cs +++ b/src/food-market.domain/Platform/PlatformSettings.cs @@ -33,4 +33,13 @@ public class PlatformSettings : Entity public string? FromEmail { get; set; } public string? FromName { get; set; } + + // ── Web Push (VAPID) ────────────────────────────────────────────────────── + // Пара ключей VAPID генерируется один раз при первом обращении и хранится + // здесь (стабильна между рестартами). Публичный ключ отдаётся фронту для + // подписки; приватный — только серверу (в API-ответах не возвращается). + public string? WebPushVapidPublicKey { get; set; } + public string? WebPushVapidPrivateKey { get; set; } + /// VAPID subject — mailto:/URL контакта оператора push (по умолчанию mailto:). + public string? WebPushVapidSubject { get; set; } } diff --git a/src/food-market.infrastructure/Notifications/WebPushSender.cs b/src/food-market.infrastructure/Notifications/WebPushSender.cs new file mode 100644 index 0000000..62d8367 --- /dev/null +++ b/src/food-market.infrastructure/Notifications/WebPushSender.cs @@ -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; + +/// Реальная отправка Web Push (VAPID) через web-push-csharp. Ключи VAPID +/// хранятся в PlatformSettings (singleton) — генерируются один раз при первом +/// обращении. Payload — JSON {title, body, url}, который разбирает service worker. +/// +/// Best-effort: любая ошибка доставки логируется и не валит вызывающего; подписки, +/// на которые push-сервис ответил 404/410 (устройство отписалось), удаляются. +public sealed class WebPushSender : IWebPushSender +{ + private readonly AppDbContext _db; + private readonly ILogger _log; + private static readonly WebPushClient _client = new(); + + public WebPushSender(AppDbContext db, ILogger log) { _db = db; _log = log; } + + // Ключи в БД → всегда можно сгенерировать, поэтому включён всегда. + public bool IsEnabled => true; + + public async Task 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 userIds, string title, string body, + string? link = null, IReadOnlyDictionary? 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(), + }); + + var dead = new List(); + 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); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 2f1b76a..369ab90 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -78,6 +78,7 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet EmployeeRoles => Set(); public DbSet DeviceTokens => Set(); public DbSet Notifications => Set(); + public DbSet WebPushSubscriptions => Set(); public DbSet Employees => Set(); public DbSet EmployeeRetailPointAssignments => Set(); public DbSet SuperAdminAuditLogs => Set(); @@ -222,6 +223,16 @@ protected override void OnModelCreating(ModelBuilder builder) b.HasIndex(x => new { x.OrganizationId, x.RecipientUserId, x.CreatedAt }); }); + builder.Entity(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.ConfigureInventory(); builder.ConfigurePurchases(); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261009040000_Phase42_WebPush.cs b/src/food-market.infrastructure/Persistence/Migrations/20261009040000_Phase42_WebPush.cs new file mode 100644 index 0000000..68ad2c1 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261009040000_Phase42_WebPush.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase42 — Web Push (VAPID): таблица подписок браузеров + /// (web_push_subscriptions) + колонки VAPID-ключей в platform_settings + /// (генерируются один раз при первом обращении). Идемпотентно. + [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""; + "); + } + } +} diff --git a/src/food-market.infrastructure/food-market.infrastructure.csproj b/src/food-market.infrastructure/food-market.infrastructure.csproj index 1cf23a0..9c487c0 100644 --- a/src/food-market.infrastructure/food-market.infrastructure.csproj +++ b/src/food-market.infrastructure/food-market.infrastructure.csproj @@ -19,6 +19,7 @@ + diff --git a/src/food-market.web/public/sw.js b/src/food-market.web/public/sw.js index 216c3d5..3efc30c 100644 --- a/src/food-market.web/public/sw.js +++ b/src/food-market.web/public/sw.js @@ -13,8 +13,9 @@ * и ?v= в ссылках (index.html, manifest, Logo.tsx). Иначе у тех, * кто уже открывал админку, останется старое лого из прекэша SW. * 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 STATIC_CACHE = `${CACHE_VERSION}-static`; 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) => { const req = event.request; if (req.method !== 'GET') return; // не вмешиваемся в мутации diff --git a/src/food-market.web/src/components/NotificationCenter.tsx b/src/food-market.web/src/components/NotificationCenter.tsx index 9c7d155..5bdf307 100644 --- a/src/food-market.web/src/components/NotificationCenter.tsx +++ b/src/food-market.web/src/components/NotificationCenter.tsx @@ -15,7 +15,8 @@ import { useState, useEffect, useRef } from 'react' import { Link, useNavigate } from 'react-router-dom' 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 { useNotifications, useMarkAllNotificationsRead, type NotificationRow } from '@/lib/useNotifications' import { useFormatCurrency } from '@/lib/useFormatCurrency' @@ -41,6 +42,15 @@ export function NotificationCenter() { const persisted = data?.items ?? [] const serverUnread = data?.unread ?? 0 + const [pushState, setPushState] = useState('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({ 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 })), @@ -112,9 +122,25 @@ export function NotificationCenter() {

Уведомления

- +
+ {pushState !== 'unsupported' && ( + + )} + +
{isEmpty ? ( diff --git a/src/food-market.web/src/lib/webPush.ts b/src/food-market.web/src/lib/webPush.ts new file mode 100644 index 0000000..b0bc9ce --- /dev/null +++ b/src/food-market.web/src/lib/webPush.ts @@ -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 { + 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 { + 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 { + 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' +} diff --git a/tests/food-market.IntegrationTests/NegativeStockTests.cs b/tests/food-market.IntegrationTests/NegativeStockTests.cs index dc060c4..4793e87 100644 --- a/tests/food-market.IntegrationTests/NegativeStockTests.cs +++ b/tests/food-market.IntegrationTests/NegativeStockTests.cs @@ -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.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); + } }