From 1ab3ef7196aa1ab933051add3e98fcacb4088b17 Mon Sep 17 00:00:00 2001 From: Peter Drier Date: Fri, 10 Apr 2026 16:11:39 +0200 Subject: [PATCH 1/2] Add notifications API endpoint for external consumers (#469) New GET /api/notifications endpoint authenticated via X-Api-Key header, returning unread notifications and live meter counts for a configured user. Reuses existing NotificationInboxService and NotificationMeterProvider. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controllers/NotificationApiController.cs | 137 ++++++++++++++++++ ...frastructureServiceCollectionExtensions.cs | 8 + src/Humans.Web/Filters/ApiKeyAuthFilter.cs | 9 ++ 3 files changed, 154 insertions(+) create mode 100644 src/Humans.Web/Controllers/NotificationApiController.cs diff --git a/src/Humans.Web/Controllers/NotificationApiController.cs b/src/Humans.Web/Controllers/NotificationApiController.cs new file mode 100644 index 000000000..3045244e3 --- /dev/null +++ b/src/Humans.Web/Controllers/NotificationApiController.cs @@ -0,0 +1,137 @@ +using System.Security.Claims; +using Humans.Application.Interfaces; +using Humans.Infrastructure.Data; +using Humans.Web.Filters; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using NodaTime; +using NodaTime.Text; + +namespace Humans.Web.Controllers; + +[ApiController] +[Route("api/notifications")] +[ServiceFilter(typeof(NotificationApiKeyAuthFilter))] +public class NotificationApiController : ControllerBase +{ + private readonly INotificationInboxService _inboxService; + private readonly INotificationMeterProvider _meterProvider; + private readonly HumansDbContext _dbContext; + private readonly IClock _clock; + private readonly NotificationApiSettings _settings; + private readonly ILogger _logger; + + public NotificationApiController( + INotificationInboxService inboxService, + INotificationMeterProvider meterProvider, + HumansDbContext dbContext, + IClock clock, + IOptions settings, + ILogger logger) + { + _inboxService = inboxService; + _meterProvider = meterProvider; + _dbContext = dbContext; + _clock = clock; + _settings = settings.Value; + _logger = logger; + } + + [HttpGet] + public async Task Get( + [FromQuery] string? since = null, + CancellationToken ct = default) + { + if (!Guid.TryParse(_settings.UserId, out var userId)) + { + _logger.LogError("NotificationApi:UserId is not configured or invalid"); + return StatusCode(503, new { error = "UserId not configured" }); + } + + try + { + // Build inbox query — unread tab, no filter, no search + var inbox = await _inboxService.GetInboxAsync(userId, search: null, filter: "", tab: "unread", ct); + + var notifications = inbox.NeedsAttention + .Concat(inbox.Informational) + .AsEnumerable(); + + // Apply since filter if provided + if (since is not null) + { + var parseResult = LocalDatePattern.Iso.Parse(since); + if (!parseResult.Success) + { + return BadRequest(new { error = "Invalid 'since' format. Use yyyy-MM-dd." }); + } + + var sinceInstant = parseResult.Value.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); + var sinceUtc = sinceInstant.ToDateTimeUtc(); + notifications = notifications.Where(n => n.CreatedAt >= sinceUtc); + } + + var notificationList = notifications.Select(n => new + { + Date = n.CreatedAt, + Source = n.Source.ToString(), + Subject = n.Title, + Link = n.ActionUrl, + Priority = n.Priority.ToString().ToLowerInvariant(), + Class = n.Class.ToString().ToLowerInvariant(), + Unread = !n.IsRead, + }).ToList(); + + // Build meters using a ClaimsPrincipal for the configured user + var principal = await BuildClaimsPrincipalAsync(userId, ct); + var meters = await _meterProvider.GetMetersForUserAsync(principal, ct); + + var meterList = meters.Select(m => new + { + Label = m.Title, + m.Count, + Link = m.ActionUrl, + }).ToList(); + + return Ok(new + { + Notifications = notificationList, + Meters = meterList, + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve notifications for API"); + return StatusCode(500, new { error = "Failed to retrieve notifications" }); + } + } + + private async Task BuildClaimsPrincipalAsync(Guid userId, CancellationToken ct) + { + var now = _clock.GetCurrentInstant(); + + var roles = await _dbContext.RoleAssignments + .AsNoTracking() + .Where(ra => + ra.UserId == userId && + ra.ValidFrom <= now && + (ra.ValidTo == null || ra.ValidTo > now)) + .Select(ra => ra.RoleName) + .Distinct() + .ToListAsync(ct); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, userId.ToString()), + }; + + foreach (var role in roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + var identity = new ClaimsIdentity(claims, "ApiKey"); + return new ClaimsPrincipal(identity); + } +} diff --git a/src/Humans.Web/Extensions/InfrastructureServiceCollectionExtensions.cs b/src/Humans.Web/Extensions/InfrastructureServiceCollectionExtensions.cs index 59076feef..4e105f2f2 100644 --- a/src/Humans.Web/Extensions/InfrastructureServiceCollectionExtensions.cs +++ b/src/Humans.Web/Extensions/InfrastructureServiceCollectionExtensions.cs @@ -202,6 +202,14 @@ public static IServiceCollection AddHumansInfrastructure( }); services.AddScoped(); + // Notification API key + user mapping + services.Configure(opts => + { + opts.ApiKey = Environment.GetEnvironmentVariable("NOTIFICATION_API_KEY") ?? string.Empty; + opts.UserId = Environment.GetEnvironmentVariable("NOTIFICATION_API_USER_ID") ?? string.Empty; + }); + services.AddScoped(); + // Ticket vendor integration var ticketVendorApiKey = Environment.GetEnvironmentVariable("TICKET_VENDOR_API_KEY") ?? string.Empty; diff --git a/src/Humans.Web/Filters/ApiKeyAuthFilter.cs b/src/Humans.Web/Filters/ApiKeyAuthFilter.cs index 95299d791..9b257c65a 100644 --- a/src/Humans.Web/Filters/ApiKeyAuthFilter.cs +++ b/src/Humans.Web/Filters/ApiKeyAuthFilter.cs @@ -46,3 +46,12 @@ public class ApiKeyAuthFilter(IOptions settings) public class LogApiKeyAuthFilter(IOptions settings) : ApiKeyAuthFilterBase(settings.Value.ApiKey); + +public class NotificationApiSettings +{ + public string ApiKey { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; +} + +public class NotificationApiKeyAuthFilter(IOptions settings) + : ApiKeyAuthFilterBase(settings.Value.ApiKey); From 8a83696a97f50cd919b7772416b9811b2b2e6514 Mon Sep 17 00:00:00 2001 From: Peter Drier Date: Fri, 10 Apr 2026 16:38:35 +0200 Subject: [PATCH 2/2] Add AllowAnonymous to NotificationApiController Prevents MembershipRequiredFilter from redirecting API-key-authenticated requests to HTML membership pages when the caller also has a non-member cookie session. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Humans.Web/Controllers/NotificationApiController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Humans.Web/Controllers/NotificationApiController.cs b/src/Humans.Web/Controllers/NotificationApiController.cs index 3045244e3..e7dfd753e 100644 --- a/src/Humans.Web/Controllers/NotificationApiController.cs +++ b/src/Humans.Web/Controllers/NotificationApiController.cs @@ -2,6 +2,7 @@ using Humans.Application.Interfaces; using Humans.Infrastructure.Data; using Humans.Web.Filters; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -12,6 +13,7 @@ namespace Humans.Web.Controllers; [ApiController] [Route("api/notifications")] +[AllowAnonymous] [ServiceFilter(typeof(NotificationApiKeyAuthFilter))] public class NotificationApiController : ControllerBase {