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
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:
parent
0fd04d4f5c
commit
560d52e8cd
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
81
src/food-market.api/Controllers/WebPushController.cs
Normal file
81
src/food-market.api/Controllers/WebPushController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: модуль «Деньги»
|
||||||
|
|
|
||||||
20
src/food-market.application/Notifications/IWebPushSender.cs
Normal file
20
src/food-market.application/Notifications/IWebPushSender.cs
Normal 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);
|
||||||
|
}
|
||||||
23
src/food-market.domain/Notifications/WebPushSubscription.cs
Normal file
23
src/food-market.domain/Notifications/WebPushSubscription.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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"";
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; // не вмешиваемся в мутации
|
||||||
|
|
|
||||||
|
|
@ -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,9 +122,25 @@ 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>
|
||||||
<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">
|
<div className="flex items-center gap-2">
|
||||||
<X className="w-4 h-4" />
|
{pushState !== 'unsupported' && (
|
||||||
</button>
|
<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">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
|
|
|
||||||
77
src/food-market.web/src/lib/webPush.ts
Normal file
77
src/food-market.web/src/lib/webPush.ts
Normal 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'
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue