355 lines
13 KiB
C#
355 lines
13 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 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;
|
|
}
|
|
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);
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
Log.Information("Push subscription created/updated 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())
|
|
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
|
|
});
|
|
|
|
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<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();
|
|
}
|
|
} |