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); } } }