using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Serilog; using WebPush; using System.Text.Json; using LittleShop.Data; using LittleShop.DTOs; using LittleShop.Models; namespace LittleShop.Services; public class PushNotificationService : IPushNotificationService { private readonly LittleShopContext _context; private readonly IConfiguration _configuration; private readonly WebPushClient _webPushClient; private readonly VapidDetails _vapidDetails; public PushNotificationService(LittleShopContext context, IConfiguration configuration) { _context = context; _configuration = configuration; // Initialize VAPID details _vapidDetails = new VapidDetails( subject: _configuration["WebPush:Subject"] ?? "mailto:admin@littleshop.local", publicKey: _configuration["WebPush:VapidPublicKey"] ?? throw new InvalidOperationException("WebPush:VapidPublicKey not configured"), privateKey: _configuration["WebPush:VapidPrivateKey"] ?? throw new InvalidOperationException("WebPush:VapidPrivateKey not configured") ); _webPushClient = new WebPushClient(); } public string GetVapidPublicKey() { return _vapidDetails.PublicKey; } public async Task SubscribeUserAsync(Guid userId, PushSubscriptionDto subscriptionDto, string? userAgent = null, string? ipAddress = null) { try { // Check if the user actually exists in the database var userExists = await _context.Users.AnyAsync(u => u.Id == userId); if (!userExists) { Log.Warning("Attempted to subscribe non-existent user {UserId} to push notifications", userId); return false; } // Check if subscription already exists var existingSubscription = await _context.PushSubscriptions .FirstOrDefaultAsync(ps => ps.Endpoint == subscriptionDto.Endpoint && ps.UserId == userId); if (existingSubscription != null) { // Update existing subscription existingSubscription.P256DH = subscriptionDto.P256DH; existingSubscription.Auth = subscriptionDto.Auth; existingSubscription.LastUsedAt = DateTime.UtcNow; existingSubscription.IsActive = true; existingSubscription.UserAgent = userAgent; existingSubscription.IpAddress = ipAddress; Log.Information("Updated existing push subscription for user {UserId}", userId); } else { // Create new subscription var subscription = new Models.PushSubscription { UserId = userId, Endpoint = subscriptionDto.Endpoint, P256DH = subscriptionDto.P256DH, Auth = subscriptionDto.Auth, SubscribedAt = DateTime.UtcNow, LastUsedAt = DateTime.UtcNow, IsActive = true, UserAgent = userAgent, IpAddress = ipAddress }; _context.PushSubscriptions.Add(subscription); Log.Information("Created new push subscription for user {UserId}", userId); } await _context.SaveChangesAsync(); Log.Information("Push subscription saved successfully for user {UserId}", userId); return true; } catch (Exception ex) { Log.Error(ex, "Failed to subscribe user {UserId} to push notifications", userId); return false; } } public async Task SubscribeCustomerAsync(Guid customerId, PushSubscriptionDto subscriptionDto, string? userAgent = null, string? ipAddress = null) { try { // Check if subscription already exists var existingSubscription = await _context.PushSubscriptions .FirstOrDefaultAsync(ps => ps.Endpoint == subscriptionDto.Endpoint && ps.CustomerId == customerId); if (existingSubscription != null) { // Update existing subscription existingSubscription.P256DH = subscriptionDto.P256DH; existingSubscription.Auth = subscriptionDto.Auth; existingSubscription.LastUsedAt = DateTime.UtcNow; existingSubscription.IsActive = true; existingSubscription.UserAgent = userAgent; existingSubscription.IpAddress = ipAddress; } else { // Create new subscription var subscription = new Models.PushSubscription { CustomerId = customerId, Endpoint = subscriptionDto.Endpoint, P256DH = subscriptionDto.P256DH, Auth = subscriptionDto.Auth, SubscribedAt = DateTime.UtcNow, LastUsedAt = DateTime.UtcNow, IsActive = true, UserAgent = userAgent, IpAddress = ipAddress }; _context.PushSubscriptions.Add(subscription); } await _context.SaveChangesAsync(); Log.Information("Push subscription created/updated for customer {CustomerId}", customerId); return true; } catch (Exception ex) { Log.Error(ex, "Failed to subscribe customer {CustomerId} to push notifications", customerId); return false; } } public async Task UnsubscribeAsync(string endpoint) { try { var subscription = await _context.PushSubscriptions .FirstOrDefaultAsync(ps => ps.Endpoint == endpoint); if (subscription != null) { subscription.IsActive = false; await _context.SaveChangesAsync(); Log.Information("Push subscription unsubscribed for endpoint {Endpoint}", endpoint); return true; } return false; } catch (Exception ex) { Log.Error(ex, "Failed to unsubscribe endpoint {Endpoint}", endpoint); return false; } } public async Task SendNotificationToUserAsync(Guid userId, PushNotificationDto notification) { var subscriptions = await _context.PushSubscriptions .Where(ps => ps.UserId == userId && ps.IsActive) .ToListAsync(); return await SendNotificationToSubscriptions(subscriptions, notification); } public async Task SendNotificationToCustomerAsync(Guid customerId, PushNotificationDto notification) { var subscriptions = await _context.PushSubscriptions .Where(ps => ps.CustomerId == customerId && ps.IsActive) .ToListAsync(); return await SendNotificationToSubscriptions(subscriptions, notification); } public async Task SendNotificationToAllUsersAsync(PushNotificationDto notification) { var subscriptions = await _context.PushSubscriptions .Where(ps => ps.UserId != null && ps.IsActive) .ToListAsync(); return await SendNotificationToSubscriptions(subscriptions, notification); } public async Task SendNotificationToAllCustomersAsync(PushNotificationDto notification) { var subscriptions = await _context.PushSubscriptions .Where(ps => ps.CustomerId != null && ps.IsActive) .ToListAsync(); return await SendNotificationToSubscriptions(subscriptions, notification); } public async Task SendOrderNotificationAsync(Guid orderId, string title, string body) { try { var order = await _context.Orders .Include(o => o.Customer) .FirstOrDefaultAsync(o => o.Id == orderId); if (order == null) return false; var notification = new PushNotificationDto { Title = title, Body = body, Icon = "/icons/icon-192x192.png", Badge = "/icons/icon-72x72.png", Url = $"/Admin/Orders/Details/{orderId}", Data = new { orderId = orderId, type = "order" } }; // Send to all admin users await SendNotificationToAllUsersAsync(notification); // Send to customer if they have push subscription if (order.CustomerId.HasValue) { var customerNotification = new PushNotificationDto { Title = title, Body = body, Icon = "/icons/icon-192x192.png", Badge = "/icons/icon-72x72.png", Data = new { orderId = orderId, type = "order" } }; await SendNotificationToCustomerAsync(order.CustomerId.Value, customerNotification); } return true; } catch (Exception ex) { Log.Error(ex, "Failed to send order notification for order {OrderId}", orderId); return false; } } public async Task SendTestNotificationAsync(string? userId = null, string title = "Test Notification", string body = "This is a test notification from LittleShop") { var notification = new PushNotificationDto { Title = title, Body = body, Icon = "/icons/icon-192x192.png", Badge = "/icons/icon-72x72.png", Data = new { type = "test", timestamp = DateTime.UtcNow } }; if (!string.IsNullOrEmpty(userId) && Guid.TryParse(userId, out Guid userIdGuid)) { return await SendNotificationToUserAsync(userIdGuid, notification); } else { // Send to all admin users return await SendNotificationToAllUsersAsync(notification); } } private async Task SendNotificationToSubscriptions(List subscriptions, PushNotificationDto notification) { if (!subscriptions.Any()) return false; int successCount = 0; var failedSubscriptions = new List(); var payload = JsonSerializer.Serialize(new { title = notification.Title, body = notification.Body, icon = notification.Icon ?? "/icons/icon-192x192.png", badge = notification.Badge ?? "/icons/icon-72x72.png", url = notification.Url, data = notification.Data }); foreach (var subscription in subscriptions) { try { var pushSubscription = new WebPush.PushSubscription( endpoint: subscription.Endpoint, p256dh: subscription.P256DH, auth: subscription.Auth ); await _webPushClient.SendNotificationAsync(pushSubscription, payload, _vapidDetails); // Update last used time subscription.LastUsedAt = DateTime.UtcNow; successCount++; Log.Information("Push notification sent successfully to endpoint {Endpoint}", subscription.Endpoint); } catch (WebPushException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Gone) { // Subscription is no longer valid, mark as inactive subscription.IsActive = false; failedSubscriptions.Add(subscription); Log.Warning("Push subscription expired for endpoint {Endpoint}", subscription.Endpoint); } catch (Exception ex) { failedSubscriptions.Add(subscription); Log.Error(ex, "Failed to send push notification to endpoint {Endpoint}", subscription.Endpoint); } } // Save changes to update last used times and inactive subscriptions await _context.SaveChangesAsync(); Log.Information("Push notifications sent: {SuccessCount} successful, {FailedCount} failed", successCount, failedSubscriptions.Count); return successCount > 0; } public async Task CleanupExpiredSubscriptionsAsync() { try { var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); var expiredSubscriptions = await _context.PushSubscriptions .Where(ps => ps.LastUsedAt < thirtyDaysAgo || !ps.IsActive) .ToListAsync(); _context.PushSubscriptions.RemoveRange(expiredSubscriptions); await _context.SaveChangesAsync(); Log.Information("Cleaned up {Count} expired push subscriptions", expiredSubscriptions.Count); return expiredSubscriptions.Count; } catch (Exception ex) { Log.Error(ex, "Failed to cleanup expired push subscriptions"); return 0; } } public async Task> GetActiveSubscriptionsAsync() { return await _context.PushSubscriptions .Where(ps => ps.IsActive) .Include(ps => ps.User) .Include(ps => ps.Customer) .OrderByDescending(ps => ps.SubscribedAt) .ToListAsync(); } }