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
2 changes: 1 addition & 1 deletion .github/workflows/pr-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ concurrency:
cancel-in-progress: true

env:
DOTNET_VERSION: '8.0.x'
DOTNET_VERSION: '10.0.x'
NODE_VERSION: '20.x'
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/simple-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ jobs:
- name: 🏗️ Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '8.0.x'
dotnet-version: '10.0.x'

- name: 🏗️ Setup Node.js
uses: actions/setup-node@v5
with:
Expand Down Expand Up @@ -213,7 +213,7 @@ jobs:
- name: 🏗️ Setup .NET (EF tools)
uses: actions/setup-dotnet@v5
with:
dotnet-version: '8.0.x'
dotnet-version: '10.0.x'

- name: 🔧 Install Azure CLI
run: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@ public Task SendAbandonedUploadNudgeAsync(string userId, string? email, string?
NudgeCalls.Add(new NudgeCall(userId, email, firstName));
return Task.CompletedTask;
}

public Task<bool> SendMarketingEmailAsync(string userId, string email, string subject, string htmlBody, string unsubscribeUrl) => Task.FromResult(true);
public string RenderMarketingEmailPreview(string subject, string htmlBody) => htmlBody;
}

private sealed record NudgeCall(string UserId, string? Email, string? FirstName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ private sealed class NoopEmailNotificationService : IEmailNotificationService
public Task SendWelcomeAsync(string userId, string? email, string? firstName = null) => Task.CompletedTask;
public Task SendRetentionDeletionWarningAsync(string userId, string? email, int imageCount, DateTime deletionDate, int daysUntilDeletion) => Task.CompletedTask;
public Task SendAbandonedUploadNudgeAsync(string userId, string? email, string? firstName = null) => Task.CompletedTask;
public Task<bool> SendMarketingEmailAsync(string userId, string email, string subject, string htmlBody, string unsubscribeUrl) => Task.FromResult(true);
public string RenderMarketingEmailPreview(string subject, string htmlBody) => htmlBody;
}

private sealed class FakeStorageService : IStorageService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,8 @@ public Task SendRetentionDeletionWarningAsync(string userId, string? email, int
}

public Task SendAbandonedUploadNudgeAsync(string userId, string? email, string? firstName = null) => Task.CompletedTask;
public Task<bool> SendMarketingEmailAsync(string userId, string email, string subject, string htmlBody, string unsubscribeUrl) => Task.FromResult(true);
public string RenderMarketingEmailPreview(string subject, string htmlBody) => htmlBody;
}

private sealed record RetentionWarningCall(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,8 @@ private sealed class DummyEmailNotificationService : IEmailNotificationService
public Task SendWelcomeAsync(string userId, string? email, string? firstName = null) => Task.CompletedTask;
public Task SendRetentionDeletionWarningAsync(string userId, string? email, int imageCount, DateTime deletionDate, int daysUntilDeletion) => Task.CompletedTask;
public Task SendAbandonedUploadNudgeAsync(string userId, string? email, string? firstName = null) => Task.CompletedTask;
public Task<bool> SendMarketingEmailAsync(string userId, string email, string subject, string htmlBody, string unsubscribeUrl) => Task.FromResult(true);
public string RenderMarketingEmailPreview(string subject, string htmlBody) => htmlBody;
}

private sealed class DummyCouponService : ICouponService
Expand Down
6 changes: 6 additions & 0 deletions AI.ProfilePhotoMaker.API/Configuration/EmailOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,10 @@ public class EmailOptions
public string? SupportToEmail { get; set; }

public string? SupportToName { get; set; }

/// <summary>
/// Shared secret sent by Postmark in the Authorization header for webhook callbacks.
/// Configure Postmark to send: Authorization: Bearer {value}
/// </summary>
public string? PostmarkWebhookSecret { get; set; }
}
17 changes: 17 additions & 0 deletions AI.ProfilePhotoMaker.API/Configuration/MarketingEmailOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace AI.ProfilePhotoMaker.API.Configuration;

