fix(email): preserve draft send identity
CI and Deploy / test (pull_request) Successful in 4m15s
CI and Deploy / deploy (pull_request) Has been skipped

Persist and export each draft's idempotency UUID so refresh and edits cannot create a fresh delivery identity. Add reversible provider-specific migration SQL.
This commit is contained in:
cesnimda
2026-08-10 10:11:04 +02:00
parent 108440d361
commit 80b5532c2f
12 changed files with 2778 additions and 4 deletions
@@ -100,6 +100,7 @@ namespace JobTrackerApi.Controllers
draft.Subject,
draft.BodyText,
draft.ThreadId,
draft.ClientRequestId,
draft.Revision,
draft.CreatedAtUtc,
draft.UpdatedAtUtc))
@@ -25,6 +25,7 @@ public sealed class EmailDraftsController(
string Subject,
string BodyText,
string? ThreadId,
string ClientRequestId,
long Revision,
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc);
@@ -41,15 +42,17 @@ public sealed class EmailDraftsController(
[HttpGet]
public async Task<ActionResult<IReadOnlyList<DraftDto>>> List(
[FromQuery] int jobApplicationId,
[FromQuery] int? jobApplicationId,
CancellationToken cancellationToken)
{
var ownerUserId = GetOwnerUserId();
if (ownerUserId is null) return Unauthorized();
if (jobApplicationId <= 0) return BadRequest("A valid job application is required.");
if (jobApplicationId is <= 0) return BadRequest("A valid job application is required.");
return Ok(await db.EmailDrafts.AsNoTracking()
.Where(draft => draft.OwnerUserId == ownerUserId && draft.JobApplicationId == jobApplicationId)
var query = db.EmailDrafts.AsNoTracking().Where(draft => draft.OwnerUserId == ownerUserId);
if (jobApplicationId.HasValue)
query = query.Where(draft => draft.JobApplicationId == jobApplicationId.Value);
return Ok(await query
.OrderByDescending(draft => draft.UpdatedAtUtc)
.Select(draft => new DraftDto(
draft.Id,
@@ -59,6 +62,7 @@ public sealed class EmailDraftsController(
draft.Subject,
draft.BodyText,
draft.ThreadId,
draft.ClientRequestId,
draft.Revision,
draft.CreatedAtUtc,
draft.UpdatedAtUtc))
@@ -103,6 +107,7 @@ public sealed class EmailDraftsController(
Subject = subject,
BodyText = bodyText,
ThreadId = threadId,
ClientRequestId = Guid.NewGuid().ToString("D"),
Revision = 1,
CreatedAtUtc = now,
UpdatedAtUtc = now,
@@ -193,6 +198,7 @@ public sealed class EmailDraftsController(
draft.Subject,
draft.BodyText,
draft.ThreadId,
draft.ClientRequestId,
draft.Revision,
draft.CreatedAtUtc,
draft.UpdatedAtUtc);
+1
View File
@@ -288,6 +288,7 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<EmailDraft>().Property(x => x.To).HasMaxLength(320);
modelBuilder.Entity<EmailDraft>().Property(x => x.Subject).HasMaxLength(998);
modelBuilder.Entity<EmailDraft>().Property(x => x.ThreadId).HasMaxLength(512);
modelBuilder.Entity<EmailDraft>().Property(x => x.ClientRequestId).HasMaxLength(128);
modelBuilder.Entity<EmailDraft>()
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.UpdatedAtUtc });
modelBuilder.Entity<EmailDraft>()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddEmailDraftClientRequestId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
{
migrationBuilder.Sql("""
ALTER TABLE `EmailDrafts`
ADD `ClientRequestId` varchar(128) NOT NULL DEFAULT '';
""");
return;
}
migrationBuilder.AddColumn<string>(
name: "ClientRequestId",
table: "EmailDrafts",
type: "TEXT",
maxLength: 128,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ClientRequestId",
table: "EmailDrafts");
}
}
}
@@ -1108,6 +1108,11 @@ namespace JobTrackerApi.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ClientRequestId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
+2
View File
@@ -11,6 +11,7 @@ public sealed class EmailDraft
public string Subject { get; set; } = string.Empty;
public string BodyText { get; set; } = string.Empty;
public string? ThreadId { get; set; }
public string ClientRequestId { get; set; } = string.Empty;
public long Revision { get; set; } = 1;
public DateTime CreatedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
@@ -24,6 +25,7 @@ public sealed record EmailDraftExport(
string Subject,
string BodyText,
string? ThreadId,
string ClientRequestId,
long Revision,
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc);
@@ -88,6 +88,7 @@ public sealed class DailyExportHostedService(
draft.Subject,
draft.BodyText,
draft.ThreadId,
draft.ClientRequestId,
draft.Revision,
draft.CreatedAtUtc,
draft.UpdatedAtUtc))