Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions src/Humans.Web/Controllers/NotificationApiController.cs
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +14 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark API-key endpoint as anonymous to avoid membership redirects

This controller is missing [AllowAnonymous], so requests with a valid X-Api-Key can still be redirected by the global MembershipRequiredFilter when the caller also has an authenticated but non-active-member cookie. In Program.cs, that filter is applied globally, and in MembershipRequiredFilter authenticated users are redirected unless the action/controller is anonymous; as written, /api/notifications can return a 302 HTML redirect instead of the JSON API response depending on caller session state.

Useful? React with 👍 / 👎.

{
private readonly INotificationInboxService _inboxService;
private readonly INotificationMeterProvider _meterProvider;
private readonly HumansDbContext _dbContext;
private readonly IClock _clock;
private readonly NotificationApiSettings _settings;
private readonly ILogger<NotificationApiController> _logger;

public NotificationApiController(
INotificationInboxService inboxService,
INotificationMeterProvider meterProvider,
HumansDbContext dbContext,
IClock clock,
IOptions<NotificationApiSettings> settings,
ILogger<NotificationApiController> logger)
{
_inboxService = inboxService;
_meterProvider = meterProvider;
_dbContext = dbContext;
_clock = clock;
_settings = settings.Value;
_logger = logger;
}

[HttpGet]
public async Task<IActionResult> 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<ClaimsPrincipal> 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<Claim>
{
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ public static IServiceCollection AddHumansInfrastructure(
});
services.AddScoped<LogApiKeyAuthFilter>();

// Notification API key + user mapping
services.Configure<NotificationApiSettings>(opts =>
{
opts.ApiKey = Environment.GetEnvironmentVariable("NOTIFICATION_API_KEY") ?? string.Empty;
opts.UserId = Environment.GetEnvironmentVariable("NOTIFICATION_API_USER_ID") ?? string.Empty;
});
services.AddScoped<NotificationApiKeyAuthFilter>();

// Ticket vendor integration
var ticketVendorApiKey = Environment.GetEnvironmentVariable("TICKET_VENDOR_API_KEY") ?? string.Empty;

Expand Down
9 changes: 9 additions & 0 deletions src/Humans.Web/Filters/ApiKeyAuthFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,12 @@ public class ApiKeyAuthFilter(IOptions<FeedbackApiSettings> settings)

public class LogApiKeyAuthFilter(IOptions<LogApiSettings> 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<NotificationApiSettings> settings)
: ApiKeyAuthFilterBase(settings.Value.ApiKey);
Loading