public class MarketingEmailOptions
{
public const string SectionName = "MarketingEmail";

public bool Enabled { get; set; } = true;
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromMinutes(1);
public TimeSpan ErrorRetryDelay { get; set; } = TimeSpan.FromMinutes(5);

/// <summary>Number of recipients to process per iteration to respect Postmark rate limits.</summary>
public int BatchSize { get; set; } = 50;

/// <summary>Delay between batches in milliseconds.</summary>
public int BatchDelayMs { get; set; } = 1000;
}
195 changes: 195 additions & 0 deletions AI.ProfilePhotoMaker.API/Controllers/MarketingCampaignController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
using AI.ProfilePhotoMaker.API.Models.DTOs;
using AI.ProfilePhotoMaker.API.Services.Marketing;
using AI.ProfilePhotoMaker.API.Services.Notifications;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace AI.ProfilePhotoMaker.API.Controllers;

[ApiController]
[Route("api/admin/campaigns")]
[Authorize(Roles = "Admin")]
public class MarketingCampaignController : BaseController
{
private readonly IMarketingEmailService _marketingService;
private readonly IUserSegmentService _segmentService;
private readonly IEmailNotificationService _emailService;

public MarketingCampaignController(
IMarketingEmailService marketingService,
IUserSegmentService segmentService,
IEmailNotificationService emailService,
ILogger<MarketingCampaignController> logger)
: base(logger)
{
_marketingService = marketingService;
_segmentService = segmentService;
_emailService = emailService;
}

[HttpPost]
public async Task<IActionResult> CreateCampaign([FromBody] CreateCampaignRequest request)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

return await ExecuteAsync(async () =>
{
var campaign = await _marketingService.CreateCampaignAsync(request, GetCurrentUserId()!);
return CampaignDetailDto.From(campaign);
}, "CreateCampaign");
}

[HttpGet]
public async Task<IActionResult> GetCampaigns([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

var (campaigns, total) = await _marketingService.GetCampaignsAsync(page, pageSize);
return SuccessResponse(new
{
campaigns = campaigns.Select(CampaignDto.From),
totalCount = total,
page,
pageSize
});
}

[HttpGet("{id:guid}")]
public async Task<IActionResult> GetCampaign(Guid id)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

var campaign = await _marketingService.GetCampaignAsync(id);
if (campaign == null) return NotFoundResponse("Campaign", id);
return SuccessResponse(CampaignDetailDto.From(campaign));
}

[HttpPatch("{id:guid}")]
public async Task<IActionResult> UpdateCampaign(Guid id, [FromBody] UpdateCampaignRequest request)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

return await ExecuteAsync(async () =>
{
var campaign = await _marketingService.UpdateCampaignAsync(id, request);
return CampaignDetailDto.From(campaign);
}, "UpdateCampaign");
}

[HttpPost("{id:guid}/schedule")]
public async Task<IActionResult> ScheduleCampaign(Guid id, [FromBody] ScheduleCampaignRequest request)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

return await ExecuteAsync(async () =>
{
await _marketingService.ScheduleCampaignAsync(id, request.ScheduledAt);
return new { scheduled = true };
}, "ScheduleCampaign");
}

[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> CancelCampaign(Guid id)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

return await ExecuteAsync(async () =>
{
await _marketingService.CancelCampaignAsync(id);
return new { cancelled = true };
}, "CancelCampaign");
}

[HttpPost("{id:guid}/test")]
public async Task<IActionResult> SendTest(Guid id, [FromBody] SendTestRequest request)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

return await ExecuteAsync(async () =>
{
await _marketingService.SendTestAsync(id, request.TestEmail);
return new { sent = true, testEmail = request.TestEmail };
}, "SendTest");
}

[HttpPost("{id:guid}/duplicate")]
public async Task<IActionResult> DuplicateCampaign(Guid id)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

return await ExecuteAsync(async () =>
{
var copy = await _marketingService.DuplicateCampaignAsync(id, GetCurrentUserId()!);
return CampaignDetailDto.From(copy);
}, "DuplicateCampaign");
}

[HttpGet("{id:guid}/recipients")]
public async Task<IActionResult> GetRecipients(Guid id, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

return await ExecuteAsync(async () =>
{
var recipients = await _marketingService.GetRecipientsAsync(id, page, pageSize);
return new { recipients = recipients.Select(r => new { r.UserId, r.Email }), page, pageSize };
}, "GetRecipients");
}

