Skip to content
Merged
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
107 changes: 107 additions & 0 deletions src/Humans.Web/Controllers/AdminController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,111 @@ public IActionResult ResetCacheStats()

return RedirectToAction(nameof(CacheStats));
}

[HttpGet("Audience")]
[Authorize(Policy = PolicyNames.AdminOnly)]
public async Task<IActionResult> AudienceSegmentation(int? year)
{
try
{
var allUserIds = await _dbContext.Users
.Select(u => u.Id)
.ToListAsync();

var profileUserIds = await _dbContext.Profiles
.Select(p => p.UserId)
.ToHashSetAsync();

// Get user IDs with tickets, optionally filtered by year
HashSet<Guid> ticketUserIds;
if (year.HasValue)
{
var yearStart = new DateTime(year.Value, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var yearEnd = new DateTime(year.Value + 1, 1, 1, 0, 0, 0, DateTimeKind.Utc);

var orderUserIds = await _dbContext.TicketOrders
.Where(o => o.MatchedUserId != null &&
o.PurchasedAt >= NodaTime.Instant.FromDateTimeUtc(yearStart) &&
o.PurchasedAt < NodaTime.Instant.FromDateTimeUtc(yearEnd))
.Select(o => o.MatchedUserId!.Value)
.Distinct()
.ToListAsync();

var attendeeUserIds = await _dbContext.TicketAttendees
.Where(a => a.MatchedUserId != null &&
a.TicketOrder.PurchasedAt >= NodaTime.Instant.FromDateTimeUtc(yearStart) &&
a.TicketOrder.PurchasedAt < NodaTime.Instant.FromDateTimeUtc(yearEnd))
.Select(a => a.MatchedUserId!.Value)
.Distinct()
.ToListAsync();

ticketUserIds = orderUserIds.Concat(attendeeUserIds).ToHashSet();
}
else
{
var orderUserIds = await _dbContext.TicketOrders
.Where(o => o.MatchedUserId != null)
.Select(o => o.MatchedUserId!.Value)
.Distinct()
.ToListAsync();

var attendeeUserIds = await _dbContext.TicketAttendees
.Where(a => a.MatchedUserId != null)
.Select(a => a.MatchedUserId!.Value)
.Distinct()
.ToListAsync();

ticketUserIds = orderUserIds.Concat(attendeeUserIds).ToHashSet();
}

var totalAccounts = allUserIds.Count;
var withProfile = 0;
var withTicket = 0;
var withBoth = 0;
var withNeither = 0;

foreach (var userId in allUserIds)
{
var hasProfile = profileUserIds.Contains(userId);
var hasTicket = ticketUserIds.Contains(userId);

if (hasProfile) withProfile++;
if (hasTicket) withTicket++;
if (hasProfile && hasTicket) withBoth++;
if (!hasProfile && !hasTicket) withNeither++;
}

// Get available years from ticket orders
var availableYears = await _dbContext.TicketOrders
.Where(o => o.MatchedUserId != null)
.Select(o => o.PurchasedAt)
.Distinct()
.ToListAsync();

var years = availableYears
.Select(i => i.ToDateTimeUtc().Year)
.Distinct()
.OrderDescending()
.ToList();

var model = new AudienceSegmentationViewModel
{
TotalAccounts = totalAccounts,
WithTicket = withTicket,
WithProfile = withProfile,
WithBoth = withBoth,
WithNeither = withNeither,
AvailableYears = years,
SelectedYear = year,
};

return View(model);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading audience segmentation data");
SetError("Failed to load audience segmentation data.");
return RedirectToAction(nameof(Index));
}
}
}
55 changes: 45 additions & 10 deletions src/Humans.Web/Controllers/UnsubscribeController.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System.Security.Cryptography;
using Humans.Application.Interfaces;
using Humans.Domain.Entities;
using Humans.Domain.Enums;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Humans.Infrastructure.Data;

Expand All @@ -12,17 +14,20 @@ public class UnsubscribeController : Controller
private readonly HumansDbContext _db;
private readonly ICommunicationPreferenceService _preferenceService;
private readonly IDataProtectionProvider _dataProtection;
private readonly SignInManager<User> _signInManager;
private readonly ILogger<UnsubscribeController> _logger;

