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