[HttpGet("{id:guid}/logs")]
public async Task<IActionResult> GetLogs(Guid id, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

var (logs, total) = await _marketingService.GetLogsAsync(id, page, pageSize);
return SuccessResponse(new
{
logs = logs.Select(EmailLogDto.From),
totalCount = total,
page,
pageSize
});
}

/// <summary>
/// Returns the fully-rendered HTML for a campaign email, for preview in a browser or iframe.
/// </summary>
[HttpGet("{id:guid}/preview")]
public async Task<IActionResult> PreviewEmail(Guid id)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

var campaign = await _marketingService.GetCampaignAsync(id);
if (campaign == null) return NotFoundResponse("Campaign", id);

var html = _emailService.RenderMarketingEmailPreview(campaign.Subject, campaign.HtmlBody);
return Content(html, "text/html");
}

[HttpPost("segments/preview")]
public async Task<IActionResult> PreviewSegment([FromBody] SegmentPreviewRequest request)
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

return await ExecuteAsync(async () =>
{
if (!await _segmentService.IsValidSegmentFilter(request.SegmentFilter))
throw new ArgumentException($"Unknown segment filter: {request.SegmentFilter}");

var count = await _segmentService.GetSegmentCountAsync(request.SegmentFilter);
return new { segmentFilter = request.SegmentFilter, count };
}, "PreviewSegment");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using AI.ProfilePhotoMaker.API.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace AI.ProfilePhotoMaker.API.Controllers;

[ApiController]
[Route("api/user/marketing")]
public class MarketingUnsubscribeController : BaseController
{
private readonly ApplicationDbContext _db;

public MarketingUnsubscribeController(
ApplicationDbContext db,
ILogger<MarketingUnsubscribeController> logger)
: base(logger, db)
{
_db = db;
}

/// <summary>
/// Unsubscribe a user from marketing emails via token embedded in emails.
/// Accepts GET (link click) or POST (programmatic).
/// </summary>
[HttpGet("unsubscribe")]
[HttpPost("unsubscribe")]
public async Task<IActionResult> Unsubscribe([FromQuery] string token)
{
if (string.IsNullOrWhiteSpace(token))
return ValidationError("Missing unsubscribe token");

string userId;
try
{
var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(token));
userId = decoded.Split(':')[0];
}
catch
{
return ValidationError("Invalid unsubscribe token");
}

if (string.IsNullOrWhiteSpace(userId) || userId == "test")
return SuccessResponse(new { unsubscribed = true });

var user = await _db.Users.FindAsync(userId);
if (user == null) return NotFoundResponse("User");

if (!user.MarketingOptOut)
{
user.MarketingOptOut = true;
user.MarketingOptOutAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
Logger.LogInformation("User {UserId} unsubscribed from marketing emails", Sid(userId));
}

return SuccessResponse(new { unsubscribed = true });
}

/// <summary>
/// Returns the current user's marketing opt-out status.
/// </summary>
[HttpGet("status")]
[Microsoft.AspNetCore.Authorization.Authorize]
public async Task<IActionResult> GetStatus()
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

var userId = GetCurrentUserId()!;
var user = await _db.Users.Where(u => u.Id == userId)
.Select(u => new { u.MarketingOptOut, u.MarketingOptOutAt })
.FirstOrDefaultAsync();

if (user == null) return NotFoundResponse("User");
return SuccessResponse(new { optedOut = user.MarketingOptOut, optedOutAt = user.MarketingOptOutAt });
}

/// <summary>
/// Re-subscribe the current authenticated user to marketing emails.
/// </summary>
[HttpPost("resubscribe")]
[Microsoft.AspNetCore.Authorization.Authorize]
public async Task<IActionResult> Resubscribe()
{
var authCheck = ValidateAuthentication();
if (authCheck != null) return authCheck;

var userId = GetCurrentUserId()!;
var user = await _db.Users.FindAsync(userId);
if (user == null) return NotFoundResponse("User");

user.MarketingOptOut = false;
user.MarketingOptOutAt = null;
await _db.SaveChangesAsync();

return SuccessResponse(new { resubscribed = true });
}
}
Loading
Loading