using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Push; using ROLAC.API.Entities.Notifications; using ROLAC.API.Services.Logging; using ROLAC.API.Services.Notifications; namespace ROLAC.API.Controllers; /// /// Self-service Web Push subscription management for the logged-in member: hand out the VAPID public /// key, store the browser's subscription, and remove it on opt-out. Subscriptions are keyed to the /// caller's linked Member, so an admin-only account (no MemberId) cannot subscribe. /// [ApiController] [Route("api/push")] [Authorize] public sealed class PushSubscriptionsController : ControllerBase { private readonly AppDbContext _db; private readonly INotificationSettingsService _settings; private readonly IWebPushService _webPush; private readonly CurrentUserAccessor _currentUser; public PushSubscriptionsController( AppDbContext db, INotificationSettingsService settings, IWebPushService webPush, CurrentUserAccessor currentUser) { _db = db; _settings = settings; _webPush = webPush; _currentUser = currentUser; } [HttpGet("vapid-public-key")] public IActionResult VapidPublicKey() => Ok(new { publicKey = _settings.GetWebPush().PublicKey }); [HttpPost("subscriptions")] public async Task Subscribe([FromBody] PushSubscriptionRequest request, CancellationToken ct) { var memberId = await CurrentMemberIdAsync(ct); if (memberId is null) return Conflict(new { message = "This account is not linked to a member, so it cannot receive push notifications." }); var existing = await _db.WebPushSubscriptions .FirstOrDefaultAsync(subscription => subscription.Endpoint == request.Endpoint, ct); if (existing is null) { _db.WebPushSubscriptions.Add(new WebPushSubscription { MemberId = memberId.Value, Endpoint = request.Endpoint, P256dh = request.Keys.P256dh, Auth = request.Keys.Auth, UserAgent = request.UserAgent, CreatedAt = DateTime.UtcNow, }); } else { // Same endpoint re-subscribed (e.g. keys rotated, or now a different member on this browser). existing.MemberId = memberId.Value; existing.P256dh = request.Keys.P256dh; existing.Auth = request.Keys.Auth; existing.UserAgent = request.UserAgent; } await _db.SaveChangesAsync(ct); return NoContent(); } [HttpDelete("subscriptions")] public async Task Unsubscribe([FromBody] PushUnsubscribeRequest request, CancellationToken ct) { var subscription = await _db.WebPushSubscriptions .FirstOrDefaultAsync(s => s.Endpoint == request.Endpoint, ct); if (subscription is not null) { _db.WebPushSubscriptions.Remove(subscription); await _db.SaveChangesAsync(ct); } return NoContent(); } // Sends a canned push to the caller's own devices so they can confirm notifications work. [HttpPost("test")] public async Task SendTestToSelf(CancellationToken ct) { var memberId = await CurrentMemberIdAsync(ct); if (memberId is null) return Conflict(new { message = "This account is not linked to a member, so it cannot receive push notifications." }); var result = await _webPush.SendToMembersAsync( new[] { memberId.Value }, new WebPushPayload("River of Life", "通知測試成功!This is a test notification.", "/user-portal/account"), _currentUser.UserIdOrSystem, ct); return Ok(result); } private async Task CurrentMemberIdAsync(CancellationToken ct) { var userId = _currentUser.UserId; if (string.IsNullOrEmpty(userId)) return null; return await _db.Users.Where(user => user.Id == userId).Select(user => user.MemberId).FirstOrDefaultAsync(ct); } }