diff --git a/src/Humans.Application/Interfaces/IDashboardService.cs b/src/Humans.Application/Interfaces/IDashboardService.cs new file mode 100644 index 000000000..83914b601 --- /dev/null +++ b/src/Humans.Application/Interfaces/IDashboardService.cs @@ -0,0 +1,60 @@ +using Humans.Application.DTOs; +using Humans.Domain.Entities; +using Humans.Domain.Enums; +using NodaTime; +using MemberApplication = Humans.Domain.Entities.Application; + +namespace Humans.Application.Interfaces; + +/// +/// Orchestrates the member dashboard view: applies business rules to combine +/// membership, application term state, shift discovery, tickets, and participation +/// into a single pre-computed snapshot the web controller can map directly to a +/// view model. Authorization-free; callers are responsible for gating access. +/// +public interface IDashboardService +{ + Task GetMemberDashboardAsync( + Guid userId, + bool isPrivileged, + CancellationToken cancellationToken = default); +} + +/// +/// Pre-computed dashboard data for a signed-in member. +/// All business rules (term expiry, urgent shift aggregation, signup filtering) +/// are applied by the service; the controller maps this 1:1 onto its view model. +/// +public record MemberDashboardData( + Profile? Profile, + MembershipSnapshot MembershipSnapshot, + MemberApplication? LatestApplication, + bool HasPendingApplication, + MembershipTier CurrentTier, + LocalDate? TermExpiresAt, + bool TermExpiresSoon, + bool TermExpired, + EventSettings? ActiveEvent, + IReadOnlyList UrgentShifts, + IReadOnlyList NextShifts, + int PendingSignupCount, + bool HasShiftSignups, + bool TicketsConfigured, + bool HasTicket, + int UserTicketCount, + ParticipationStatus? ParticipationStatus); + +/// Dashboard-shaped urgent shift entry (domain shift with joined department name). +public record DashboardUrgentShift( + Shift Shift, + string DepartmentName, + Instant AbsoluteStart, + int RemainingSlots, + double UrgencyScore); + +/// Dashboard-shaped confirmed signup entry (domain signup with resolved dept and bounds). +public record DashboardSignup( + ShiftSignup Signup, + string DepartmentName, + Instant AbsoluteStart, + Instant AbsoluteEnd); diff --git a/src/Humans.Infrastructure/Services/DashboardService.cs b/src/Humans.Infrastructure/Services/DashboardService.cs new file mode 100644 index 000000000..9ef6efe75 --- /dev/null +++ b/src/Humans.Infrastructure/Services/DashboardService.cs @@ -0,0 +1,247 @@ +using Humans.Application.Configuration; +using Humans.Application.Interfaces; +using Humans.Domain.Entities; +using Humans.Domain.Enums; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NodaTime; +using MemberApplication = Humans.Domain.Entities.Application; + +namespace Humans.Infrastructure.Services; + +/// +/// Orchestrates the member dashboard snapshot. Pulls from several owning services +/// (profile, membership, applications, shifts, signups, tickets, participation) +/// and applies the business rules (term expiry, urgent-shift aggregation, signup +/// filtering, ticket visibility) that previously lived in HomeController. +/// +public class DashboardService : IDashboardService +{ + private readonly IProfileService _profileService; + private readonly IMembershipCalculator _membershipCalculator; + private readonly IApplicationDecisionService _applicationDecisionService; + private readonly IShiftManagementService _shiftMgmt; + private readonly IShiftSignupService _shiftSignup; + private readonly ITicketQueryService _ticketQueryService; + private readonly IUserService _userService; + private readonly TicketVendorSettings _ticketSettings; + private readonly IClock _clock; + private readonly ILogger _logger; + + public DashboardService( + IProfileService profileService, + IMembershipCalculator membershipCalculator, + IApplicationDecisionService applicationDecisionService, + IShiftManagementService shiftMgmt, + IShiftSignupService shiftSignup, + ITicketQueryService ticketQueryService, + IUserService userService, + IOptions ticketSettings, + IClock clock, + ILogger logger) + { + _profileService = profileService; + _membershipCalculator = membershipCalculator; + _applicationDecisionService = applicationDecisionService; + _shiftMgmt = shiftMgmt; + _shiftSignup = shiftSignup; + _ticketQueryService = ticketQueryService; + _userService = userService; + _ticketSettings = ticketSettings.Value; + _clock = clock; + _logger = logger; + } + + public async Task GetMemberDashboardAsync( + Guid userId, + bool isPrivileged, + CancellationToken cancellationToken = default) + { + _ = isPrivileged; // Retained for future privileged-only fields; no current effect. + + var profile = await _profileService.GetProfileAsync(userId, cancellationToken); + var membershipSnapshot = await _membershipCalculator.GetMembershipSnapshotAsync(userId, cancellationToken); + + // Applications + term expiry state + var applications = await _applicationDecisionService.GetUserApplicationsAsync(userId, cancellationToken); + var latestApplication = applications.Count > 0 ? applications[0] : null; + var hasPendingApp = latestApplication is not null && + latestApplication.Status == ApplicationStatus.Submitted; + + var currentTier = profile?.MembershipTier ?? MembershipTier.Volunteer; + var (termExpiresAt, termExpiresSoon, termExpired) = + ComputeTermState(applications, currentTier); + + // Shift cards (urgent shifts + confirmed signups) — guarded, failures never crash the dashboard. + EventSettings? activeEvent = null; + try + { + activeEvent = await _shiftMgmt.GetActiveAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load active event for dashboard"); + } + + var urgentItems = new List(); + var nextShifts = new List(); + var pendingCount = 0; + var hasShiftSignups = false; + + if (activeEvent is not null && activeEvent.IsShiftBrowsingOpen) + { + try + { + var urgentShifts = await _shiftMgmt.GetUrgentShiftsAsync(activeEvent.Id, limit: 3); + foreach (var u in urgentShifts) + { + if (u.Shift is null) + { + _logger.LogWarning("Skipping urgent shift item because shift data was missing"); + continue; + } + + try + { + urgentItems.Add(new DashboardUrgentShift( + Shift: u.Shift, + DepartmentName: u.DepartmentName ?? "Unknown", + AbsoluteStart: u.Shift.GetAbsoluteStart(activeEvent), + RemainingSlots: u.RemainingSlots, + UrgencyScore: u.UrgencyScore)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to build urgent shift item for shift {ShiftId}", u.Shift.Id); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load urgent shifts for dashboard"); + } + + try + { + var now = _clock.GetCurrentInstant(); + var userSignups = await _shiftSignup.GetByUserAsync(userId, activeEvent.Id); + pendingCount = userSignups + .Where(s => s.Status == SignupStatus.Pending) + .Select(s => s.SignupBlockId ?? s.Id) + .Distinct() + .Count(); + + foreach (var s in userSignups.Where(s => s.Status == SignupStatus.Confirmed)) + { + try + { + if (s.Shift is null) + { + _logger.LogWarning("Skipping signup {SignupId} on dashboard because shift data was missing", s.Id); + continue; + } + + var item = new DashboardSignup( + Signup: s, + DepartmentName: s.Shift.Rota?.Team?.Name ?? "Unknown", + AbsoluteStart: s.Shift.GetAbsoluteStart(activeEvent), + AbsoluteEnd: s.Shift.GetAbsoluteEnd(activeEvent)); + if (item.AbsoluteEnd > now) + nextShifts.Add(item); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to build shift item for signup {SignupId}", s.Id); + } + } + + nextShifts = nextShifts.OrderBy(i => i.AbsoluteStart).Take(3).ToList(); + hasShiftSignups = nextShifts.Count > 0 || pendingCount > 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load user signups for dashboard"); + } + } + + // Ticket state + var ticketsConfigured = _ticketSettings.IsConfigured; + var hasTicket = false; + var userTicketCount = 0; + try + { + if (ticketsConfigured) + { + userTicketCount = await _ticketQueryService.GetUserTicketCountAsync(userId); + hasTicket = userTicketCount > 0; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load ticket status for user {UserId}", userId); + } + + // Event participation + ParticipationStatus? participationStatus = null; + try + { + if (activeEvent is not null && activeEvent.Year > 0) + { + var participation = await _userService.GetParticipationAsync(userId, activeEvent.Year, cancellationToken); + participationStatus = participation?.Status; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load participation status for user {UserId}", userId); + } + + return new MemberDashboardData( + Profile: profile, + MembershipSnapshot: membershipSnapshot, + LatestApplication: latestApplication, + HasPendingApplication: hasPendingApp, + CurrentTier: currentTier, + TermExpiresAt: termExpiresAt, + TermExpiresSoon: termExpiresSoon, + TermExpired: termExpired, + ActiveEvent: activeEvent, + UrgentShifts: urgentItems, + NextShifts: nextShifts, + PendingSignupCount: pendingCount, + HasShiftSignups: hasShiftSignups, + TicketsConfigured: ticketsConfigured, + HasTicket: hasTicket, + UserTicketCount: userTicketCount, + ParticipationStatus: participationStatus); + } + + private (LocalDate? ExpiresAt, bool ExpiresSoon, bool Expired) ComputeTermState( + IReadOnlyList applications, + MembershipTier currentTier) + { + if (currentTier == MembershipTier.Volunteer) + { + return (null, false, false); + } + + var latestApprovedApp = applications + .Where(a => a.Status == ApplicationStatus.Approved + && a.MembershipTier == currentTier + && a.TermExpiresAt is not null) + .OrderByDescending(a => a.TermExpiresAt) + .FirstOrDefault(); + + if (latestApprovedApp?.TermExpiresAt is null) + { + return (null, false, false); + } + + var today = _clock.GetCurrentInstant().InUtc().Date; + var expiryDate = latestApprovedApp.TermExpiresAt.Value; + var expired = expiryDate < today; + var expiresSoon = !expired && expiryDate <= today.PlusDays(90); + + return (expiryDate, expiresSoon, expired); + } +} diff --git a/src/Humans.Web/Controllers/HomeController.cs b/src/Humans.Web/Controllers/HomeController.cs index 601fcc7fe..2f398068e 100644 --- a/src/Humans.Web/Controllers/HomeController.cs +++ b/src/Humans.Web/Controllers/HomeController.cs @@ -1,8 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using NodaTime; using Humans.Application.Configuration; using Humans.Application.Interfaces; using Humans.Domain.Entities; @@ -14,50 +12,32 @@ namespace Humans.Web.Controllers; public class HomeController : HumansControllerBase { - private readonly IMembershipCalculator _membershipCalculator; - private readonly IProfileService _profileService; - private readonly IApplicationDecisionService _applicationDecisionService; + private readonly IDashboardService _dashboardService; private readonly IShiftManagementService _shiftMgmt; - private readonly IShiftSignupService _shiftSignup; + private readonly IUserService _userService; private readonly IConfiguration _configuration; private readonly ConfigurationRegistry _configRegistry; - private readonly IClock _clock; - private readonly TicketVendorSettings _ticketSettings; - private readonly ITicketQueryService _ticketQueryService; - private readonly IUserService _userService; private readonly ILogger _logger; public HomeController( UserManager userManager, - IMembershipCalculator membershipCalculator, - IProfileService profileService, - IApplicationDecisionService applicationDecisionService, + IDashboardService dashboardService, IShiftManagementService shiftMgmt, - IShiftSignupService shiftSignup, + IUserService userService, IConfiguration configuration, ConfigurationRegistry configRegistry, - IClock clock, - IOptions ticketSettings, - ITicketQueryService ticketQueryService, - IUserService userService, ILogger logger) : base(userManager) { - _membershipCalculator = membershipCalculator; - _profileService = profileService; - _applicationDecisionService = applicationDecisionService; + _dashboardService = dashboardService; _shiftMgmt = shiftMgmt; - _shiftSignup = shiftSignup; + _userService = userService; _configuration = configuration; _configRegistry = configRegistry; - _clock = clock; - _ticketSettings = ticketSettings.Value; - _ticketQueryService = ticketQueryService; - _userService = userService; _logger = logger; } - public async Task Index() + public async Task Index(CancellationToken cancellationToken) { if (!User.Identity?.IsAuthenticated ?? true) { @@ -80,209 +60,67 @@ public async Task Index() return RedirectToAction(nameof(Index), "Guest"); } - var profile = await _profileService.GetProfileAsync(user.Id); - - var membershipSnapshot = await _membershipCalculator.GetMembershipSnapshotAsync(user.Id); - - // Get all applications for the user - var applications = await _applicationDecisionService.GetUserApplicationsAsync(user.Id); - var latestApplication = applications.Count > 0 ? applications[0] : null; - - var hasPendingApp = latestApplication is not null && - latestApplication.Status == ApplicationStatus.Submitted; - - // Get term expiry from latest approved application for the user's current tier - var currentTier = profile?.MembershipTier ?? MembershipTier.Volunteer; - DateTime? termExpiresAt = null; - var termExpiresSoon = false; - var termExpired = false; - - if (currentTier != MembershipTier.Volunteer) - { - var latestApprovedApp = applications - .Where(a => a.Status == ApplicationStatus.Approved - && a.MembershipTier == currentTier - && a.TermExpiresAt is not null) - .OrderByDescending(a => a.TermExpiresAt) - .FirstOrDefault(); - - if (latestApprovedApp?.TermExpiresAt is not null) - { - var today = _clock.GetCurrentInstant().InUtc().Date; - var expiryDate = latestApprovedApp.TermExpiresAt.Value; - termExpiresAt = expiryDate.AtMidnight().InUtc().ToDateTimeUtc(); - termExpired = expiryDate < today; - termExpiresSoon = !termExpired && expiryDate <= today.PlusDays(90); - } - } + var isPrivileged = User.IsInRole("Admin"); + var data = await _dashboardService.GetMemberDashboardAsync(user.Id, isPrivileged, cancellationToken); var viewModel = new DashboardViewModel { UserId = user.Id, DisplayName = user.DisplayName, ProfilePictureUrl = user.ProfilePictureUrl, - MembershipStatus = membershipSnapshot.Status, - HasProfile = profile is not null, - ProfileComplete = profile is not null && !string.IsNullOrEmpty(profile.FirstName), - PendingConsents = membershipSnapshot.PendingConsentCount, - TotalRequiredConsents = membershipSnapshot.RequiredConsentCount, - IsVolunteerMember = membershipSnapshot.IsVolunteerMember, - MembershipTier = currentTier, - ConsentCheckStatus = profile?.ConsentCheckStatus, - IsRejected = profile?.RejectedAt is not null, - RejectionReason = profile?.RejectionReason, - HasPendingApplication = hasPendingApp, - LatestApplicationStatus = latestApplication?.Status, - LatestApplicationDate = latestApplication?.SubmittedAt.ToDateTimeUtc(), - LatestApplicationTier = latestApplication?.MembershipTier, - TermExpiresAt = termExpiresAt, - TermExpiresSoon = termExpiresSoon, - TermExpired = termExpired, + MembershipStatus = data.MembershipSnapshot.Status, + HasProfile = data.Profile is not null, + ProfileComplete = data.Profile is not null && !string.IsNullOrEmpty(data.Profile.FirstName), + PendingConsents = data.MembershipSnapshot.PendingConsentCount, + TotalRequiredConsents = data.MembershipSnapshot.RequiredConsentCount, + IsVolunteerMember = data.MembershipSnapshot.IsVolunteerMember, + MembershipTier = data.CurrentTier, + ConsentCheckStatus = data.Profile?.ConsentCheckStatus, + IsRejected = data.Profile?.RejectedAt is not null, + RejectionReason = data.Profile?.RejectionReason, + HasPendingApplication = data.HasPendingApplication, + LatestApplicationStatus = data.LatestApplication?.Status, + LatestApplicationDate = data.LatestApplication?.SubmittedAt.ToDateTimeUtc(), + LatestApplicationTier = data.LatestApplication?.MembershipTier, + TermExpiresAt = data.TermExpiresAt?.AtMidnight().InUtc().ToDateTimeUtc(), + TermExpiresSoon = data.TermExpiresSoon, + TermExpired = data.TermExpired, MemberSince = user.CreatedAt.ToDateTimeUtc(), - LastLogin = user.LastLoginAt?.ToDateTimeUtc() + LastLogin = user.LastLoginAt?.ToDateTimeUtc(), + EventName = data.ActiveEvent?.EventName, + IsShiftBrowsingOpen = data.ActiveEvent?.IsShiftBrowsingOpen ?? false, + HasShiftSignups = data.HasShiftSignups, + TicketPurchaseUrl = "https://tickets.nobodies.team", + TicketsConfigured = data.TicketsConfigured, + HasTicket = data.HasTicket, + UserTicketCount = data.UserTicketCount, + EventYear = data.ActiveEvent is not null && data.ActiveEvent.Year > 0 ? data.ActiveEvent.Year : null, + ParticipationStatus = data.ParticipationStatus, }; - // Load active event once — used by shift cards, ticket widget, and participation - EventSettings? activeEvent = null; - try - { - activeEvent = await _shiftMgmt.GetActiveAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load active event for dashboard"); - } - - // Shift cards — fully guarded, failures must never crash the homepage - try + ViewData["ShiftCards"] = new ShiftCardsViewModel { - if (activeEvent is not null) - { - viewModel.EventName = activeEvent.EventName; - viewModel.IsShiftBrowsingOpen = activeEvent.IsShiftBrowsingOpen; - } - if (activeEvent is not null && activeEvent.IsShiftBrowsingOpen) - { - var urgentShifts = await _shiftMgmt.GetUrgentShiftsAsync(activeEvent.Id, limit: 3); - - var urgentItems = new List(); - foreach (var u in urgentShifts) + UrgentShifts = data.UrgentShifts + .Select(u => new UrgentShiftItem { - if (u.Shift is null) - { - _logger.LogWarning("Skipping urgent shift item because shift data was missing"); - continue; - } - - try - { - urgentItems.Add(new UrgentShiftItem - { - Shift = u.Shift, - DepartmentName = u.DepartmentName ?? "Unknown", - AbsoluteStart = u.Shift.GetAbsoluteStart(activeEvent), - RemainingSlots = u.RemainingSlots, - UrgencyScore = u.UrgencyScore - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to build urgent shift item for shift {ShiftId}", u.Shift.Id); - } - } - - var nextShifts = new List(); - var pendingCount = 0; - try + Shift = u.Shift, + DepartmentName = u.DepartmentName, + AbsoluteStart = u.AbsoluteStart, + RemainingSlots = u.RemainingSlots, + UrgencyScore = u.UrgencyScore, + }) + .ToList(), + NextShifts = data.NextShifts + .Select(s => new MySignupItem { - var now = _clock.GetCurrentInstant(); - var userSignups = await _shiftSignup.GetByUserAsync(user.Id, activeEvent.Id); - pendingCount = userSignups - .Where(s => s.Status == SignupStatus.Pending) - .Select(s => s.SignupBlockId ?? s.Id) - .Distinct() - .Count(); - - foreach (var s in userSignups.Where(s => s.Status == SignupStatus.Confirmed)) - { - try - { - if (s.Shift is null) - { - _logger.LogWarning("Skipping signup {SignupId} on dashboard because shift data was missing", s.Id); - continue; - } - - var item = new MySignupItem - { - Signup = s, - DepartmentName = s.Shift.Rota?.Team?.Name ?? "Unknown", - AbsoluteStart = s.Shift.GetAbsoluteStart(activeEvent), - AbsoluteEnd = s.Shift.GetAbsoluteEnd(activeEvent) - }; - if (item.AbsoluteEnd > now) - nextShifts.Add(item); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to build shift item for signup {SignupId}", s.Id); - } - } - - nextShifts = nextShifts.OrderBy(i => i.AbsoluteStart).Take(3).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load user signups for dashboard"); - } - - ViewData["ShiftCards"] = new ShiftCardsViewModel - { - UrgentShifts = urgentItems, - NextShifts = nextShifts, - PendingCount = pendingCount - }; - - // Pass shift signup state to ThingsToDo wizard - viewModel.HasShiftSignups = nextShifts.Count > 0 || pendingCount > 0; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load shift cards for dashboard"); - } - - // Per-user ticket status — always renders, shows warning when unconfigured - viewModel.TicketPurchaseUrl = "https://tickets.nobodies.team"; - viewModel.TicketsConfigured = _ticketSettings.IsConfigured; - try - { - if (_ticketSettings.IsConfigured) - { - var ticketCount = await _ticketQueryService.GetUserTicketCountAsync(user.Id); - viewModel.HasTicket = ticketCount > 0; - viewModel.UserTicketCount = ticketCount; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load ticket status for user {UserId}", user.Id); - } - - // Event participation status - try - { - if (activeEvent is not null && activeEvent.Year > 0) - { - viewModel.EventYear = activeEvent.Year; - var participation = await _userService.GetParticipationAsync(user.Id, activeEvent.Year); - viewModel.ParticipationStatus = participation?.Status; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load participation status for user {UserId}", user.Id); - } + Signup = s.Signup, + DepartmentName = s.DepartmentName, + AbsoluteStart = s.AbsoluteStart, + AbsoluteEnd = s.AbsoluteEnd, + }) + .ToList(), + PendingCount = data.PendingSignupCount, + }; return View("Dashboard", viewModel); } diff --git a/src/Humans.Web/Controllers/ProfileController.cs b/src/Humans.Web/Controllers/ProfileController.cs index 00b955ccc..951703ed3 100644 --- a/src/Humans.Web/Controllers/ProfileController.cs +++ b/src/Humans.Web/Controllers/ProfileController.cs @@ -190,6 +190,7 @@ public async Task Edit([FromQuery] bool preview = false, Cancella var viewModel = new ProfileViewModel { Id = profile?.Id ?? Guid.Empty, + UserId = user.Id, Email = user.Email ?? string.Empty, DisplayName = user.DisplayName, ProfilePictureUrl = user.ProfilePictureUrl, diff --git a/src/Humans.Web/Extensions/InfrastructureServiceCollectionExtensions.cs b/src/Humans.Web/Extensions/InfrastructureServiceCollectionExtensions.cs index 20938a5e9..fdf55928d 100644 --- a/src/Humans.Web/Extensions/InfrastructureServiceCollectionExtensions.cs +++ b/src/Humans.Web/Extensions/InfrastructureServiceCollectionExtensions.cs @@ -163,6 +163,7 @@ public static IServiceCollection AddHumansInfrastructure( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); diff --git a/src/Humans.Web/ViewComponents/UserAvatarViewComponent.cs b/src/Humans.Web/ViewComponents/UserAvatarViewComponent.cs index 7dc553684..a86880f81 100644 --- a/src/Humans.Web/ViewComponents/UserAvatarViewComponent.cs +++ b/src/Humans.Web/ViewComponents/UserAvatarViewComponent.cs @@ -1,33 +1,57 @@ +using System.Security.Claims; using Humans.Application.Interfaces; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; namespace Humans.Web.ViewComponents; +/// +/// Renders a user's avatar by looking up all display data from the cached profile +/// given only a user ID. Resolution precedence: +/// 1. Custom uploaded picture (served via /Profile/{profileId}/Picture) +/// 2. Google OAuth User.ProfilePictureUrl +/// 3. Initial-letter fallback from the display name +/// public class UserAvatarViewComponent : ViewComponent { private readonly IProfileService _profileService; + private readonly IUrlHelperFactory _urlHelperFactory; - public UserAvatarViewComponent(IProfileService profileService) + public UserAvatarViewComponent( + IProfileService profileService, + IUrlHelperFactory urlHelperFactory) { _profileService = profileService; + _urlHelperFactory = urlHelperFactory; } - public IViewComponentResult Invoke( - string? profilePictureUrl = null, - string? displayName = null, + public async Task InvokeAsync( + Guid userId, int size = 40, string? cssClass = null, - string bgColor = "bg-secondary", - Guid? userId = null) + string bgColor = "bg-secondary") { - // Resolve from cache when userId is provided and displayName/profilePictureUrl are not - if (userId.HasValue && userId.Value != Guid.Empty && string.IsNullOrEmpty(displayName)) + string? profilePictureUrl = null; + string? displayName = null; + + if (userId != Guid.Empty) { - var cached = _profileService.GetCachedProfile(userId.Value); + // Warm the cache if cold — GetCachedProfile is a pure cache hit. + var cached = _profileService.GetCachedProfile(userId) + ?? await _profileService.GetCachedProfileAsync(userId); + if (cached is not null) { displayName = cached.DisplayName; - profilePictureUrl ??= cached.ProfilePictureUrl; + profilePictureUrl = ResolveAvatarUrl(cached); + } + else if (IsCurrentUser(userId)) + { + // Onboarding/guest users have no Profile row yet. Fall back to the Google + // claims on the signed-in principal so their own avatar still renders in + // the nav and dashboard until a Profile is created. + displayName = UserClaimsPrincipal.FindFirstValue(ClaimTypes.Name); + profilePictureUrl = UserClaimsPrincipal.FindFirstValue("urn:google:picture"); } } @@ -46,4 +70,24 @@ public IViewComponentResult Invoke( return View(); } + + private string? ResolveAvatarUrl(CachedProfile cached) + { + if (cached.HasCustomPicture && cached.ProfileId != Guid.Empty) + { + var urlHelper = _urlHelperFactory.GetUrlHelper(ViewContext); + return urlHelper.Action( + action: "Picture", + controller: "Profile", + values: new { id = cached.ProfileId, v = cached.UpdatedAtTicks }); + } + + return cached.ProfilePictureUrl; + } + + private bool IsCurrentUser(Guid userId) + { + var claim = UserClaimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(claim, out var currentUserId) && currentUserId == userId; + } } diff --git a/src/Humans.Web/Views/Application/ApplicationDetail.cshtml b/src/Humans.Web/Views/Application/ApplicationDetail.cshtml index 0b63276f1..245fad19f 100644 --- a/src/Humans.Web/Views/Application/ApplicationDetail.cshtml +++ b/src/Humans.Web/Views/Application/ApplicationDetail.cshtml @@ -24,8 +24,7 @@
@Localizer["AdminApp_Applicant"]
-
diff --git a/src/Humans.Web/Views/Google/_SyncTabContent.cshtml b/src/Humans.Web/Views/Google/_SyncTabContent.cshtml index 1893d7f9b..de95e1328 100644 --- a/src/Humans.Web/Views/Google/_SyncTabContent.cshtml +++ b/src/Humans.Web/Views/Google/_SyncTabContent.cshtml @@ -188,13 +188,7 @@ else - @await Component.InvokeAsync("UserAvatar", new - { - profilePictureUrl = m.ProfilePictureUrl, - displayName = nameDisplay, - size = 24, - cssClass = "me-2" - }) + @nameDisplay } diff --git a/src/Humans.Web/Views/Home/Dashboard.cshtml b/src/Humans.Web/Views/Home/Dashboard.cshtml index 9cdd3e89d..248f6594c 100644 --- a/src/Humans.Web/Views/Home/Dashboard.cshtml +++ b/src/Humans.Web/Views/Home/Dashboard.cshtml @@ -6,8 +6,7 @@
-
@@ -72,7 +71,7 @@
} } - else if (Model.IsShiftBrowsingOpen) + else if (Model.IsShiftBrowsingOpen && Model.IsVolunteerMember) { @* Guided shift discovery — shown when user has no upcoming shifts *@
diff --git a/src/Humans.Web/Views/Profile/AdminDetail.cshtml b/src/Humans.Web/Views/Profile/AdminDetail.cshtml index 0b6a0403e..c31bf263b 100644 --- a/src/Humans.Web/Views/Profile/AdminDetail.cshtml +++ b/src/Humans.Web/Views/Profile/AdminDetail.cshtml @@ -23,8 +23,7 @@
-
diff --git a/src/Humans.Web/Views/Profile/Edit.cshtml b/src/Humans.Web/Views/Profile/Edit.cshtml index 3c425e9ac..71f4315e8 100644 --- a/src/Humans.Web/Views/Profile/Edit.cshtml +++ b/src/Humans.Web/Views/Profile/Edit.cshtml @@ -38,8 +38,7 @@
@Localizer["Profile_ProfilePicture"]
- @if (!Model.HasCustomProfilePicture && !string.IsNullOrEmpty(Model.ProfilePictureUrl)) { diff --git a/src/Humans.Web/Views/Profile/Search.cshtml b/src/Humans.Web/Views/Profile/Search.cshtml index fe4c243ec..76f2ce351 100644 --- a/src/Humans.Web/Views/Profile/Search.cshtml +++ b/src/Humans.Web/Views/Profile/Search.cshtml @@ -40,8 +40,7 @@ {
-
diff --git a/src/Humans.Web/Views/Shared/Components/ProfileCard/Default.cshtml b/src/Humans.Web/Views/Shared/Components/ProfileCard/Default.cshtml index 2520825b5..fde8281c4 100644 --- a/src/Humans.Web/Views/Shared/Components/ProfileCard/Default.cshtml +++ b/src/Humans.Web/Views/Shared/Components/ProfileCard/Default.cshtml @@ -13,8 +13,7 @@ @* Photo + Name + Pronouns + Status + Teams *@
-
diff --git a/src/Humans.Web/Views/Shared/_HumanPopover.cshtml b/src/Humans.Web/Views/Shared/_HumanPopover.cshtml index 48eddce2e..62df98ac3 100644 --- a/src/Humans.Web/Views/Shared/_HumanPopover.cshtml +++ b/src/Humans.Web/Views/Shared/_HumanPopover.cshtml @@ -1,7 +1,7 @@ @model Humans.Web.Models.ProfileSummaryViewModel
- +
@Model.DisplayName
@if (Model.IsSuspended) diff --git a/src/Humans.Web/Views/Shared/_LoginPartial.cshtml b/src/Humans.Web/Views/Shared/_LoginPartial.cshtml index 6cb4b9582..cbe9fd3cb 100644 --- a/src/Humans.Web/Views/Shared/_LoginPartial.cshtml +++ b/src/Humans.Web/Views/Shared/_LoginPartial.cshtml @@ -13,12 +13,10 @@ data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false"> - @await Component.InvokeAsync("UserAvatar", new { - profilePictureUrl = currentUser?.ProfilePictureUrl, - displayName = displayName, - size = 32, - bgColor = "bg-secondary" - }) + @if (currentUser is not null) + { + + } @displayName