diff --git a/src/Humans.Web/Controllers/NotificationApiController.cs b/src/Humans.Web/Controllers/NotificationApiController.cs new file mode 100644 index 00000000..e7dfd753 --- /dev/null +++ b/src/Humans.Web/Controllers/NotificationApiController.cs @@ -0,0 +1,139 @@ +using System.Security.Claims; +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; +using NodaTime; +using NodaTime.Text; + +namespace Humans.Web.Controllers; + +[ApiController] +[Route("api/notifications")] +[AllowAnonymous] +[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 59076fee..4e105f2f 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 95299d79..9b257c65 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);