WebPush-and-photo-upload-fixes
This commit is contained in:
parent
5eb7647faf
commit
c8a55c143b
353
LittleShop.Tests/Unit/PushNotificationControllerTests.cs
Normal file
353
LittleShop.Tests/Unit/PushNotificationControllerTests.cs
Normal file
@ -0,0 +1,353 @@
|
||||
using Xunit;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Moq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using LittleShop.Controllers;
|
||||
using LittleShop.Services;
|
||||
using LittleShop.DTOs;
|
||||
|
||||
namespace LittleShop.Tests.Unit;
|
||||
|
||||
public class PushNotificationControllerTests
|
||||
{
|
||||
private readonly Mock<IPushNotificationService> _pushServiceMock;
|
||||
private readonly PushNotificationController _controller;
|
||||
private readonly Guid _testUserId = Guid.NewGuid();
|
||||
|
||||
public PushNotificationControllerTests()
|
||||
{
|
||||
_pushServiceMock = new Mock<IPushNotificationService>();
|
||||
_controller = new PushNotificationController(_pushServiceMock.Object);
|
||||
|
||||
// Setup controller context for authenticated user
|
||||
SetupControllerContext();
|
||||
}
|
||||
|
||||
private void SetupControllerContext()
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, _testUserId.ToString()),
|
||||
new Claim(ClaimTypes.Role, "Admin")
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "TestAuth");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
_controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal,
|
||||
Connection = { RemoteIpAddress = System.Net.IPAddress.Parse("192.168.1.1") }
|
||||
}
|
||||
};
|
||||
|
||||
// Setup User-Agent header
|
||||
_controller.HttpContext.Request.Headers.Add("User-Agent", "TestBrowser/1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVapidPublicKey_ReturnsPublicKey()
|
||||
{
|
||||
// Arrange
|
||||
var expectedKey = "test-public-key";
|
||||
_pushServiceMock.Setup(s => s.GetVapidPublicKey()).Returns(expectedKey);
|
||||
|
||||
// Act
|
||||
var result = _controller.GetVapidPublicKey();
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||
dynamic value = okResult.Value;
|
||||
Assert.Equal(expectedKey, value.publicKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVapidPublicKey_ReturnsInternalServerError_OnException()
|
||||
{
|
||||
// Arrange
|
||||
_pushServiceMock.Setup(s => s.GetVapidPublicKey()).Throws(new Exception("Test exception"));
|
||||
|
||||
// Act
|
||||
var result = _controller.GetVapidPublicKey();
|
||||
|
||||
// Assert
|
||||
var statusResult = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(500, statusResult.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_ReturnsOk_WhenSubscriptionSuccessful()
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionDto = new PushSubscriptionDto
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "test-p256dh",
|
||||
Auth = "test-auth"
|
||||
};
|
||||
|
||||
_pushServiceMock
|
||||
.Setup(s => s.SubscribeUserAsync(_testUserId, subscriptionDto, "TestBrowser/1.0", "192.168.1.1"))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var result = await _controller.Subscribe(subscriptionDto);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||
dynamic value = okResult.Value;
|
||||
Assert.Equal("Successfully subscribed to push notifications", value.message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_ReturnsInternalServerError_WhenSubscriptionFails()
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionDto = new PushSubscriptionDto
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "test-p256dh",
|
||||
Auth = "test-auth"
|
||||
};
|
||||
|
||||
_pushServiceMock
|
||||
.Setup(s => s.SubscribeUserAsync(It.IsAny<Guid>(), It.IsAny<PushSubscriptionDto>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _controller.Subscribe(subscriptionDto);
|
||||
|
||||
// Assert
|
||||
var statusResult = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(500, statusResult.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_ReturnsUnauthorized_WhenUserIdInvalid()
|
||||
{
|
||||
// Arrange
|
||||
// Setup controller with invalid user ID
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "invalid-guid"),
|
||||
new Claim(ClaimTypes.Role, "Admin")
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "TestAuth");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
_controller.ControllerContext.HttpContext.User = principal;
|
||||
|
||||
var subscriptionDto = new PushSubscriptionDto
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "test-p256dh",
|
||||
Auth = "test-auth"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.Subscribe(subscriptionDto);
|
||||
|
||||
// Assert
|
||||
var unauthorizedResult = Assert.IsType<UnauthorizedObjectResult>(result);
|
||||
dynamic value = unauthorizedResult.Value;
|
||||
Assert.Equal("Invalid user ID", value.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeCustomer_ReturnsOk_WhenSuccessful()
|
||||
{
|
||||
// Arrange
|
||||
var customerId = Guid.NewGuid();
|
||||
var subscriptionDto = new PushSubscriptionDto
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/customer",
|
||||
P256DH = "customer-p256dh",
|
||||
Auth = "customer-auth"
|
||||
};
|
||||
|
||||
_pushServiceMock
|
||||
.Setup(s => s.SubscribeCustomerAsync(customerId, subscriptionDto, It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var result = await _controller.SubscribeCustomer(subscriptionDto, customerId);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||
dynamic value = okResult.Value;
|
||||
Assert.Equal("Successfully subscribed to push notifications", value.message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeCustomer_ReturnsBadRequest_WhenCustomerIdEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionDto = new PushSubscriptionDto
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/customer",
|
||||
P256DH = "customer-p256dh",
|
||||
Auth = "customer-auth"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.SubscribeCustomer(subscriptionDto, Guid.Empty);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
|
||||
dynamic value = badRequestResult.Value;
|
||||
Assert.Equal("Invalid customer ID", value.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_ReturnsOk_WhenSuccessful()
|
||||
{
|
||||
// Arrange
|
||||
var unsubscribeDto = new UnsubscribeDto { Endpoint = "https://fcm.googleapis.com/fcm/send/test" };
|
||||
_pushServiceMock.Setup(s => s.UnsubscribeAsync(unsubscribeDto.Endpoint)).ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var result = await _controller.Unsubscribe(unsubscribeDto);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||
dynamic value = okResult.Value;
|
||||
Assert.Equal("Successfully unsubscribed from push notifications", value.message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_ReturnsNotFound_WhenSubscriptionNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var unsubscribeDto = new UnsubscribeDto { Endpoint = "https://fcm.googleapis.com/fcm/send/nonexistent" };
|
||||
_pushServiceMock.Setup(s => s.UnsubscribeAsync(unsubscribeDto.Endpoint)).ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _controller.Unsubscribe(unsubscribeDto);
|
||||
|
||||
// Assert
|
||||
var notFoundResult = Assert.IsType<NotFoundObjectResult>(result);
|
||||
dynamic value = notFoundResult.Value;
|
||||
Assert.Equal("Subscription not found", value.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotification_ReturnsOk_WhenSuccessful()
|
||||
{
|
||||
// Arrange
|
||||
var testDto = new TestPushNotificationDto
|
||||
{
|
||||
Title = "Test Title",
|
||||
Body = "Test Body"
|
||||
};
|
||||
|
||||
_pushServiceMock
|
||||
.Setup(s => s.SendTestNotificationAsync(_testUserId.ToString(), testDto.Title, testDto.Body))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var result = await _controller.SendTestNotification(testDto);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||
dynamic value = okResult.Value;
|
||||
Assert.Equal("Test notification sent successfully", value.message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotification_ReturnsInternalServerError_WhenFails()
|
||||
{
|
||||
// Arrange
|
||||
var testDto = new TestPushNotificationDto
|
||||
{
|
||||
Title = "Test Title",
|
||||
Body = "Test Body"
|
||||
};
|
||||
|
||||
_pushServiceMock
|
||||
.Setup(s => s.SendTestNotificationAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _controller.SendTestNotification(testDto);
|
||||
|
||||
// Assert
|
||||
var statusResult = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(500, statusResult.StatusCode);
|
||||
dynamic value = statusResult.Value;
|
||||
Assert.Contains("Failed to send test notification", (string)value.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendBroadcastNotification_ReturnsOk_WhenSuccessful()
|
||||
{
|
||||
// Arrange
|
||||
var notificationDto = new PushNotificationDto
|
||||
{
|
||||
Title = "Broadcast Title",
|
||||
Body = "Broadcast Body"
|
||||
};
|
||||
|
||||
_pushServiceMock
|
||||
.Setup(s => s.SendNotificationToAllUsersAsync(notificationDto))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var result = await _controller.SendBroadcastNotification(notificationDto);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||
dynamic value = okResult.Value;
|
||||
Assert.Equal("Broadcast notification sent successfully", value.message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveSubscriptions_ReturnsOk_WithSubscriptions()
|
||||
{
|
||||
// Arrange
|
||||
var subscriptions = new List<LittleShop.Models.PushSubscription>
|
||||
{
|
||||
new LittleShop.Models.PushSubscription
|
||||
{
|
||||
Id = 1,
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test-long-endpoint-that-should-be-truncated",
|
||||
SubscribedAt = DateTime.UtcNow,
|
||||
LastUsedAt = DateTime.UtcNow,
|
||||
UserAgent = "TestBrowser/1.0",
|
||||
User = new LittleShop.Models.User { Username = "testuser" }
|
||||
}
|
||||
};
|
||||
|
||||
_pushServiceMock.Setup(s => s.GetActiveSubscriptionsAsync()).ReturnsAsync(subscriptions);
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetActiveSubscriptions();
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||
var subscriptionList = okResult.Value as IEnumerable<dynamic>;
|
||||
Assert.NotNull(subscriptionList);
|
||||
Assert.Single(subscriptionList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupExpiredSubscriptions_ReturnsOk_WithCleanupCount()
|
||||
{
|
||||
// Arrange
|
||||
_pushServiceMock.Setup(s => s.CleanupExpiredSubscriptionsAsync()).ReturnsAsync(5);
|
||||
|
||||
// Act
|
||||
var result = await _controller.CleanupExpiredSubscriptions();
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||
dynamic value = okResult.Value;
|
||||
Assert.Equal("Cleaned up 5 expired subscriptions", value.message);
|
||||
}
|
||||
}
|
||||
358
LittleShop.Tests/Unit/PushNotificationServiceTests.cs
Normal file
358
LittleShop.Tests/Unit/PushNotificationServiceTests.cs
Normal file
@ -0,0 +1,358 @@
|
||||
using Xunit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using LittleShop.Services;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.Models;
|
||||
using LittleShop.DTOs;
|
||||
using WebPush;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LittleShop.Tests.Unit;
|
||||
|
||||
public class PushNotificationServiceTests : IDisposable
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly Mock<IConfiguration> _configurationMock;
|
||||
private readonly PushNotificationService _service;
|
||||
private readonly Guid _testUserId = Guid.NewGuid();
|
||||
private readonly Guid _testCustomerId = Guid.NewGuid();
|
||||
|
||||
public PushNotificationServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LittleShopContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
_context = new LittleShopContext(options);
|
||||
|
||||
// Setup configuration mock with VAPID keys
|
||||
_configurationMock = new Mock<IConfiguration>();
|
||||
_configurationMock.Setup(c => c["WebPush:VapidPublicKey"])
|
||||
.Returns("BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4");
|
||||
_configurationMock.Setup(c => c["WebPush:VapidPrivateKey"])
|
||||
.Returns("dYuuagbz2CzCnPDFUpO_qkGLBgnN3MEFZQnjXNkc1MY");
|
||||
_configurationMock.Setup(c => c["WebPush:Subject"])
|
||||
.Returns("mailto:admin@littleshop.local");
|
||||
|
||||
_service = new PushNotificationService(_context, _configurationMock.Object);
|
||||
|
||||
// Seed test data
|
||||
SeedTestData();
|
||||
}
|
||||
|
||||
private void SeedTestData()
|
||||
{
|
||||
var testUser = new User
|
||||
{
|
||||
Id = _testUserId,
|
||||
Username = "testuser",
|
||||
PasswordHash = "hash",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var testCustomer = new Customer
|
||||
{
|
||||
Id = _testCustomerId,
|
||||
TelegramUserId = 123456,
|
||||
TelegramUsername = "testcustomer",
|
||||
TelegramDisplayName = "Test Customer",
|
||||
TelegramFirstName = "Test",
|
||||
TelegramLastName = "Customer",
|
||||
Language = "en",
|
||||
Timezone = "UTC",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
LastActiveAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
_context.Users.Add(testUser);
|
||||
_context.Customers.Add(testCustomer);
|
||||
_context.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVapidPublicKey_ReturnsCorrectKey()
|
||||
{
|
||||
// Act
|
||||
var result = _service.GetVapidPublicKey();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeUserAsync_CreatesNewSubscription_WhenNotExists()
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionDto = new PushSubscriptionDto
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "test-p256dh",
|
||||
Auth = "test-auth"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.SubscribeUserAsync(_testUserId, subscriptionDto, "test-agent", "192.168.1.1");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
var subscription = await _context.PushSubscriptions.FirstOrDefaultAsync();
|
||||
Assert.NotNull(subscription);
|
||||
Assert.Equal(_testUserId, subscription.UserId);
|
||||
Assert.Equal(subscriptionDto.Endpoint, subscription.Endpoint);
|
||||
Assert.Equal(subscriptionDto.P256DH, subscription.P256DH);
|
||||
Assert.Equal(subscriptionDto.Auth, subscription.Auth);
|
||||
Assert.Equal("test-agent", subscription.UserAgent);
|
||||
Assert.Equal("192.168.1.1", subscription.IpAddress);
|
||||
Assert.True(subscription.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeUserAsync_UpdatesExistingSubscription_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var existingSubscription = new LittleShop.Models.PushSubscription
|
||||
{
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "old-p256dh",
|
||||
Auth = "old-auth",
|
||||
SubscribedAt = DateTime.UtcNow.AddDays(-1),
|
||||
IsActive = false
|
||||
};
|
||||
_context.PushSubscriptions.Add(existingSubscription);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var subscriptionDto = new PushSubscriptionDto
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "new-p256dh",
|
||||
Auth = "new-auth"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.SubscribeUserAsync(_testUserId, subscriptionDto, "new-agent", "192.168.1.2");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
var updatedSubscription = await _context.PushSubscriptions.FirstOrDefaultAsync();
|
||||
Assert.NotNull(updatedSubscription);
|
||||
Assert.Equal("new-p256dh", updatedSubscription.P256DH);
|
||||
Assert.Equal("new-auth", updatedSubscription.Auth);
|
||||
Assert.Equal("new-agent", updatedSubscription.UserAgent);
|
||||
Assert.Equal("192.168.1.2", updatedSubscription.IpAddress);
|
||||
Assert.True(updatedSubscription.IsActive);
|
||||
Assert.NotNull(updatedSubscription.LastUsedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeCustomerAsync_CreatesNewSubscription()
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionDto = new PushSubscriptionDto
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/customer-test",
|
||||
P256DH = "customer-p256dh",
|
||||
Auth = "customer-auth"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.SubscribeCustomerAsync(_testCustomerId, subscriptionDto);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
var subscription = await _context.PushSubscriptions.FirstOrDefaultAsync();
|
||||
Assert.NotNull(subscription);
|
||||
Assert.Equal(_testCustomerId, subscription.CustomerId);
|
||||
Assert.Equal(subscriptionDto.Endpoint, subscription.Endpoint);
|
||||
Assert.True(subscription.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_DeactivatesSubscription()
|
||||
{
|
||||
// Arrange
|
||||
var subscription = new LittleShop.Models.PushSubscription
|
||||
{
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "test-p256dh",
|
||||
Auth = "test-auth",
|
||||
SubscribedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
_context.PushSubscriptions.Add(subscription);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.UnsubscribeAsync(subscription.Endpoint);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
var updatedSubscription = await _context.PushSubscriptions.FirstOrDefaultAsync();
|
||||
Assert.NotNull(updatedSubscription);
|
||||
Assert.False(updatedSubscription.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_ReturnsFalse_WhenSubscriptionNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.UnsubscribeAsync("non-existent-endpoint");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveSubscriptionsAsync_ReturnsOnlyActiveSubscriptions()
|
||||
{
|
||||
// Arrange
|
||||
var activeSubscription = new LittleShop.Models.PushSubscription
|
||||
{
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/active",
|
||||
P256DH = "active-p256dh",
|
||||
Auth = "active-auth",
|
||||
SubscribedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var inactiveSubscription = new LittleShop.Models.PushSubscription
|
||||
{
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/inactive",
|
||||
P256DH = "inactive-p256dh",
|
||||
Auth = "inactive-auth",
|
||||
SubscribedAt = DateTime.UtcNow,
|
||||
IsActive = false
|
||||
};
|
||||
|
||||
_context.PushSubscriptions.AddRange(activeSubscription, inactiveSubscription);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _service.GetActiveSubscriptionsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(activeSubscription.Endpoint, result.First().Endpoint);
|
||||
Assert.True(result.First().IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupExpiredSubscriptionsAsync_RemovesOldSubscriptions()
|
||||
{
|
||||
// Arrange
|
||||
var expiredSubscription = new LittleShop.Models.PushSubscription
|
||||
{
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/expired",
|
||||
P256DH = "expired-p256dh",
|
||||
Auth = "expired-auth",
|
||||
SubscribedAt = DateTime.UtcNow.AddDays(-35),
|
||||
LastUsedAt = DateTime.UtcNow.AddDays(-35),
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var recentSubscription = new LittleShop.Models.PushSubscription
|
||||
{
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/recent",
|
||||
P256DH = "recent-p256dh",
|
||||
Auth = "recent-auth",
|
||||
SubscribedAt = DateTime.UtcNow,
|
||||
LastUsedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var inactiveSubscription = new LittleShop.Models.PushSubscription
|
||||
{
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/inactive",
|
||||
P256DH = "inactive-p256dh",
|
||||
Auth = "inactive-auth",
|
||||
SubscribedAt = DateTime.UtcNow,
|
||||
LastUsedAt = DateTime.UtcNow,
|
||||
IsActive = false
|
||||
};
|
||||
|
||||
_context.PushSubscriptions.AddRange(expiredSubscription, recentSubscription, inactiveSubscription);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var cleanedCount = await _service.CleanupExpiredSubscriptionsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, cleanedCount); // expired and inactive should be removed
|
||||
var remainingSubscriptions = await _context.PushSubscriptions.ToListAsync();
|
||||
Assert.Single(remainingSubscriptions);
|
||||
Assert.Equal(recentSubscription.Endpoint, remainingSubscriptions.First().Endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_WithValidUserId_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var subscription = new LittleShop.Models.PushSubscription
|
||||
{
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "test-p256dh",
|
||||
Auth = "test-auth",
|
||||
SubscribedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
_context.PushSubscriptions.Add(subscription);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Note: This test will only verify the logic flow, not actual push sending
|
||||
// since that requires real push service endpoints
|
||||
|
||||
// Act & Assert
|
||||
// For now, we'll test that the method handles the case where no valid subscriptions exist
|
||||
var result = await _service.SendTestNotificationAsync(Guid.NewGuid().ToString());
|
||||
|
||||
// The method should complete without throwing exceptions
|
||||
Assert.False(result); // No subscriptions for non-existent user
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestNotificationAsync_WithoutUserId_SendsToAllAdmins()
|
||||
{
|
||||
// Arrange
|
||||
var subscription = new LittleShop.Models.PushSubscription
|
||||
{
|
||||
UserId = _testUserId,
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "test-p256dh",
|
||||
Auth = "test-auth",
|
||||
SubscribedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
_context.PushSubscriptions.Add(subscription);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Act & Assert
|
||||
// This will attempt to send to all admin users
|
||||
// The method should complete without throwing exceptions even if push fails
|
||||
var result = await _service.SendTestNotificationAsync();
|
||||
|
||||
// We expect false because we can't actually send push notifications in tests
|
||||
// but the method should handle the flow correctly
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
}
|
||||
}
|
||||
@ -59,6 +59,7 @@ public class ProductsController : Controller
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
ViewData["Categories"] = categories.Where(c => c.IsActive);
|
||||
ViewData["ProductId"] = id;
|
||||
ViewData["ProductPhotos"] = product.Photos;
|
||||
|
||||
var model = new UpdateProductDto
|
||||
{
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
ViewData["Title"] = "Edit Product";
|
||||
var categories = ViewData["Categories"] as IEnumerable<LittleShop.DTOs.CategoryDto>;
|
||||
var productId = ViewData["ProductId"];
|
||||
var productPhotos = ViewData["ProductPhotos"] as IEnumerable<LittleShop.DTOs.ProductPhotoDto>;
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
@ -127,10 +128,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary" id="upload-photo-btn">
|
||||
<i class="fas fa-upload"></i> Upload Photo
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (productPhotos != null && productPhotos.Any())
|
||||
{
|
||||
<hr>
|
||||
<h6><i class="fas fa-images"></i> Current Photos</h6>
|
||||
<div class="row">
|
||||
@foreach (var photo in productPhotos)
|
||||
{
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card">
|
||||
<img src="@photo.FilePath" class="card-img-top" style="height: 150px; object-fit: cover;" alt="@photo.AltText">
|
||||
<div class="card-body p-2">
|
||||
<small class="text-muted">@photo.FileName</small>
|
||||
<form method="post" action="@Url.Action("DeletePhoto", new { id = productId, photoId = photo.Id })" class="mt-1">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm w-100"
|
||||
onclick="return confirm('Delete this photo?')">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted text-center py-3">
|
||||
<i class="fas fa-camera fa-2x mb-2"></i>
|
||||
<p>No photos uploaded yet. Upload your first photo above.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -152,4 +186,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Photo upload enhancement
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fileInput = document.getElementById('file');
|
||||
const uploadBtn = document.getElementById('upload-photo-btn');
|
||||
const uploadForm = uploadBtn.closest('form');
|
||||
|
||||
// Preview selected file
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
console.log('Photo selected:', file.name, file.size, 'bytes');
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file (JPG, PNG, GIF, etc.)');
|
||||
fileInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('File too large. Please select an image smaller than 5MB.');
|
||||
fileInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
uploadBtn.classList.remove('btn-primary');
|
||||
uploadBtn.classList.add('btn-success');
|
||||
uploadBtn.innerHTML = '<i class="fas fa-check"></i> Ready to Upload';
|
||||
} else {
|
||||
uploadBtn.classList.remove('btn-success');
|
||||
uploadBtn.classList.add('btn-primary');
|
||||
uploadBtn.innerHTML = '<i class="fas fa-upload"></i> Upload Photo';
|
||||
}
|
||||
});
|
||||
|
||||
// Upload progress feedback
|
||||
uploadForm.addEventListener('submit', function(e) {
|
||||
if (!fileInput.files[0]) {
|
||||
e.preventDefault();
|
||||
alert('Please select a photo first');
|
||||
return;
|
||||
}
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Uploading...';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
238
LittleShop/Controllers/PushNotificationController.cs
Normal file
238
LittleShop/Controllers/PushNotificationController.cs
Normal file
@ -0,0 +1,238 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using System.Security.Claims;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Services;
|
||||
|
||||
namespace LittleShop.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/push")]
|
||||
public class PushNotificationController : ControllerBase
|
||||
{
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
|
||||
public PushNotificationController(IPushNotificationService pushNotificationService)
|
||||
{
|
||||
_pushNotificationService = pushNotificationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the VAPID public key for client-side push subscription
|
||||
/// </summary>
|
||||
[HttpGet("vapid-key")]
|
||||
public IActionResult GetVapidPublicKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
var publicKey = _pushNotificationService.GetVapidPublicKey();
|
||||
return Ok(new { publicKey });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe admin user to push notifications
|
||||
/// </summary>
|
||||
[HttpPost("subscribe")]
|
||||
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
|
||||
public async Task<IActionResult> Subscribe([FromBody] PushSubscriptionDto subscriptionDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out Guid userId))
|
||||
{
|
||||
return Unauthorized("Invalid user ID");
|
||||
}
|
||||
|
||||
var userAgent = Request.Headers.UserAgent.ToString();
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var success = await _pushNotificationService.SubscribeUserAsync(userId, subscriptionDto, userAgent, ipAddress);
|
||||
|
||||
if (success)
|
||||
{
|
||||
return Ok(new { message = "Successfully subscribed to push notifications" });
|
||||
}
|
||||
else
|
||||
{
|
||||
return StatusCode(500, new { error = "Failed to subscribe to push notifications" });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe customer to push notifications (for customer-facing apps)
|
||||
/// </summary>
|
||||
[HttpPost("subscribe/customer")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> SubscribeCustomer([FromBody] PushSubscriptionDto subscriptionDto, [FromQuery] Guid customerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (customerId == Guid.Empty)
|
||||
{
|
||||
return BadRequest("Invalid customer ID");
|
||||
}
|
||||
|
||||
var userAgent = Request.Headers.UserAgent.ToString();
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var success = await _pushNotificationService.SubscribeCustomerAsync(customerId, subscriptionDto, userAgent, ipAddress);
|
||||
|
||||
if (success)
|
||||
{
|
||||
return Ok(new { message = "Successfully subscribed to push notifications" });
|
||||
}
|
||||
else
|
||||
{
|
||||
return StatusCode(500, new { error = "Failed to subscribe to push notifications" });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribe from push notifications
|
||||
/// </summary>
|
||||
[HttpPost("unsubscribe")]
|
||||
public async Task<IActionResult> Unsubscribe([FromBody] UnsubscribeDto unsubscribeDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await _pushNotificationService.UnsubscribeAsync(unsubscribeDto.Endpoint);
|
||||
|
||||
if (success)
|
||||
{
|
||||
return Ok(new { message = "Successfully unsubscribed from push notifications" });
|
||||
}
|
||||
else
|
||||
{
|
||||
return NotFound(new { error = "Subscription not found" });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send test notification to current admin user
|
||||
/// </summary>
|
||||
[HttpPost("test")]
|
||||
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
|
||||
public async Task<IActionResult> SendTestNotification([FromBody] TestPushNotificationDto testDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var success = await _pushNotificationService.SendTestNotificationAsync(userIdClaim, testDto.Title, testDto.Body);
|
||||
|
||||
if (success)
|
||||
{
|
||||
return Ok(new { message = "Test notification sent successfully" });
|
||||
}
|
||||
else
|
||||
{
|
||||
return StatusCode(500, new { error = "Failed to send test notification. Make sure you are subscribed to push notifications." });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send broadcast notification to all admin users
|
||||
/// </summary>
|
||||
[HttpPost("broadcast")]
|
||||
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
|
||||
public async Task<IActionResult> SendBroadcastNotification([FromBody] PushNotificationDto notificationDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await _pushNotificationService.SendNotificationToAllUsersAsync(notificationDto);
|
||||
|
||||
if (success)
|
||||
{
|
||||
return Ok(new { message = "Broadcast notification sent successfully" });
|
||||
}
|
||||
else
|
||||
{
|
||||
return StatusCode(500, new { error = "Failed to send broadcast notification" });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get active push subscriptions (admin only)
|
||||
/// </summary>
|
||||
[HttpGet("subscriptions")]
|
||||
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
|
||||
public async Task<IActionResult> GetActiveSubscriptions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var subscriptions = await _pushNotificationService.GetActiveSubscriptionsAsync();
|
||||
|
||||
var result = subscriptions.Select(s => new
|
||||
{
|
||||
id = s.Id,
|
||||
userId = s.UserId,
|
||||
customerId = s.CustomerId,
|
||||
userName = s.User?.Username,
|
||||
customerName = s.Customer?.TelegramDisplayName ?? s.Customer?.TelegramUsername,
|
||||
subscribedAt = s.SubscribedAt,
|
||||
lastUsedAt = s.LastUsedAt,
|
||||
userAgent = s.UserAgent,
|
||||
endpoint = s.Endpoint[..Math.Min(50, s.Endpoint.Length)] + "..." // Truncate for security
|
||||
}).ToList();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup expired push subscriptions
|
||||
/// </summary>
|
||||
[HttpPost("cleanup")]
|
||||
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
|
||||
public async Task<IActionResult> CleanupExpiredSubscriptions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cleanedCount = await _pushNotificationService.CleanupExpiredSubscriptionsAsync();
|
||||
return Ok(new { message = $"Cleaned up {cleanedCount} expired subscriptions" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class UnsubscribeDto
|
||||
{
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
}
|
||||
40
LittleShop/DTOs/PushSubscriptionDto.cs
Normal file
40
LittleShop/DTOs/PushSubscriptionDto.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LittleShop.DTOs;
|
||||
|
||||
public class PushSubscriptionDto
|
||||
{
|
||||
[Required]
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string P256DH { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Auth { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PushNotificationDto
|
||||
{
|
||||
[Required]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
public string? Icon { get; set; }
|
||||
public string? Badge { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public object? Data { get; set; }
|
||||
}
|
||||
|
||||
public class TestPushNotificationDto
|
||||
{
|
||||
[Required]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
public string? UserId { get; set; }
|
||||
}
|
||||
@ -22,6 +22,7 @@ public class LittleShopContext : DbContext
|
||||
public DbSet<BotSession> BotSessions { get; set; }
|
||||
public DbSet<Customer> Customers { get; set; }
|
||||
public DbSet<CustomerMessage> CustomerMessages { get; set; }
|
||||
public DbSet<PushSubscription> PushSubscriptions { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@ -179,5 +180,25 @@ public class LittleShopContext : DbContext
|
||||
entity.HasIndex(e => e.ThreadId);
|
||||
entity.HasIndex(e => e.ParentMessageId);
|
||||
});
|
||||
|
||||
// PushSubscription entity
|
||||
modelBuilder.Entity<PushSubscription>(entity =>
|
||||
{
|
||||
entity.HasOne(ps => ps.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(ps => ps.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(ps => ps.Customer)
|
||||
.WithMany()
|
||||
.HasForeignKey(ps => ps.CustomerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(e => e.Endpoint).IsUnique();
|
||||
entity.HasIndex(e => e.UserId);
|
||||
entity.HasIndex(e => e.CustomerId);
|
||||
entity.HasIndex(e => e.SubscribedAt);
|
||||
entity.HasIndex(e => e.IsActive);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,7 @@
|
||||
<PackageReference Include="BTCPayServer.Client" Version="2.0.0" />
|
||||
<PackageReference Include="NBitcoin" Version="7.0.37" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="WebPush" Version="1.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
31
LittleShop/Models/PushSubscription.cs
Normal file
31
LittleShop/Models/PushSubscription.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LittleShop.Models;
|
||||
|
||||
public class PushSubscription
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string P256DH { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Auth { get; set; } = string.Empty;
|
||||
|
||||
public Guid? UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
|
||||
public Guid? CustomerId { get; set; }
|
||||
public Customer? Customer { get; set; }
|
||||
|
||||
public DateTime SubscribedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
// Browser/device information for identification
|
||||
public string? UserAgent { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
}
|
||||
@ -72,6 +72,7 @@ builder.Services.AddScoped<IBotService, BotService>();
|
||||
builder.Services.AddScoped<IBotMetricsService, BotMetricsService>();
|
||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
builder.Services.AddScoped<ICustomerMessageService, CustomerMessageService>();
|
||||
builder.Services.AddScoped<IPushNotificationService, PushNotificationService>();
|
||||
builder.Services.AddSingleton<ITelegramBotManagerService, TelegramBotManagerService>();
|
||||
// Temporarily disabled to use standalone TeleBot with customer orders fix
|
||||
// builder.Services.AddHostedService<TelegramBotManagerService>();
|
||||
|
||||
24
LittleShop/Services/IPushNotificationService.cs
Normal file
24
LittleShop/Services/IPushNotificationService.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Models;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
public interface IPushNotificationService
|
||||
{
|
||||
Task<bool> SubscribeUserAsync(Guid userId, PushSubscriptionDto subscriptionDto, string? userAgent = null, string? ipAddress = null);
|
||||
Task<bool> SubscribeCustomerAsync(Guid customerId, PushSubscriptionDto subscriptionDto, string? userAgent = null, string? ipAddress = null);
|
||||
Task<bool> UnsubscribeAsync(string endpoint);
|
||||
|
||||
Task<bool> SendNotificationToUserAsync(Guid userId, PushNotificationDto notification);
|
||||
Task<bool> SendNotificationToCustomerAsync(Guid customerId, PushNotificationDto notification);
|
||||
Task<bool> SendNotificationToAllUsersAsync(PushNotificationDto notification);
|
||||
Task<bool> SendNotificationToAllCustomersAsync(PushNotificationDto notification);
|
||||
|
||||
Task<bool> SendOrderNotificationAsync(Guid orderId, string title, string body);
|
||||
Task<bool> SendTestNotificationAsync(string? userId = null, string title = "Test Notification", string body = "This is a test notification from LittleShop");
|
||||
|
||||
Task<int> CleanupExpiredSubscriptionsAsync();
|
||||
Task<List<Models.PushSubscription>> GetActiveSubscriptionsAsync();
|
||||
|
||||
string GetVapidPublicKey();
|
||||
}
|
||||
355
LittleShop/Services/PushNotificationService.cs
Normal file
355
LittleShop/Services/PushNotificationService.cs
Normal file
@ -0,0 +1,355 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,11 @@
|
||||
"ApiKey": "your-royal-mail-api-key",
|
||||
"BaseUrl": "https://api.royalmail.com"
|
||||
},
|
||||
"WebPush": {
|
||||
"VapidPublicKey": "BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4",
|
||||
"VapidPrivateKey": "dYuuagbz2CzCnPDFUpO_qkGLBgnN3MEFZQnjXNkc1MY",
|
||||
"Subject": "mailto:admin@littleshop.local"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@ -67,9 +67,16 @@ class ModernMobile {
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
// Skip validation for file upload forms
|
||||
if (form.enctype === 'multipart/form-data') {
|
||||
console.log('Mobile: Skipping validation for file upload form');
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidInputs = form.querySelectorAll(':invalid');
|
||||
if (invalidInputs.length > 0) {
|
||||
e.preventDefault();
|
||||
console.log('Mobile: Form validation failed, focusing on first invalid input');
|
||||
invalidInputs[0].focus();
|
||||
invalidInputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
class PWAManager {
|
||||
constructor() {
|
||||
this.swRegistration = null;
|
||||
this.vapidPublicKey = null;
|
||||
this.pushSubscription = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
@ -32,9 +34,12 @@ class PWAManager {
|
||||
// Setup notifications (if enabled)
|
||||
this.setupNotifications();
|
||||
|
||||
// Show manual install option after 3 seconds if no prompt appeared
|
||||
// Setup push notifications
|
||||
this.setupPushNotifications();
|
||||
|
||||
// Show manual install option after 3 seconds if no prompt appeared and app not installed
|
||||
setTimeout(() => {
|
||||
if (!document.getElementById('pwa-install-btn')) {
|
||||
if (!document.getElementById('pwa-install-btn') && !this.isInstalled()) {
|
||||
this.showManualInstallButton();
|
||||
}
|
||||
}, 3000);
|
||||
@ -61,12 +66,27 @@ class PWAManager {
|
||||
// Debug: Check if app is already installed
|
||||
if (this.isInstalled()) {
|
||||
console.log('PWA: App is already installed (standalone mode)');
|
||||
// Hide any existing install buttons
|
||||
this.hideInstallButton();
|
||||
} else {
|
||||
console.log('PWA: App is not installed, waiting for install prompt...');
|
||||
}
|
||||
|
||||
// Periodically check if app becomes installed (for cases where user installs via browser menu)
|
||||
setInterval(() => {
|
||||
if (this.isInstalled()) {
|
||||
this.hideInstallButton();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
showInstallButton(deferredPrompt) {
|
||||
// Don't show install button if app is already installed
|
||||
if (this.isInstalled()) {
|
||||
console.log('PWA: App already installed, skipping install button');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create install button
|
||||
const installBtn = document.createElement('button');
|
||||
installBtn.id = 'pwa-install-btn';
|
||||
@ -170,6 +190,12 @@ class PWAManager {
|
||||
|
||||
// Show manual install button for browsers that don't auto-prompt
|
||||
showManualInstallButton() {
|
||||
// Don't show install button if app is already installed
|
||||
if (this.isInstalled()) {
|
||||
console.log('PWA: App already installed, skipping manual install button');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('PWA: Showing manual install button');
|
||||
const installBtn = document.createElement('button');
|
||||
installBtn.id = 'pwa-install-btn';
|
||||
@ -195,11 +221,383 @@ class PWAManager {
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true;
|
||||
}
|
||||
|
||||
// Setup push notifications
|
||||
async setupPushNotifications() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.log('PWA: Push notifications not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user has dismissed push notifications recently
|
||||
const dismissedUntil = localStorage.getItem('pushNotificationsDismissedUntil');
|
||||
if (dismissedUntil && new Date() < new Date(dismissedUntil)) {
|
||||
console.log('PWA: Push notifications dismissed by user, skipping setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get VAPID public key from server
|
||||
await this.getVapidPublicKey();
|
||||
|
||||
// Check if user is already subscribed
|
||||
await this.checkPushSubscription();
|
||||
|
||||
// Only show setup UI if not subscribed and not recently dismissed
|
||||
const isSubscribedFromCache = localStorage.getItem('pushNotificationsSubscribed') === 'true';
|
||||
if (!this.pushSubscription && !isSubscribedFromCache) {
|
||||
this.showPushNotificationSetup();
|
||||
} else {
|
||||
console.log('PWA: User already subscribed to push notifications, skipping setup UI');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('PWA: Failed to setup push notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getVapidPublicKey() {
|
||||
try {
|
||||
const response = await fetch('/api/push/vapid-key');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.vapidPublicKey = data.publicKey;
|
||||
console.log('PWA: VAPID public key retrieved');
|
||||
} else {
|
||||
throw new Error('Failed to get VAPID public key');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PWA: Error getting VAPID public key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkPushSubscription() {
|
||||
if (!this.swRegistration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.pushSubscription = await this.swRegistration.pushManager.getSubscription();
|
||||
if (this.pushSubscription) {
|
||||
console.log('PWA: Browser has push subscription');
|
||||
|
||||
// Verify server-side subscription still exists by trying to send a test
|
||||
try {
|
||||
const response = await fetch('/api/push/subscriptions', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const subscriptions = await response.json();
|
||||
const hasServerSubscription = subscriptions.some(sub =>
|
||||
sub.endpoint && this.pushSubscription.endpoint.includes(sub.endpoint.substring(0, 50))
|
||||
);
|
||||
|
||||
if (!hasServerSubscription) {
|
||||
console.log('PWA: Server subscription missing, will re-subscribe on next attempt');
|
||||
localStorage.removeItem('pushNotificationsSubscribed');
|
||||
} else {
|
||||
console.log('PWA: Server subscription confirmed');
|
||||
localStorage.setItem('pushNotificationsSubscribed', 'true');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('PWA: Could not verify server subscription:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('PWA: User is not subscribed to push notifications');
|
||||
localStorage.removeItem('pushNotificationsSubscribed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PWA: Error checking push subscription:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeToPushNotifications() {
|
||||
if (!this.swRegistration || !this.vapidPublicKey) {
|
||||
throw new Error('Service worker or VAPID key not available');
|
||||
}
|
||||
|
||||
try {
|
||||
// Check current permission status
|
||||
if (Notification.permission === 'denied') {
|
||||
throw new Error('Notification permission was denied. Please enable notifications in your browser settings.');
|
||||
}
|
||||
|
||||
// Request notification permission if not already granted
|
||||
let permission = Notification.permission;
|
||||
if (permission === 'default') {
|
||||
permission = await Notification.requestPermission();
|
||||
}
|
||||
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission is required for push notifications. Please allow notifications and try again.');
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
const subscription = await this.swRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey)
|
||||
});
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('p256dh')))),
|
||||
auth: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('auth'))))
|
||||
}),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.pushSubscription = subscription;
|
||||
console.log('PWA: Successfully subscribed to push notifications');
|
||||
|
||||
// Cache subscription state
|
||||
localStorage.setItem('pushNotificationsSubscribed', 'true');
|
||||
localStorage.setItem('pushNotificationsSubscribedAt', new Date().toISOString());
|
||||
|
||||
this.updatePushNotificationUI();
|
||||
return true;
|
||||
} else {
|
||||
throw new Error('Failed to save push subscription to server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PWA: Failed to subscribe to push notifications:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribeFromPushNotifications() {
|
||||
if (!this.pushSubscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Unsubscribe from push manager
|
||||
await this.pushSubscription.unsubscribe();
|
||||
|
||||
// Notify server
|
||||
await fetch('/api/push/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint: this.pushSubscription.endpoint
|
||||
}),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
this.pushSubscription = null;
|
||||
console.log('PWA: Successfully unsubscribed from push notifications');
|
||||
|
||||
// Clear subscription cache
|
||||
localStorage.removeItem('pushNotificationsSubscribed');
|
||||
localStorage.removeItem('pushNotificationsSubscribedAt');
|
||||
|
||||
this.updatePushNotificationUI();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('PWA: Failed to unsubscribe from push notifications:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
showPushNotificationSetup() {
|
||||
// Check if setup UI already exists
|
||||
if (document.getElementById('push-notification-setup')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setupDiv = document.createElement('div');
|
||||
setupDiv.id = 'push-notification-setup';
|
||||
setupDiv.className = 'alert alert-info alert-dismissible';
|
||||
setupDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 1050;
|
||||
max-width: 350px;
|
||||
`;
|
||||
|
||||
const isSubscribed = !!this.pushSubscription;
|
||||
|
||||
setupDiv.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-bell me-2"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Push Notifications</strong><br>
|
||||
<small>${isSubscribed ? 'You are subscribed to notifications' : 'Get notified of new orders and updates'}</small>
|
||||
</div>
|
||||
<div class="ms-2">
|
||||
${isSubscribed ?
|
||||
'<button type="button" class="btn btn-sm btn-outline-danger" id="unsubscribe-push-btn">Turn Off</button>' :
|
||||
'<div class="d-flex flex-column gap-1"><button type="button" class="btn btn-sm btn-primary" id="subscribe-push-btn">Turn On</button><button type="button" class="btn btn-sm btn-outline-secondary" id="dismiss-push-btn">Not Now</button></div>'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" id="close-push-setup"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(setupDiv);
|
||||
|
||||
// Add event listeners
|
||||
const subscribeBtn = document.getElementById('subscribe-push-btn');
|
||||
const unsubscribeBtn = document.getElementById('unsubscribe-push-btn');
|
||||
const dismissBtn = document.getElementById('dismiss-push-btn');
|
||||
const closeBtn = document.getElementById('close-push-setup');
|
||||
|
||||
if (subscribeBtn) {
|
||||
subscribeBtn.addEventListener('click', async () => {
|
||||
subscribeBtn.disabled = true;
|
||||
subscribeBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Subscribing...';
|
||||
|
||||
try {
|
||||
await this.subscribeToPushNotifications();
|
||||
this.showNotification('Push notifications enabled!', {
|
||||
body: 'You will now receive notifications for new orders and updates.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('PWA: Push subscription failed:', error);
|
||||
|
||||
// Provide user-friendly error messages
|
||||
let userMessage = error.message;
|
||||
if (error.message.includes('permission')) {
|
||||
userMessage = 'Please allow notifications when your browser asks, then try again.';
|
||||
}
|
||||
|
||||
alert('Failed to enable push notifications: ' + userMessage);
|
||||
subscribeBtn.disabled = false;
|
||||
subscribeBtn.innerHTML = 'Turn On';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (unsubscribeBtn) {
|
||||
unsubscribeBtn.addEventListener('click', async () => {
|
||||
unsubscribeBtn.disabled = true;
|
||||
unsubscribeBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Disabling...';
|
||||
|
||||
try {
|
||||
await this.unsubscribeFromPushNotifications();
|
||||
this.showNotification('Push notifications disabled', {
|
||||
body: 'You will no longer receive push notifications.'
|
||||
});
|
||||
} catch (error) {
|
||||
alert('Failed to disable push notifications: ' + error.message);
|
||||
unsubscribeBtn.disabled = false;
|
||||
unsubscribeBtn.innerHTML = 'Turn Off';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle dismiss button
|
||||
if (dismissBtn) {
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
// Dismiss for 24 hours
|
||||
const dismissUntil = new Date();
|
||||
dismissUntil.setHours(dismissUntil.getHours() + 24);
|
||||
localStorage.setItem('pushNotificationsDismissedUntil', dismissUntil.toISOString());
|
||||
|
||||
const element = document.getElementById('push-notification-setup');
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
console.log('PWA: Push notifications dismissed for 24 hours');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle close button
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
// Dismiss for 1 hour
|
||||
const dismissUntil = new Date();
|
||||
dismissUntil.setHours(dismissUntil.getHours() + 1);
|
||||
localStorage.setItem('pushNotificationsDismissedUntil', dismissUntil.toISOString());
|
||||
console.log('PWA: Push notifications dismissed for 1 hour');
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-hide after 15 seconds
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById('push-notification-setup');
|
||||
if (element) {
|
||||
element.remove();
|
||||
|
||||
// Auto-dismiss for 2 hours if user ignores
|
||||
const dismissUntil = new Date();
|
||||
dismissUntil.setHours(dismissUntil.getHours() + 2);
|
||||
localStorage.setItem('pushNotificationsDismissedUntil', dismissUntil.toISOString());
|
||||
console.log('PWA: Push notifications auto-dismissed for 2 hours');
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
updatePushNotificationUI() {
|
||||
const setupDiv = document.getElementById('push-notification-setup');
|
||||
if (setupDiv) {
|
||||
setupDiv.remove();
|
||||
this.showPushNotificationSetup();
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestNotification() {
|
||||
try {
|
||||
const response = await fetch('/api/push/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test push notification from LittleShop Admin!'
|
||||
}),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
console.log('PWA: Test notification sent successfully');
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to send test notification');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PWA: Failed to send test notification:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert VAPID key
|
||||
urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize PWA Manager
|
||||
const pwaManager = new PWAManager();
|
||||
window.pwaManager = pwaManager;
|
||||
|
||||
// Expose notification function globally
|
||||
window.showNotification = (title, options) => pwaManager.showNotification(title, options);
|
||||
// Expose notification functions globally
|
||||
window.showNotification = (title, options) => pwaManager.showNotification(title, options);
|
||||
window.sendTestPushNotification = () => pwaManager.sendTestNotification();
|
||||
window.subscribeToPushNotifications = () => pwaManager.subscribeToPushNotifications();
|
||||
window.unsubscribeFromPushNotifications = () => pwaManager.unsubscribeFromPushNotifications();
|
||||
@ -47,6 +47,80 @@ self.addEventListener('activate', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Push event - handle incoming push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('SW: Push notification received');
|
||||
|
||||
if (!event.data) {
|
||||
console.log('SW: Push notification received without data');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = event.data.json();
|
||||
console.log('SW: Push notification data:', data);
|
||||
|
||||
const notificationOptions = {
|
||||
body: data.body || 'New notification',
|
||||
icon: data.icon || '/icons/icon-192x192.png',
|
||||
badge: data.badge || '/icons/icon-72x72.png',
|
||||
tag: 'littleshop-notification',
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
data: data.data || {}
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'LittleShop Admin', notificationOptions)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('SW: Error parsing push notification data:', error);
|
||||
|
||||
// Show generic notification
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('LittleShop Admin', {
|
||||
body: 'New notification received',
|
||||
icon: '/icons/icon-192x192.png',
|
||||
badge: '/icons/icon-72x72.png',
|
||||
tag: 'littleshop-notification'
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Notification click event - handle user interaction with notifications
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('SW: Notification clicked');
|
||||
event.notification.close();
|
||||
|
||||
const notificationData = event.notification.data || {};
|
||||
|
||||
// Determine URL to open
|
||||
let urlToOpen = '/Admin/Dashboard';
|
||||
if (notificationData.url) {
|
||||
urlToOpen = notificationData.url;
|
||||
} else if (notificationData.orderId) {
|
||||
urlToOpen = `/Admin/Orders/Details/${notificationData.orderId}`;
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Check if there's already a window/tab open with the target URL
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(urlToOpen) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// If no window/tab is already open, open a new one
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache, fallback to network
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Only handle GET requests
|
||||
|
||||
Loading…
Reference in New Issue
Block a user