WebPush-and-photo-upload-fixes

This commit is contained in:
sysadmin 2025-09-01 06:01:05 +01:00
parent 5eb7647faf
commit c8a55c143b
16 changed files with 2000 additions and 6 deletions

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

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

View File

@ -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
{

View File

@ -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>
@ -153,3 +187,56 @@
</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>
}

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

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

View File

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

View File

@ -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>

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

View File

@ -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>();

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

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

View File

@ -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",

View File

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

View File

@ -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
// Expose notification functions globally
window.showNotification = (title, options) => pwaManager.showNotification(title, options);
window.sendTestPushNotification = () => pwaManager.sendTestNotification();
window.subscribeToPushNotifications = () => pwaManager.subscribeToPushNotifications();
window.unsubscribeFromPushNotifications = () => pwaManager.unsubscribeFromPushNotifications();

View File

@ -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