feat: complete release readiness work #28
@@ -0,0 +1,104 @@
|
|||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class EmailSendAttemptStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Creation_is_idempotent_and_rejects_payload_reuse()
|
||||||
|
{
|
||||||
|
await using var fixture = await Fixture.CreateAsync();
|
||||||
|
var store = fixture.Store("user-1");
|
||||||
|
var requestId = Guid.NewGuid().ToString();
|
||||||
|
var first = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default);
|
||||||
|
var duplicate = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default);
|
||||||
|
|
||||||
|
Assert.True(first.Created);
|
||||||
|
Assert.False(duplicate.Created);
|
||||||
|
Assert.Equal(first.Attempt.Id, duplicate.Attempt.Id);
|
||||||
|
await Assert.ThrowsAsync<EmailSendConflictException>(() =>
|
||||||
|
store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('b')), default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task State_machine_never_restarts_terminal_or_uncertain_attempts()
|
||||||
|
{
|
||||||
|
await using var fixture = await Fixture.CreateAsync();
|
||||||
|
var store = fixture.Store("user-1");
|
||||||
|
var created = await store.CreateAsync(new(fixture.JobId, "microsoft", Guid.NewGuid().ToString(), Hash('c')), default);
|
||||||
|
|
||||||
|
Assert.Equal(1, await store.BeginAsync(created.Attempt.Id, default));
|
||||||
|
Assert.Equal(0, await store.BeginAsync(created.Attempt.Id, default));
|
||||||
|
Assert.Equal(1, await store.MarkUncertainAsync(created.Attempt.Id, "transport_interrupted", default));
|
||||||
|
Assert.Equal(0, await store.MarkSentAsync(created.Attempt.Id, "late-message", default));
|
||||||
|
|
||||||
|
var stored = await store.GetAsync(created.Attempt.Id, default);
|
||||||
|
Assert.Equal(EmailSendStatuses.Uncertain, stored!.Status);
|
||||||
|
Assert.Equal("transport_interrupted", stored.FailureCategory);
|
||||||
|
Assert.NotNull(stored.CompletedAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Direct_attempt_ids_are_tenant_scoped_and_store_no_message_content()
|
||||||
|
{
|
||||||
|
await using var fixture = await Fixture.CreateAsync();
|
||||||
|
var ownerStore = fixture.Store("user-1");
|
||||||
|
var created = await ownerStore.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('d')), default);
|
||||||
|
|
||||||
|
Assert.Null(await fixture.Store("user-2").GetAsync(created.Attempt.Id, default));
|
||||||
|
Assert.DoesNotContain(typeof(EmailSendAttempt).GetProperties(), property =>
|
||||||
|
property.Name.Contains("Subject", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
property.Name.Contains("Body", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
property.Name.Contains("Recipient", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Hash(char value) => new(value, 64);
|
||||||
|
|
||||||
|
private sealed class Fixture(SqliteConnection connection, DbContextOptions<JobTrackerContext> options, int jobId) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly List<JobTrackerContext> contexts = new();
|
||||||
|
public int JobId { get; } = jobId;
|
||||||
|
|
||||||
|
public static async Task<Fixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options;
|
||||||
|
await using var db = CreateDb(options, "user-1");
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var job = new JobApplication { JobTitle = "Backend", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new Fixture(connection, options, job.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmailSendAttemptStore Store(string userId)
|
||||||
|
{
|
||||||
|
var db = CreateDb(options, userId);
|
||||||
|
contexts.Add(db);
|
||||||
|
return new EmailSendAttemptStore(db, TimeProvider.System);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JobTrackerContext CreateDb(DbContextOptions<JobTrackerContext> options, string userId)
|
||||||
|
{
|
||||||
|
var currentUser = new Mock<ICurrentUserService>();
|
||||||
|
currentUser.SetupGet(service => service.UserId).Returns(userId);
|
||||||
|
return new JobTrackerContext(options, currentUser.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var context in contexts) await context.DisposeAsync();
|
||||||
|
await connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ namespace JobTrackerApi.Data
|
|||||||
public DbSet<InterviewPrepItem> InterviewPrepItems => Set<InterviewPrepItem>();
|
public DbSet<InterviewPrepItem> InterviewPrepItems => Set<InterviewPrepItem>();
|
||||||
public DbSet<UserOperation> UserOperations => Set<UserOperation>();
|
public DbSet<UserOperation> UserOperations => Set<UserOperation>();
|
||||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||||
|
public DbSet<EmailSendAttempt> EmailSendAttempts => Set<EmailSendAttempt>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -259,6 +260,26 @@ namespace JobTrackerApi.Data
|
|||||||
.HasForeignKey<UserNotification>(x => x.OperationId)
|
.HasForeignKey<UserNotification>(x => x.OperationId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>()
|
||||||
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.Provider).HasMaxLength(32);
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.ClientRequestId).HasMaxLength(128);
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.PayloadHash).HasMaxLength(64);
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.Status).HasMaxLength(32);
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.ProviderMessageId).HasMaxLength(256);
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.FailureCategory).HasMaxLength(64);
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>()
|
||||||
|
.HasIndex(x => new { x.OwnerUserId, x.ClientRequestId })
|
||||||
|
.IsUnique();
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>()
|
||||||
|
.HasIndex(x => new { x.OwnerUserId, x.CreatedAtUtc });
|
||||||
|
modelBuilder.Entity<EmailSendAttempt>()
|
||||||
|
.HasOne(x => x.JobApplication)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.JobApplicationId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
modelBuilder.Entity<TailoredCvDraft>()
|
modelBuilder.Entity<TailoredCvDraft>()
|
||||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEmailSendAttempts : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE TABLE `EmailSendAttempts` (
|
||||||
|
`Id` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||||
|
`OwnerUserId` varchar(255) NOT NULL,
|
||||||
|
`JobApplicationId` int NOT NULL,
|
||||||
|
`Provider` varchar(32) NOT NULL,
|
||||||
|
`ClientRequestId` varchar(128) NOT NULL,
|
||||||
|
`PayloadHash` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||||
|
`Status` varchar(32) NOT NULL,
|
||||||
|
`ProviderMessageId` varchar(256) NULL,
|
||||||
|
`FailureCategory` varchar(64) NULL,
|
||||||
|
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||||
|
`StartedAtUtc` datetime(6) NULL,
|
||||||
|
`CompletedAtUtc` datetime(6) NULL,
|
||||||
|
CONSTRAINT `PK_EmailSendAttempts` PRIMARY KEY (`Id`),
|
||||||
|
CONSTRAINT `FK_EmailSendAttempts_JobApplications_JobApplicationId`
|
||||||
|
FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||||
|
) CHARACTER SET=utf8mb4;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "EmailSendAttempts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
OwnerUserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||||
|
JobApplicationId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Provider = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||||
|
ClientRequestId = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||||
|
PayloadHash = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||||
|
ProviderMessageId = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||||
|
FailureCategory = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
|
||||||
|
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
StartedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
CompletedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_EmailSendAttempts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EmailSendAttempts_JobApplications_JobApplicationId",
|
||||||
|
column: x => x.JobApplicationId,
|
||||||
|
principalTable: "JobApplications",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EmailSendAttempts_JobApplicationId",
|
||||||
|
table: "EmailSendAttempts",
|
||||||
|
column: "JobApplicationId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EmailSendAttempts_OwnerUserId_ClientRequestId",
|
||||||
|
table: "EmailSendAttempts",
|
||||||
|
columns: new[] { "OwnerUserId", "ClientRequestId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EmailSendAttempts_OwnerUserId_CreatedAtUtc",
|
||||||
|
table: "EmailSendAttempts",
|
||||||
|
columns: new[] { "OwnerUserId", "CreatedAtUtc" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "EmailSendAttempts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1098,6 +1098,69 @@ namespace JobTrackerApi.Migrations
|
|||||||
b.ToTable("CvVariantVersions");
|
b.ToTable("CvVariantVersions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClientRequestId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FailureCategory")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("JobApplicationId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("OwnerUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PayloadHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Provider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderMessageId")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("JobApplicationId");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId", "ClientRequestId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId", "CreatedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("EmailSendAttempts");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b =>
|
modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -2383,6 +2446,17 @@ namespace JobTrackerApi.Migrations
|
|||||||
b.Navigation("CvVariant");
|
b.Navigation("CvVariant");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("JobApplicationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("JobApplication");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b =>
|
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
|
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace JobTrackerApi.Models;
|
||||||
|
|
||||||
|
public static class EmailSendStatuses
|
||||||
|
{
|
||||||
|
public const string Pending = "pending";
|
||||||
|
public const string Sending = "sending";
|
||||||
|
public const string Sent = "sent";
|
||||||
|
public const string Failed = "failed";
|
||||||
|
public const string Uncertain = "uncertain";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class EmailSendAttempt
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string OwnerUserId { get; set; } = string.Empty;
|
||||||
|
public int JobApplicationId { get; set; }
|
||||||
|
public JobApplication JobApplication { get; set; } = null!;
|
||||||
|
public string Provider { get; set; } = string.Empty;
|
||||||
|
public string ClientRequestId { get; set; } = string.Empty;
|
||||||
|
public string PayloadHash { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = EmailSendStatuses.Pending;
|
||||||
|
public string? ProviderMessageId { get; set; }
|
||||||
|
public string? FailureCategory { get; set; }
|
||||||
|
public DateTime CreatedAtUtc { get; set; }
|
||||||
|
public DateTime? StartedAtUtc { get; set; }
|
||||||
|
public DateTime? CompletedAtUtc { get; set; }
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ builder.Services.AddScoped<ICurrentUserService>(sp => sp.GetRequiredService<Curr
|
|||||||
builder.Services.AddSingleton<BackgroundTenantRunner>();
|
builder.Services.AddSingleton<BackgroundTenantRunner>();
|
||||||
builder.Services.AddSingleton(TimeProvider.System);
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
builder.Services.AddScoped<UserOperationStore>();
|
builder.Services.AddScoped<UserOperationStore>();
|
||||||
|
builder.Services.AddScoped<EmailSendAttemptStore>();
|
||||||
builder.Services.AddScoped<AiOperationAdmission>();
|
builder.Services.AddScoped<AiOperationAdmission>();
|
||||||
builder.Services.AddScoped<StrategySnapshotService>();
|
builder.Services.AddScoped<StrategySnapshotService>();
|
||||||
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
|
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
public sealed record CreateEmailSendAttempt(int JobApplicationId, string Provider, string ClientRequestId, string PayloadHash);
|
||||||
|
public sealed record EmailSendAttemptCreation(EmailSendAttempt Attempt, bool Created);
|
||||||
|
|
||||||
|
public sealed class EmailSendConflictException(string message) : InvalidOperationException(message);
|
||||||
|
|
||||||
|
public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
private DateTime UtcNow => timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
public Task<EmailSendAttempt?> GetAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureOwnerScope();
|
||||||
|
return db.EmailSendAttempts.AsNoTracking().FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EmailSendAttemptCreation> CreateAsync(CreateEmailSendAttempt request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ownerUserId = db.CurrentUserId ?? throw new InvalidOperationException("Email send creation requires an authenticated owner scope.");
|
||||||
|
Validate(request);
|
||||||
|
if (!await db.JobApplications.AnyAsync(job => job.Id == request.JobApplicationId, cancellationToken))
|
||||||
|
throw new InvalidOperationException("The job application does not exist in the current owner scope.");
|
||||||
|
|
||||||
|
var existing = await db.EmailSendAttempts.FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken);
|
||||||
|
if (existing is not null) return Existing(existing, request.PayloadHash);
|
||||||
|
|
||||||
|
var attempt = new EmailSendAttempt
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
OwnerUserId = ownerUserId,
|
||||||
|
JobApplicationId = request.JobApplicationId,
|
||||||
|
Provider = request.Provider.Trim().ToLowerInvariant(),
|
||||||
|
ClientRequestId = request.ClientRequestId.Trim(),
|
||||||
|
PayloadHash = request.PayloadHash.Trim().ToLowerInvariant(),
|
||||||
|
CreatedAtUtc = UtcNow,
|
||||||
|
};
|
||||||
|
db.EmailSendAttempts.Add(attempt);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return new EmailSendAttemptCreation(attempt, true);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
db.Entry(attempt).State = EntityState.Detached;
|
||||||
|
existing = await db.EmailSendAttempts.FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken);
|
||||||
|
if (existing is not null) return Existing(existing, request.PayloadHash);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> BeginAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureOwnerScope();
|
||||||
|
var now = UtcNow;
|
||||||
|
return db.EmailSendAttempts
|
||||||
|
.Where(item => item.Id == id && item.Status == EmailSendStatuses.Pending)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(item => item.Status, EmailSendStatuses.Sending)
|
||||||
|
.SetProperty(item => item.StartedAtUtc, now), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> MarkSentAsync(Guid id, string? providerMessageId, CancellationToken cancellationToken) =>
|
||||||
|
CompleteAsync(id, EmailSendStatuses.Sent, providerMessageId, null, cancellationToken);
|
||||||
|
|
||||||
|
public Task<int> MarkFailedAsync(Guid id, string failureCategory, CancellationToken cancellationToken) =>
|
||||||
|
CompleteAsync(id, EmailSendStatuses.Failed, null, failureCategory, cancellationToken);
|
||||||
|
|
||||||
|
public Task<int> MarkUncertainAsync(Guid id, string failureCategory, CancellationToken cancellationToken) =>
|
||||||
|
CompleteAsync(id, EmailSendStatuses.Uncertain, null, failureCategory, cancellationToken);
|
||||||
|
|
||||||
|
private Task<int> CompleteAsync(Guid id, string status, string? providerMessageId, string? failureCategory, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureOwnerScope();
|
||||||
|
ValidateOptional(providerMessageId, 256, nameof(providerMessageId));
|
||||||
|
ValidateOptional(failureCategory, 64, nameof(failureCategory));
|
||||||
|
var now = UtcNow;
|
||||||
|
return db.EmailSendAttempts
|
||||||
|
.Where(item => item.Id == id && item.Status == EmailSendStatuses.Sending)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(item => item.Status, status)
|
||||||
|
.SetProperty(item => item.ProviderMessageId, providerMessageId)
|
||||||
|
.SetProperty(item => item.FailureCategory, failureCategory)
|
||||||
|
.SetProperty(item => item.CompletedAtUtc, now), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EmailSendAttemptCreation Existing(EmailSendAttempt existing, string payloadHash)
|
||||||
|
{
|
||||||
|
if (!string.Equals(existing.PayloadHash, payloadHash.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new EmailSendConflictException("The client request ID was already used for different email content.");
|
||||||
|
return new EmailSendAttemptCreation(existing, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(CreateEmailSendAttempt request)
|
||||||
|
{
|
||||||
|
if (request.JobApplicationId <= 0) throw new ArgumentOutOfRangeException(nameof(request.JobApplicationId));
|
||||||
|
ValidateRequired(request.Provider, 32, nameof(request.Provider));
|
||||||
|
ValidateRequired(request.ClientRequestId, 128, nameof(request.ClientRequestId));
|
||||||
|
ValidateRequired(request.PayloadHash, 64, nameof(request.PayloadHash));
|
||||||
|
if (!Guid.TryParse(request.ClientRequestId, out _)) throw new ArgumentException("Client request ID must be a UUID.", nameof(request.ClientRequestId));
|
||||||
|
if (request.PayloadHash.Length != 64 || request.PayloadHash.Any(value => !Uri.IsHexDigit(value)))
|
||||||
|
throw new ArgumentException("Payload hash must be a SHA-256 hex digest.", nameof(request.PayloadHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateRequired(string value, int maxLength, string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value) || value.Trim().Length > maxLength) throw new ArgumentException($"{name} is required and must be at most {maxLength} characters.", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateOptional(string? value, int maxLength, string name)
|
||||||
|
{
|
||||||
|
if (value?.Length > maxLength) throw new ArgumentException($"{name} must be at most {maxLength} characters.", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureOwnerScope()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(db.CurrentUserId)) throw new InvalidOperationException("Email send access requires an authenticated owner scope.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user