public UnsubscribeController(
HumansDbContext db,
ICommunicationPreferenceService preferenceService,
IDataProtectionProvider dataProtection,
SignInManager<User> signInManager,
ILogger<UnsubscribeController> logger)
{
_db = db;
_preferenceService = preferenceService;
_dataProtection = dataProtection;
_signInManager = signInManager;
_logger = logger;
}

Expand All @@ -38,9 +43,13 @@ public async Task<IActionResult> Index(string token)
if (user is null)
return NotFound();

ViewData["DisplayName"] = user.DisplayName;
ViewData["CategoryName"] = category.ToDisplayName();
return View();
// Sign in the user and redirect to communication preferences
await _signInManager.SignInAsync(user, isPersistent: false);
_logger.LogInformation(
"User {UserId} authenticated via unsubscribe link for {Category}",
userId, category);

return RedirectToCommunicationPreferences(user);
}

// Fall back to legacy campaign-only token
Expand All @@ -62,8 +71,13 @@ public async Task<IActionResult> Confirm(string token)

await _preferenceService.UpdatePreferenceAsync(userId, category, optedOut: true, source: "MagicLink");

ViewData["CategoryName"] = category.ToDisplayName();
return View("Done");
// Sign in and redirect to communication preferences
await _signInManager.SignInAsync(user, isPersistent: false);
_logger.LogInformation(
"User {UserId} unsubscribed from {Category} and authenticated via unsubscribe link",
userId, category);

return RedirectToCommunicationPreferences(user);
}

// Fall back to legacy campaign-only token
Expand Down Expand Up @@ -102,6 +116,18 @@ public async Task<IActionResult> OneClick([FromQuery] string token)
}
}

/// <summary>
/// Redirects to the appropriate communication preferences page based on whether the user has a profile.
/// Profileless users go to Guest/CommunicationPreferences; users with profiles go to Profile/CommunicationPreferences.
/// </summary>
private IActionResult RedirectToCommunicationPreferences(User user)
{
var hasProfile = _db.Profiles.Any(p => p.UserId == user.Id);
return hasProfile
? RedirectToAction(nameof(ProfileController.CommunicationPreferences), "Profile")
: RedirectToAction(nameof(GuestController.CommunicationPreferences), "Guest");
}

private async Task<IActionResult> TryLegacyToken(string token)
{
var protector = _dataProtection
Expand All @@ -126,9 +152,13 @@ private async Task<IActionResult> TryLegacyToken(string token)
if (user is null)
return NotFound();

ViewData["DisplayName"] = user.DisplayName;
ViewData["CategoryName"] = MessageCategory.Marketing.ToDisplayName();
return View();
// Sign in the user and redirect to communication preferences
await _signInManager.SignInAsync(user, isPersistent: false);
_logger.LogInformation(
"User {UserId} authenticated via legacy unsubscribe link",
userId);

return RedirectToCommunicationPreferences(user);
}

private async Task<IActionResult> TryLegacyConfirm(string token)
Expand Down Expand Up @@ -158,7 +188,12 @@ private async Task<IActionResult> TryLegacyConfirm(string token)
// Use the new preference service for legacy tokens too
await _preferenceService.UpdatePreferenceAsync(userId, MessageCategory.Marketing, optedOut: true, source: "MagicLink");

ViewData["CategoryName"] = MessageCategory.Marketing.ToDisplayName();
return View("Done");
// Sign in and redirect to communication preferences
await _signInManager.SignInAsync(user, isPersistent: false);
_logger.LogInformation(
"User {UserId} unsubscribed from Marketing and authenticated via legacy unsubscribe link",
userId);

return RedirectToCommunicationPreferences(user);
}
}
19 changes: 19 additions & 0 deletions src/Humans.Web/Models/AdminViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,22 @@ public class DuplicateAccountDetailViewModel
public List<string> Account1EmailSources { get; set; } = [];
public List<string> Account2EmailSources { get; set; } = [];
}

/// <summary>
/// Audience segmentation gauges for admin view.
/// Shows total accounts, accounts with tickets, with profiles, both, or neither.
/// </summary>
public class AudienceSegmentationViewModel
{
public int TotalAccounts { get; set; }
public int WithTicket { get; set; }
public int WithProfile { get; set; }
public int WithBoth { get; set; }
public int WithNeither { get; set; }

/// <summary>Available event years for filtering (e.g. 2025, 2026).</summary>
public List<int> AvailableYears { get; set; } = [];

/// <summary>Currently selected event year filter, or null for all time.</summary>
public int? SelectedYear { get; set; }
}
Loading
Loading