littleshop/LittleShop/Services/PushNotificationService.cs

371 lines
14 KiB
C#

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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> SendNotificationToAllUsersAsync(PushNotificationDto notification)
{
var subscriptions = await _context.PushSubscriptions
.Where(ps => ps.UserId != null && ps.IsActive)
.ToListAsync();
return await SendNotificationToSubscriptions(subscriptions, notification);
}
public async Task<bool> SendNotificationToAllCustomersAsync(PushNotificationDto notification)
{
var subscriptions = await _context.PushSubscriptions
.Where(ps => ps.CustomerId != null && ps.IsActive)
.ToListAsync();
return await SendNotificationToSubscriptions(subscriptions, notification);
}
public async Task<bool> 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<bool> 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<bool> SendNotificationToSubscriptions(List<Models.PushSubscription> subscriptions, PushNotificationDto notification)
{
if (!subscriptions.Any())
{
Log.Information("No active push subscriptions found for notification: {Title}", notification.Title);
return false;
}
int successCount = 0;
var failedSubscriptions = new List<Models.PushSubscription>();
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
});
Log.Information("Attempting to send push notification to {Count} subscriptions: {Title}",
subscriptions.Count, notification.Title);
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 (Total subscriptions: {Total})",
successCount, failedSubscriptions.Count, subscriptions.Count);
return successCount > 0;
}
public async Task<int> 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<List<Models.PushSubscription>> GetActiveSubscriptionsAsync()
{
return await _context.PushSubscriptions
.Where(ps => ps.IsActive)
.Include(ps => ps.User)
.Include(ps => ps.Customer)
.OrderByDescending(ps => ps.SubscribedAt)
.ToListAsync();
}
}